Compare commits
120
Commits
@@ -11,3 +11,5 @@ runtime/tmp/
|
||||
runtime/cache/
|
||||
logs/
|
||||
config/test_write_perms
|
||||
.pytmp/
|
||||
.pytest_cache/
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
title = "AIOS repository secret scan policy"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Exact non-secret fixtures: Task Admission idempotency test fixture and release-freeze git commit OIDs (generic-api-key false positive on 40-hex git hashes)"
|
||||
condition = "AND"
|
||||
paths = [
|
||||
'''tests/test_task_admission_bootstrap_http\.py$''',
|
||||
'''docs/V2R2C2-P0-convergence-20260909\.md$''',
|
||||
'''artifacts/round3-provider-auth/ledger\.json$''',
|
||||
]
|
||||
regexes = [
|
||||
'''idempotency-1''',
|
||||
'''f7f53d0568767d6deec2c47008a89f4d8413f93d''',
|
||||
]
|
||||
@@ -129,9 +129,12 @@ class AgentPullRunner:
|
||||
task = self.transport.poll(self.config.worker_id)
|
||||
if not task:
|
||||
return None
|
||||
task_id = str(task.get("task_id", ""))
|
||||
if not task_id:
|
||||
raise RunnerError("task_id is required")
|
||||
# A conforming transport may surface an idle/no-task response (e.g.
|
||||
# {"status":"idle"}) without a task_id. REQ-03c: treat it as "no task"
|
||||
# and return None instead of crashing the runner.
|
||||
if not isinstance(task, dict) or not task.get("task_id"):
|
||||
return None
|
||||
task_id = str(task["task_id"])
|
||||
if task_id in self.state.terminal:
|
||||
return {"status": "duplicate_suppressed", "task_id": task_id}
|
||||
claimed = dict(self.transport.claim(self.config.worker_id, task))
|
||||
@@ -140,9 +143,13 @@ class AgentPullRunner:
|
||||
return claimed
|
||||
|
||||
def renew(self, task_id: str) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
inflight = self.state.inflight.get(task_id)
|
||||
if inflight is None:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.renew(self.config.worker_id, task_id)
|
||||
claim_id = None
|
||||
if isinstance(inflight, dict):
|
||||
claim_id = inflight.get("claim_id")
|
||||
return self.transport.renew(self.config.worker_id, task_id, claim_id=claim_id)
|
||||
|
||||
def report_progress(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Small operational CLI for the Agent Pull Runner contract."""
|
||||
"""Small operational CLI for the Agent Pull Runner contract.
|
||||
|
||||
Default mode talks to a live governed TaskPlane HTTP API via HttpRunnerTransport
|
||||
(--base-url / --token). --self-test keeps an in-process stub transport for
|
||||
offline smoke checks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from agent_pull_runner import AgentPullRunner, RunnerConfig
|
||||
from task_plane.http_transport import HttpRunnerTransport
|
||||
|
||||
|
||||
class _NoopTransport:
|
||||
@@ -22,13 +28,26 @@ class _NoopTransport:
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS Agent Pull Runner")
|
||||
parser.add_argument("--worker-id", default="self-test-worker")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--token", default="")
|
||||
parser.add_argument("--actor-identity", default="")
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
args = parser.parse_args()
|
||||
runner = AgentPullRunner(RunnerConfig(worker_id=args.worker_id, fallback_seconds=60), _NoopTransport())
|
||||
if args.self_test:
|
||||
transport = _NoopTransport()
|
||||
else:
|
||||
transport = HttpRunnerTransport(
|
||||
args.base_url,
|
||||
token=args.token,
|
||||
actor_identity=args.actor_identity or None,
|
||||
)
|
||||
runner = AgentPullRunner(
|
||||
RunnerConfig(worker_id=args.worker_id, fallback_seconds=60), transport
|
||||
)
|
||||
if args.self_test:
|
||||
print(json.dumps({"status": "PASS", "registration": runner.register(), "fallback_seconds": runner.config.fallback_seconds}, sort_keys=True))
|
||||
return 0
|
||||
print(json.dumps({"status": "READY", "worker_id": args.worker_id}, sort_keys=True))
|
||||
print(json.dumps({"status": "READY", "worker_id": args.worker_id, "base_url": args.base_url}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Staging HTTP overlay for the exact Task lineage supersession capability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import ThreadingHTTPServer
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aios_task_lineage_supersession import (
|
||||
DECISION_SCHEMA,
|
||||
TaskLineageSupersessionController,
|
||||
TaskLineageSupersessionError,
|
||||
)
|
||||
|
||||
SUPERSESSION_ROUTE = "/v1/control/task-lineage-supersession"
|
||||
|
||||
|
||||
def _status(error: TaskLineageSupersessionError) -> int:
|
||||
return error.http_status
|
||||
|
||||
|
||||
class LineageSupersessionEndpoint:
|
||||
"""Framework-neutral endpoint; authentication must precede all parsing/mutation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path,
|
||||
verifier,
|
||||
authenticate_control: Callable[[Mapping[str, str]], Any],
|
||||
*,
|
||||
clock=None,
|
||||
identity_digest=None,
|
||||
) -> None:
|
||||
self.authenticate_control = authenticate_control
|
||||
self.controller = TaskLineageSupersessionController(
|
||||
db_path, verifier, clock=clock, identity_digest=identity_digest
|
||||
)
|
||||
|
||||
def post(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
self.authenticate_control(headers)
|
||||
try:
|
||||
result = self.controller.supersede(body)
|
||||
except TaskLineageSupersessionError as exc:
|
||||
return _status(exc), {"error": exc.code}
|
||||
return (200 if result.get("idempotent") else 201), result
|
||||
|
||||
|
||||
def build_runtime():
|
||||
"""Bind to the already-active recovery API and its formal Owner verifier lazily."""
|
||||
try:
|
||||
from aios_agent_task_api import APIConfig
|
||||
from aios_agent_task_api_p0_failed_lease_recovery import (
|
||||
P0FailedLeaseRecoveryHandler,
|
||||
P0FailedLeaseRecoveryTaskApplication,
|
||||
)
|
||||
from aios_agent_task_protocol import TaskProtocolError
|
||||
from aios_owner_approval_verifier import (
|
||||
GrantRootOwnerApprovalVerifier,
|
||||
OwnerApprovalVerifierError,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("LINEAGE_SUPERSESSION_RUNTIME_OVERLAY_UNAVAILABLE") from exc
|
||||
|
||||
# The active entrypoint layers failed- and active-lease recovery on top of
|
||||
# expired recovery. Inherit that current layer so adding lineage does not
|
||||
# silently remove either already-deployed recovery route.
|
||||
class Application(P0FailedLeaseRecoveryTaskApplication):
|
||||
def __init__(self, config: Any) -> None:
|
||||
super().__init__(config)
|
||||
try:
|
||||
verifier = GrantRootOwnerApprovalVerifier()
|
||||
except OwnerApprovalVerifierError as exc:
|
||||
raise TaskProtocolError(str(exc), 503) from exc
|
||||
self.lineage_supersession = LineageSupersessionEndpoint(
|
||||
config.db_path, verifier, self.authenticate_control
|
||||
)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
value = dict(super().health())
|
||||
value.update({
|
||||
"task_lineage_supersession": True,
|
||||
"task_lineage_supersession_schema": DECISION_SCHEMA,
|
||||
"task_lineage_supersession_exact_scope": True,
|
||||
"task_lineage_supersession_exact_cas": True,
|
||||
"task_lineage_supersession_append_only": True,
|
||||
"task_lineage_supersession_caller_payload": False,
|
||||
})
|
||||
return value
|
||||
|
||||
class Handler(P0FailedLeaseRecoveryHandler):
|
||||
app: Application | None = None
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != SUPERSESSION_ROUTE:
|
||||
super().do_POST()
|
||||
return
|
||||
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
status, result = self.app.lineage_supersession.post(
|
||||
self._headers(), self._body()
|
||||
)
|
||||
if "error" in result:
|
||||
raise TaskProtocolError(str(result["error"]), status)
|
||||
self._json(result, status)
|
||||
|
||||
self._run(action)
|
||||
|
||||
return APIConfig, Application, Handler
|
||||
|
||||
|
||||
def serve(config=None) -> None:
|
||||
APIConfig, Application, Handler = build_runtime()
|
||||
config = config or APIConfig.from_env()
|
||||
application = Application(config)
|
||||
Handler.app = application
|
||||
ThreadingHTTPServer((config.host, config.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""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
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Fail-closed Authority Git side-effect boundary.
|
||||
|
||||
Only this module may issue the narrowly scoped ref CAS primitives used by the
|
||||
durable executor. It never accepts force updates, arbitrary refs, tags, or
|
||||
direct production mutations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_BRANCH_RE = re.compile(r"^refs/heads/agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
|
||||
|
||||
class AuthorityGitError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise AuthorityGitError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorityGitExecutor:
|
||||
git_dir: Path
|
||||
worktree_root: Path = Path("/var/lib/aios-gitops/worktrees")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.git_dir.is_absolute() or self.git_dir.is_symlink():
|
||||
raise AuthorityGitError("AUTHORITY_GIT_DIR_INVALID")
|
||||
if not self.worktree_root.is_absolute() or self.worktree_root.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ROOT_INVALID")
|
||||
|
||||
def _git(self, args: Sequence[str], *, check: bool = True) -> str:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise AuthorityGitError("AUTHORITY_GIT_COMMAND_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
def read_ref(self, ref: str) -> str | None:
|
||||
if not ref.startswith("refs/") or ref.startswith(("refs/tags/", "refs/remotes/")):
|
||||
raise AuthorityGitError("REF_READ_SCOPE_DENIED")
|
||||
value = self._git(["rev-parse", "--verify", ref], check=False)
|
||||
return value if value and OID_RE.fullmatch(value) else None
|
||||
|
||||
def commit_exists(self, oid: str) -> bool:
|
||||
_oid(oid, "commit_oid")
|
||||
result = subprocess.run(["git", f"--git-dir={self.git_dir}", "cat-file", "-e", oid + "^{commit}"], capture_output=True, check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def ancestry_check(self, older_oid: str, newer_oid: str) -> bool:
|
||||
_oid(older_oid, "older_oid")
|
||||
_oid(newer_oid, "newer_oid")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "merge-base", "--is-ancestor", older_oid, newer_oid],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode not in (0, 1):
|
||||
raise AuthorityGitError("ANCESTRY_CHECK_FAILED")
|
||||
return result.returncode == 0
|
||||
|
||||
def create_detached_worktree(
|
||||
self,
|
||||
base_oid: str,
|
||||
worktree_name: str,
|
||||
root: Path | None = None,
|
||||
) -> Path:
|
||||
_oid(base_oid, "base_oid")
|
||||
if not worktree_name or "/" in worktree_name or "\\" in worktree_name or worktree_name in {".", ".."}:
|
||||
raise AuthorityGitError("WORKTREE_NAME_INVALID")
|
||||
parent = root or self.worktree_root
|
||||
if not parent.is_absolute() or parent.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ROOT_INVALID")
|
||||
path = parent / worktree_name
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ALREADY_EXISTS")
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||
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:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
current = self.read_ref(branch)
|
||||
if current != expected_oid:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
if not self.ancestry_check(expected_oid, new_oid):
|
||||
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||
self._git(["update-ref", branch, new_oid, expected_oid])
|
||||
readback = self.read_ref(branch)
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def ff_CAS_main_after_verified_protected_merge(self, expected_oid: str, new_oid: str) -> str:
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
if not self.ancestry_check(expected_oid, new_oid):
|
||||
raise AuthorityGitError("MAIN_FF_REQUIRED")
|
||||
current = self.read_ref("refs/heads/main")
|
||||
if current != expected_oid:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_MAIN_EXPECTATION_MISMATCH")
|
||||
self._git(["update-ref", "refs/heads/main", new_oid, expected_oid])
|
||||
readback = self.read_ref("refs/heads/main")
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_MAIN_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def commit_file_in_isolated_worktree(
|
||||
self,
|
||||
*,
|
||||
base_oid: str,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
worktree_name: str,
|
||||
) -> str:
|
||||
_oid(base_oid, "base_oid")
|
||||
if not worktree_name or "/" in worktree_name or "\\" in worktree_name or worktree_name in {".", ".."}:
|
||||
raise AuthorityGitError("WORKTREE_NAME_INVALID")
|
||||
root = self.worktree_root / worktree_name
|
||||
if root.exists() or root.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ALREADY_EXISTS")
|
||||
self.worktree_root.mkdir(parents=True, exist_ok=True)
|
||||
self._git(["worktree", "add", "--detach", str(root), base_oid])
|
||||
try:
|
||||
for relative, content in files.items():
|
||||
path = Path(relative)
|
||||
if path.is_absolute() or ".." in path.parts or not path.parts or path.parts[0] == ".git":
|
||||
raise AuthorityGitError("FILE_PATH_INVALID")
|
||||
target = root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "add", "--", *files.keys()],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("WORKTREE_ADD_FAILED")
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "-c", "user.name=aios-durable-job", "-c", "user.email=aios-durable-job@localhost", "commit", "--no-verify", "-m", message],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("WORKTREE_COMMIT_FAILED")
|
||||
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
|
||||
finally:
|
||||
subprocess.run(["git", f"--git-dir={self.git_dir}", "worktree", "remove", "--force", str(root)], capture_output=True, check=False)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Implementation Baseline 1.1 integrations for existing AIOS authorities."""
|
||||
BASELINE_VERSION = '1.1'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
"""Executable validation profile and thin Action client.
|
||||
|
||||
This supplied ZIP does not establish the online Task authority. Consequently
|
||||
the runnable profile creates an explicitly isolated validation authority. Online
|
||||
activation requires the authority mapping gates documented in this package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,Ed25519PublicKey
|
||||
from task_plane.store import TaskPlaneStore
|
||||
from aios_gitops_job_controller import JobStore
|
||||
from .authorization import AuthorizationService,sign_owner_delegation_grant
|
||||
from .host import SystemdHost
|
||||
from .lineage import LineageLedger
|
||||
from .operations import OperationRegistry
|
||||
from .runtime_contract import validate_contract
|
||||
from .service import BaselineService,Identity
|
||||
from .worker_policy import WorkerProfile
|
||||
|
||||
|
||||
def _unique_object(pairs):
|
||||
result={}
|
||||
for key,value in pairs:
|
||||
if key in result: raise ValueError('DUPLICATE_JSON_KEY')
|
||||
result[key]=value
|
||||
return result
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(_safe_read(Path(path).absolute()).decode('utf-8'),object_pairs_hook=_unique_object,
|
||||
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError('NON_FINITE_JSON')))
|
||||
|
||||
|
||||
def _path(value):
|
||||
if not isinstance(value,str): raise ValueError('PATH_INVALID')
|
||||
prefix='${CREDENTIALS_DIRECTORY}/'
|
||||
if value.startswith(prefix):
|
||||
directory=os.environ.get('CREDENTIALS_DIRECTORY')
|
||||
name=value[len(prefix):]
|
||||
if not directory or '/' in name or name in ('','.','..'): raise ValueError('CREDENTIAL_PATH_INVALID')
|
||||
value=str(Path(directory)/name)
|
||||
if '$' in value or not Path(value).is_absolute() or '..' in Path(value).parts:
|
||||
raise ValueError('PATH_MUST_BE_EXPLICIT_ABSOLUTE')
|
||||
return Path(value)
|
||||
|
||||
|
||||
def _safe_read(path,*,private=False):
|
||||
path=_path(str(path))
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
||||
try:
|
||||
info=os.fstat(fd)
|
||||
forbidden=0o077 if private else 0o022
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid not in {0,os.geteuid()} or info.st_mode & forbidden or info.st_size>65536:
|
||||
raise ValueError('CONFIG_OR_SECRET_FILE_PERMISSIONS_INVALID')
|
||||
with os.fdopen(fd,'rb',closefd=False) as stream:
|
||||
data=stream.read(65537)
|
||||
if len(data)>65536: raise ValueError('CONFIG_OR_SECRET_FILE_TOO_LARGE')
|
||||
return data
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
def secret(path):
|
||||
return _safe_read(path,private=True)
|
||||
|
||||
|
||||
def load_configuration(path):
|
||||
config=read_json(path)
|
||||
required={'schema','profile','state_dir','listen','machine_key_id','machine_key_file',
|
||||
'owner_public_key_files','owner_key_scopes','identities','workers','contracts','operations','host_bindings'}
|
||||
if type(config) is not dict or set(config)-{'receipt_public_key_files'}!=required or config['schema']!='aios.baseline-server-config.v1':
|
||||
raise ValueError('SERVER_CONFIG_FIELDS_INVALID')
|
||||
if config['profile']!='isolated-validation': raise ValueError('PROFILE_REQUIRES_VERIFIED_AUTHORITY_ADAPTER')
|
||||
listen=config['listen']
|
||||
if type(listen) is not dict or set(listen)!={'host','port'} or listen['host']!='127.0.0.1' or type(listen['port']) is not int or not 1024<=listen['port']<=65535:
|
||||
raise ValueError('LOOPBACK_LISTENER_REQUIRED')
|
||||
state=_path(config['state_dir'])
|
||||
if state.is_symlink() or state==Path('/') or state.name in {'aios','active-runtime','app'}:
|
||||
raise ValueError('ISOLATED_STATE_PATH_REQUIRED')
|
||||
if not isinstance(config['machine_key_id'],str) or not config['machine_key_id']: raise ValueError('MACHINE_ID_REQUIRED')
|
||||
machine=serialization.load_pem_private_key(secret(config['machine_key_file']),password=None)
|
||||
if not isinstance(machine,Ed25519PrivateKey): raise ValueError('ED25519_MACHINE_KEY_REQUIRED')
|
||||
keys={}
|
||||
for key_id,filename in config['owner_public_key_files'].items():
|
||||
public=serialization.load_pem_public_key(_safe_read(filename))
|
||||
if not isinstance(public,Ed25519PublicKey): raise ValueError('ED25519_OWNER_KEY_REQUIRED')
|
||||
keys[key_id]=public
|
||||
if not keys: raise ValueError('OWNER_TRUST_ROOT_REQUIRED')
|
||||
receipt_keys={}
|
||||
for key_id,filename in config.get('receipt_public_key_files',{}).items():
|
||||
public=serialization.load_pem_public_key(_safe_read(filename))
|
||||
if not isinstance(public,Ed25519PublicKey): raise ValueError('ED25519_RECEIPT_KEY_REQUIRED')
|
||||
receipt_keys[key_id]=public
|
||||
tokens={}
|
||||
for entry in config['identities']:
|
||||
if type(entry) is not dict or set(entry)-{'token_file','tenant_id','subject_id','role','worker_id'}:
|
||||
raise ValueError('IDENTITY_FIELDS_INVALID')
|
||||
token=secret(entry['token_file']).decode().strip()
|
||||
if len(token)<32 or len(token)>512 or any(x.isspace() for x in token) or token in tokens: raise ValueError('TOKEN_INVALID_OR_DUPLICATE')
|
||||
identity=Identity(**{k:v for k,v in entry.items() if k!='token_file'})
|
||||
if identity.role not in {'user','owner','worker'} or any(not isinstance(v,str) or not v for v in (identity.tenant_id,identity.subject_id)):
|
||||
raise ValueError('IDENTITY_INVALID')
|
||||
if (identity.role=='worker') != bool(identity.worker_id): raise ValueError('WORKER_IDENTITY_INVALID')
|
||||
tokens[token]=identity
|
||||
workers={}
|
||||
for entry in config['workers']:
|
||||
worker=WorkerProfile.from_dict(entry)
|
||||
if worker.worker_id in workers: raise ValueError('WORKER_DUPLICATE')
|
||||
workers[worker.worker_id]=worker
|
||||
for identity in tokens.values():
|
||||
if identity.role=='worker':
|
||||
worker=workers.get(identity.worker_id)
|
||||
if not worker or worker.identity!=identity.subject_id or identity.tenant_id not in worker.tenant_scope:
|
||||
raise ValueError('WORKER_IDENTITY_SCOPE_MISMATCH')
|
||||
contracts=[validate_contract(c) for c in config['contracts']]
|
||||
operations=OperationRegistry(config['operations'])
|
||||
host=SystemdHost(config['host_bindings'])
|
||||
config['_resolved']={'state':state,'machine':machine,'owner_keys':keys,'workers':workers,
|
||||
'contracts':contracts,'operations':operations,'host':host,'tokens':tokens,'receipt_keys':receipt_keys}
|
||||
return config,tokens
|
||||
|
||||
|
||||
def build_service(config):
|
||||
resolved=config['_resolved']
|
||||
state=resolved['state']
|
||||
marker=state/'ISOLATED_VALIDATION_AUTHORITY'
|
||||
if state.exists() and not marker.is_file() and any(state.iterdir()):
|
||||
raise ValueError('EXISTING_STATE_AUTHORITY_NOT_IDENTIFIED')
|
||||
if marker.exists() and _safe_read(marker)!=b'aios.baseline.isolated-validation.v1\n':
|
||||
raise ValueError('STATE_AUTHORITY_MARKER_INVALID')
|
||||
state.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
if state.stat().st_mode & 0o077: raise ValueError('STATE_DIRECTORY_PERMISSIONS_INVALID')
|
||||
if not marker.exists(): marker.write_text('aios.baseline.isolated-validation.v1\n')
|
||||
auth=AuthorizationService(state/'authorization.sqlite3',resolved['owner_keys'],resolved['machine'],config['machine_key_id'],
|
||||
owner_key_scopes=config['owner_key_scopes'],
|
||||
control_context_verifier=lambda ctx:isinstance(ctx,Identity) and ctx.role=='owner' and any(ctx is known for known in resolved['tokens'].values()))
|
||||
store=TaskPlaneStore(str(state/'tasks.sqlite3'),high_assurance=True)
|
||||
return BaselineService(store,state/'jobs',auth,resolved['contracts'],resolved['operations'],resolved['workers'],
|
||||
resolved['host'],LineageLedger(state/'lineage.sqlite3',store,JobStore(state/'jobs').read),resolved['machine'],
|
||||
machine_key_id=config['machine_key_id'],trusted_receipt_keys=resolved['receipt_keys'])
|
||||
|
||||
|
||||
def _write_json(path,value,mode=0o600):
|
||||
data=(json.dumps(value,ensure_ascii=False,sort_keys=True,indent=2,allow_nan=False)+'\n').encode()
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,mode)
|
||||
with os.fdopen(fd,'wb') as out: out.write(data)
|
||||
|
||||
|
||||
def initialize_validation(directory,*,unit):
|
||||
"""Create explicit local-only example identities, not production trust roots."""
|
||||
directory=Path(directory).absolute()
|
||||
directory.mkdir(mode=0o700) # Never overwrite an existing installation.
|
||||
# Validate unit syntax before writing any identity material.
|
||||
SystemdHost({'validation:staging:sample':unit})
|
||||
owner=Ed25519PrivateKey.generate()
|
||||
machine=Ed25519PrivateKey.generate()
|
||||
for name,key in [('owner-private.pem',owner),('machine-private.pem',machine)]:
|
||||
path=directory/name
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
out.write(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
|
||||
(directory/'owner-public.pem').write_bytes(owner.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
for name in ('user.token','worker.token'):
|
||||
fd=os.open(directory/name,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
with os.fdopen(fd,'w') as out: out.write(secrets.token_urlsafe(48)+'\n')
|
||||
operation={'operation_id':'service-status','version':1,'effect_class':'READ_ONLY','handler_ref':'host.service_status',
|
||||
'input_schema':'aios.operation.service-status.input.v1','required_permissions':['service-status'],'timeout_seconds':10,
|
||||
'readback':{'mode':'NONE','required':False},'retry':{'mode':'SAFE','max_attempts':2},'reconcile':{'mode':'NONE'},
|
||||
'rollback':{'mode':'NONE'},'owner_boundary':{'mode':'WITHIN_GRANT'}}
|
||||
contract={'contract_schema':'aios.runtime-contract.v1','contract_revision':1,'component_id':'sample','environment':'staging',
|
||||
'tenant_scope':'validation','lifecycle':'active','entrypoints':{'status':{'component_id':'sample','environment':'staging','resolver_ref':'service://sample/status'}},
|
||||
'bindings':{},'resource_refs':{},'credential_refs':{},'operations':['service-status']}
|
||||
config={'schema':'aios.baseline-server-config.v1','profile':'isolated-validation','state_dir':str(directory/'state'),
|
||||
'listen':{'host':'127.0.0.1','port':8769},'machine_key_id':'validation-machine','machine_key_file':str(directory/'machine-private.pem'),
|
||||
'owner_public_key_files':{'validation-owner':str(directory/'owner-public.pem')},
|
||||
'owner_key_scopes':{'validation-owner':{'tenant_ids':['validation'],'subject_ids':['owner']}},
|
||||
'identities':[{'token_file':str(directory/'user.token'),'tenant_id':'validation','subject_id':'owner','role':'owner'},
|
||||
{'token_file':str(directory/'worker.token'),'tenant_id':'validation','subject_id':'machine-worker','role':'worker','worker_id':'diagnostic-worker'}],
|
||||
'workers':[{'worker_id':'diagnostic-worker','identity':'machine-worker','tenant_scope':['validation'],'subject_scope':['owner'],
|
||||
'capabilities':['service-status'],'priority':10,'fallback_executor':False,'heartbeat_seconds':15,'max_concurrency':1}],
|
||||
'contracts':[contract],'operations':[operation],'host_bindings':{'validation:staging:sample':unit}}
|
||||
_write_json(directory/'server.json',config)
|
||||
_write_json(directory/'worker.json',{'schema':'aios.baseline-worker-config.v1','base_url':'http://127.0.0.1:8769',
|
||||
'token_file':str(directory/'worker.token'),'poll_seconds':2})
|
||||
grant=sign_owner_delegation_grant(owner,'validation-owner',grant_id='validation-grant',generation=1,
|
||||
tenant_id='validation',subject_id='owner',mission_ids=('validation-mission',),task_ids=('*',),operations=('service-status',),
|
||||
component_ids=('sample',),environments=('staging',),issued_at=int(time.time()),validity_mode='UNTIL_REVOKED',max_effects=20)
|
||||
_write_json(directory/'grant.json',grant)
|
||||
_write_json(directory/'goal.json',{'mission_id':'validation-mission','grant_id':'validation-grant','operation':'service-status',
|
||||
'component_id':'sample','environment':'staging','inputs':{},'intent':'检查已绑定 staging 服务并生成诊断结果'})
|
||||
return directory
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
sub=parser.add_subparsers(dest='command',required=True)
|
||||
init=sub.add_parser('init-validation');init.add_argument('--directory',required=True);init.add_argument('--service-unit',required=True)
|
||||
for name in ('preflight','server','grant-import','grant-revoke'):
|
||||
item=sub.add_parser(name);item.add_argument('--config',required=True)
|
||||
if name=='grant-import': item.add_argument('--grant',required=True)
|
||||
if name=='grant-revoke':
|
||||
item.add_argument('--grant',required=True);item.add_argument('--owner-token-file',required=True)
|
||||
worker=sub.add_parser('worker');worker.add_argument('--config',required=True);worker.add_argument('--once',action='store_true')
|
||||
for name in ('goal-submit','goal-explain','task-get','task-cancel'):
|
||||
item=sub.add_parser(name);item.add_argument('--base-url',default='http://127.0.0.1:8769');item.add_argument('--token-file',required=True)
|
||||
if name=='goal-submit': item.add_argument('--request',required=True);item.add_argument('--idempotency-key',required=True)
|
||||
elif name=='goal-explain': item.add_argument('--mission-id',required=True)
|
||||
else: item.add_argument('--task-id',required=True)
|
||||
args=parser.parse_args(argv)
|
||||
try:
|
||||
if args.command=='init-validation':
|
||||
print(json.dumps({'profile':'isolated-validation','directory':str(initialize_validation(args.directory,unit=args.service_unit))}));return 0
|
||||
if args.command in {'preflight','server','grant-import','grant-revoke'}:
|
||||
config,tokens=load_configuration(args.config)
|
||||
if args.command=='preflight':
|
||||
print(json.dumps({'status':'CONFIG_VALID','profile':config['profile'],'online_authority':'NOT_VERIFIED',
|
||||
'production_activation':'BLOCKED','registered_operations':len(config['operations'])}));return 0
|
||||
service=build_service(config)
|
||||
if args.command=='grant-import':
|
||||
grant=service.authorization.register_grant(read_json(args.grant))
|
||||
print(json.dumps({'grant_id':grant['payload']['grant_id'],'registered':True}));return 0
|
||||
if args.command=='grant-revoke':
|
||||
context=tokens.get(secret(args.owner_token_file).decode().strip())
|
||||
grant=read_json(args.grant)
|
||||
payload=grant['payload']
|
||||
if context is None or context.role!='owner' or payload['tenant_id']!=context.tenant_id or payload['subject_id']!=context.subject_id:
|
||||
raise ValueError('OWNER_SCOPE_DENIED')
|
||||
# Register verifies signature and ID before a target can be revoked.
|
||||
service.authorization.register_grant(grant)
|
||||
generation=service.authorization.revoke_grant(payload['grant_id'],control_context=context)
|
||||
print(json.dumps({'grant_id':payload['grant_id'],'revocation_generation':generation}));return 0
|
||||
from .transport import serve
|
||||
server=serve(service,tokens,**config['listen'])
|
||||
stop=threading.Event()
|
||||
def recover_loop():
|
||||
while not stop.wait(5):
|
||||
try: service.recover()
|
||||
except Exception: service.storage_blocked=True
|
||||
recovery=threading.Thread(target=recover_loop,daemon=True);recovery.start()
|
||||
def shutdown(_signal,_frame):
|
||||
stop.set();threading.Thread(target=server.shutdown,daemon=True).start()
|
||||
signal.signal(signal.SIGTERM,shutdown);signal.signal(signal.SIGINT,shutdown)
|
||||
print(json.dumps({'status':'LISTENING','host':config['listen']['host'],'port':config['listen']['port'],'profile':config['profile']}),flush=True)
|
||||
try: server.serve_forever(poll_interval=0.5)
|
||||
finally: stop.set();server.server_close()
|
||||
return 0
|
||||
from .transport import HttpTransport
|
||||
if args.command=='worker':
|
||||
from .worker import OperationWorker
|
||||
config=read_json(args.config)
|
||||
if type(config) is not dict or set(config)!={'schema','base_url','token_file','poll_seconds'} or config['schema']!='aios.baseline-worker-config.v1': raise ValueError('WORKER_CONFIG_INVALID')
|
||||
transport=HttpTransport(config['base_url'],secret(config['token_file']).decode().strip(),timeout_seconds=20)
|
||||
worker=OperationWorker(transport,poll_seconds=config['poll_seconds'])
|
||||
if args.once: print(json.dumps(worker.run_once(),ensure_ascii=False));return 0
|
||||
worker.run_forever();return 0
|
||||
transport=HttpTransport(args.base_url,secret(args.token_file).decode().strip(),timeout_seconds=20)
|
||||
if args.command=='goal-submit': result=transport.submit_goal(read_json(args.request),idempotency_key=args.idempotency_key)
|
||||
elif args.command=='goal-explain': result=transport.explain(args.mission_id)
|
||||
elif args.command=='task-get': result=transport.get_task(args.task_id)
|
||||
else: result=transport.cancel_task(args.task_id)
|
||||
print(json.dumps(result,ensure_ascii=False,indent=2,allow_nan=False));return 0
|
||||
except (Exception,KeyboardInterrupt) as exc:
|
||||
# Avoid dumping request bodies, credential material or Python tracebacks.
|
||||
text=str(exc)
|
||||
code=text if re.fullmatch(r'[A-Z][A-Z0-9_]{2,100}',text) else getattr(exc,'code',type(exc).__name__)
|
||||
print(json.dumps({'status':'ERROR','error_type':type(exc).__name__,'code':code,'message':'See local configuration and acceptance runbook; no action was reported successful.'}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Canonical, domain-separated machine receipts, distinct from Owner grants."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False).encode()
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class ReceiptSigner:
|
||||
def __init__(self, private_key, *, key_id='baseline-machine', trusted_public_keys=None):
|
||||
self.private_key=private_key
|
||||
self.public_key=private_key.public_key()
|
||||
self.key_id=key_id
|
||||
self.trusted_public_keys=dict(trusted_public_keys or {})
|
||||
self.trusted_public_keys[key_id]=self.public_key
|
||||
|
||||
def sign(self,payload):
|
||||
raw=canonical({'domain':'aios.operation-receipt.v1','key_id':self.key_id,'payload':payload})
|
||||
return {'schema':'aios.operation-receipt.v1','key_id':self.key_id,'payload':payload,
|
||||
'signature':base64.b64encode(self.private_key.sign(raw)).decode()}
|
||||
|
||||
def verify(self,envelope):
|
||||
try:
|
||||
if type(envelope) is not dict or set(envelope)!={'schema','key_id','payload','signature'} or envelope['schema']!='aios.operation-receipt.v1' or type(envelope['payload']) is not dict:
|
||||
return False
|
||||
public=self.trusted_public_keys.get(envelope['key_id'])
|
||||
if public is None: return False
|
||||
raw=canonical({'domain':envelope['schema'],'key_id':envelope['key_id'],'payload':envelope['payload']})
|
||||
public.verify(base64.b64decode(envelope['signature'],validate=True),raw)
|
||||
return True
|
||||
except (ValueError,TypeError,InvalidSignature,KeyError):
|
||||
return False
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Small registered host diagnostics; unit bindings come from trusted configuration."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SystemdHost:
|
||||
PROPERTIES = ('Id', 'ActiveState', 'SubState', 'MainPID', 'InvocationID')
|
||||
|
||||
def __init__(self, bindings: dict[str, str], *, executable: str = '/usr/bin/systemctl',
|
||||
timeout_seconds: float = 10):
|
||||
if type(timeout_seconds) not in (int,float) or not 0 < timeout_seconds <= 60:
|
||||
raise HostError('HOST_TIMEOUT_INVALID')
|
||||
self.bindings = dict(bindings)
|
||||
for binding,unit in self.bindings.items():
|
||||
if not isinstance(binding,str) or len(binding.split(':'))!=3 or any(not re.fullmatch(r'[A-Za-z0-9_.-]+',part) for part in binding.split(':')):
|
||||
raise HostError('HOST_TENANT_BINDING_INVALID')
|
||||
if not isinstance(unit, str) or not re.fullmatch(r'[A-Za-z0-9_.@:-]+\.service', unit):
|
||||
raise HostError('HOST_UNIT_INVALID')
|
||||
if not Path(executable).is_absolute() or not Path(executable).is_file() or Path(executable).is_symlink():
|
||||
raise HostError('HOST_EXECUTABLE_UNAVAILABLE')
|
||||
info=Path(executable).stat()
|
||||
if info.st_uid not in {0,os.geteuid()} or info.st_mode & 0o022:
|
||||
raise HostError('HOST_EXECUTABLE_UNTRUSTED')
|
||||
self._executable_identity=(info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns)
|
||||
self.executable = executable
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def status(self, component_id: str, environment: str, *, tenant_id: str, deadline=None) -> dict:
|
||||
if any(not isinstance(part,str) or not re.fullmatch(r'[A-Za-z0-9_.-]+',part) for part in (tenant_id,environment,component_id)):
|
||||
raise HostError('HOST_SCOPE_INVALID')
|
||||
unit = self.bindings.get(f'{tenant_id}:{environment}:{component_id}')
|
||||
if not unit:
|
||||
raise HostError('HOST_BINDING_NOT_FOUND')
|
||||
remaining=self.timeout_seconds if deadline is None else min(self.timeout_seconds,deadline-time.time())
|
||||
if remaining<=0: raise HostError('HOST_OPERATION_TIMEOUT')
|
||||
stop_at=time.monotonic()+remaining
|
||||
executable_fd=os.open(self.executable,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
||||
try:
|
||||
info=os.fstat(executable_fd)
|
||||
if not stat.S_ISREG(info.st_mode) or (info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns)!=self._executable_identity:
|
||||
raise HostError('HOST_EXECUTABLE_IDENTITY_CHANGED')
|
||||
executable_path=f'/proc/self/fd/{executable_fd}'
|
||||
if not Path(executable_path).exists(): raise HostError('HOST_EXECUTOR_PLATFORM_UNSUPPORTED')
|
||||
argv=[executable_path,'show','--no-pager','--property='+','.join(self.PROPERTIES),'--',unit]
|
||||
process=subprocess.Popen(argv,stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,
|
||||
env={'PATH':'/usr/bin:/bin','LANG':'C'},start_new_session=True,pass_fds=(executable_fd,))
|
||||
finally: os.close(executable_fd)
|
||||
output=bytearray()
|
||||
byte_count=0
|
||||
try:
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout,selectors.EVENT_READ,'stdout')
|
||||
selector.register(process.stderr,selectors.EVENT_READ,'stderr')
|
||||
while selector.get_map():
|
||||
remaining=stop_at-time.monotonic()
|
||||
if remaining<=0: raise HostError('HOST_OPERATION_TIMEOUT')
|
||||
for event,_mask in selector.select(remaining):
|
||||
chunk=os.read(event.fileobj.fileno(),4096)
|
||||
if not chunk:
|
||||
selector.unregister(event.fileobj)
|
||||
continue
|
||||
byte_count+=len(chunk)
|
||||
if byte_count>65536: raise HostError('HOST_RESPONSE_TOO_LARGE')
|
||||
if event.data=='stdout': output.extend(chunk)
|
||||
process.wait(timeout=max(0.001,stop_at-time.monotonic()))
|
||||
except (HostError,subprocess.TimeoutExpired) as exc:
|
||||
try: os.killpg(process.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
process.wait()
|
||||
if isinstance(exc,HostError): raise
|
||||
raise HostError('HOST_OPERATION_TIMEOUT') from exc
|
||||
finally:
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
if process.returncode != 0:
|
||||
raise HostError('HOST_STATUS_UNAVAILABLE')
|
||||
try: out=output.decode('utf-8')
|
||||
except UnicodeDecodeError as exc: raise HostError('HOST_OBSERVATION_INVALID') from exc
|
||||
properties = {}
|
||||
for line in out.splitlines():
|
||||
key, sep, value = line.partition('=')
|
||||
if sep and key in self.PROPERTIES:
|
||||
if key in properties:
|
||||
raise HostError('HOST_DUPLICATE_PROPERTY')
|
||||
properties[key] = value
|
||||
if set(properties) != set(self.PROPERTIES) or properties['Id'] != unit:
|
||||
raise HostError('HOST_OBSERVATION_INCOMPLETE')
|
||||
if not properties['MainPID'].isdigit():
|
||||
raise HostError('HOST_OBSERVATION_INVALID')
|
||||
return {'operation':'service-status', 'tenant_id':tenant_id, 'component_id':component_id, 'environment':environment,
|
||||
'unit':unit, 'healthy':properties['ActiveState'] == 'active',
|
||||
'properties':properties, 'observed_at':time.time(), 'source':'systemd.show'}
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Persistent correlation relationships for the AIOS baseline.
|
||||
|
||||
This is deliberately a relationship authority. It does not create or advance
|
||||
Tasks, Jobs, Releases, or Deployments. In particular, a Task is admitted only
|
||||
after the configured Task authority returns the real Task and its authenticated
|
||||
tenant/subject binding has been checked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
class LineageError(RuntimeError):
|
||||
"""Base class for persistent lineage failures."""
|
||||
|
||||
|
||||
class LineageConflict(LineageError):
|
||||
"""An opaque identity was already bound to different lineage."""
|
||||
|
||||
|
||||
class LineageVerificationError(LineageError):
|
||||
"""An external authority did not verify the requested relationship."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedAcceptanceEvidence:
|
||||
"""Typed output of a configured acceptance evidence verifier."""
|
||||
|
||||
acceptance_id: str
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
component_id: str
|
||||
deployment_id: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
accepted: bool
|
||||
evidence_id: str
|
||||
observed: Mapping[str, Any] | None = None
|
||||
observed_at: str | None = None
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text or len(text) > 512 or any(ord(char) < 32 for char in text):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
_SELF_HASH_FIELDS = {
|
||||
"manifest_sha256",
|
||||
"manifest_hash",
|
||||
"outer_manifest_hash",
|
||||
"outer_manifest_sha256",
|
||||
"self_hash",
|
||||
"self_sha256",
|
||||
}
|
||||
|
||||
|
||||
def canonical_artifact(artifact: Any) -> str:
|
||||
"""Return an exact artifact identity without permitting outer self-hashes."""
|
||||
|
||||
if not isinstance(artifact, (str, Mapping)):
|
||||
raise ValueError("artifact must be a string or object")
|
||||
if isinstance(artifact, str):
|
||||
_required(artifact, "artifact")
|
||||
else:
|
||||
if not artifact:
|
||||
raise ValueError("artifact must not be empty")
|
||||
if any(not isinstance(key,str) for key in artifact):
|
||||
raise ValueError("artifact object keys must be strings")
|
||||
if _SELF_HASH_FIELDS.intersection(key.lower() for key in artifact):
|
||||
raise ValueError("outer artifact manifest must not contain a self hash")
|
||||
try:
|
||||
return _json(artifact)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("artifact must be canonical JSON") from exc
|
||||
|
||||
|
||||
def decode_artifact(value: str) -> Any:
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
converter = getattr(value, "to_dict", None)
|
||||
if not callable(converter):
|
||||
converter = getattr(value, "as_dict", None)
|
||||
if callable(converter):
|
||||
try:
|
||||
converted = converter(redact_payload=False)
|
||||
except TypeError:
|
||||
converted = converter()
|
||||
if isinstance(converted, Mapping):
|
||||
return converted
|
||||
raise LineageVerificationError("task_authority returned an invalid Task")
|
||||
|
||||
|
||||
def _task_plan(task: Any) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
|
||||
raw = _mapping(task)
|
||||
plan: Any = raw.get("plan") or raw.get("task_plan")
|
||||
if plan is None and hasattr(task, "task_plan"):
|
||||
plan = getattr(task, "task_plan")
|
||||
if plan is None:
|
||||
plan = raw
|
||||
plan_map = _mapping(plan)
|
||||
return raw, plan_map
|
||||
|
||||
|
||||
class LineageLedger:
|
||||
"""SQLite-backed authority for cross-domain identity relationships."""
|
||||
|
||||
def __init__(self, path: str | Path, task_authority: Any, job_authority: Any):
|
||||
if task_authority is None:
|
||||
raise ValueError("task_authority is required")
|
||||
if job_authority is None:
|
||||
raise ValueError("job_authority is required")
|
||||
self.path = str(path)
|
||||
self.task_authority = task_authority
|
||||
self.job_authority = job_authority
|
||||
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
tables={row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "lineage_tasks" in tables and "lineage_schema_meta" not in tables:
|
||||
raise LineageConflict("unversioned candidate lineage schema requires explicit migration")
|
||||
if "lineage_schema_meta" in tables:
|
||||
version=connection.execute("SELECT version FROM lineage_schema_meta WHERE domain='lineage'").fetchone()
|
||||
if version is None or version[0] != 2:
|
||||
raise LineageConflict("unsupported lineage schema version")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS lineage_schema_meta (
|
||||
domain TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_tasks (
|
||||
tenant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
source_task_id TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, task_id),
|
||||
UNIQUE (tenant_id, task_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_task_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_effects (
|
||||
tenant_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
source_effect_id TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, effect_id),
|
||||
UNIQUE (tenant_id, job_id),
|
||||
UNIQUE (tenant_id, effect_id, subject_id, mission_id, component_id),
|
||||
UNIQUE (tenant_id, effect_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, task_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_effect_id, subject_id, mission_id, component_id)
|
||||
REFERENCES lineage_effects(tenant_id, effect_id, subject_id, mission_id, component_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_releases (
|
||||
tenant_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
source_task_id TEXT NOT NULL,
|
||||
source_effect_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, release_id),
|
||||
UNIQUE (tenant_id, effect_id),
|
||||
UNIQUE (tenant_id, release_id, subject_id, mission_id, component_id, artifact_json),
|
||||
FOREIGN KEY (tenant_id, effect_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_effects(tenant_id, effect_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_task_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id, subject_id, mission_id, component_id, grant_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_deployments (
|
||||
tenant_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, deployment_id),
|
||||
UNIQUE (tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json),
|
||||
FOREIGN KEY (tenant_id, release_id, subject_id, mission_id, component_id, artifact_json)
|
||||
REFERENCES lineage_releases(tenant_id, release_id, subject_id, mission_id, component_id, artifact_json)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_acceptances (
|
||||
tenant_id TEXT NOT NULL,
|
||||
acceptance_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL CHECK (accepted IN (0,1)),
|
||||
evidence_id TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, acceptance_id),
|
||||
FOREIGN KEY (tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json)
|
||||
REFERENCES lineage_deployments(
|
||||
tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json
|
||||
)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute("INSERT OR IGNORE INTO lineage_schema_meta(domain,version) VALUES('lineage',2)")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Compatibility hook; connections are transaction-scoped."""
|
||||
|
||||
def _authorized_task(self, tenant_id: str, subject_id: str, task_id: str) -> Any:
|
||||
authority = self.task_authority
|
||||
try:
|
||||
if hasattr(authority, "get_task"):
|
||||
task = authority.get_task(task_id)
|
||||
elif callable(authority):
|
||||
try:
|
||||
task = authority(tenant_id, subject_id, task_id)
|
||||
except TypeError:
|
||||
task = authority(task_id)
|
||||
else:
|
||||
raise TypeError("authority has no task lookup")
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("task_authority did not confirm task") from exc
|
||||
raw, plan = _task_plan(task)
|
||||
returned_id = raw.get("task_id") or plan.get("task_id") or getattr(task, "task_id", None)
|
||||
if str(returned_id or "") != task_id:
|
||||
raise LineageVerificationError("task_authority returned wrong task_id")
|
||||
issuer = plan.get("issuer") if isinstance(plan.get("issuer"), Mapping) else {}
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
tenant_values = [value for value in (issuer.get("tenant_id"), metadata.get("tenant_id"), baseline.get("tenant_id")) if value is not None]
|
||||
subject_values = [
|
||||
value for value in (issuer.get("subject_id"), metadata.get("subject_id"), baseline.get("subject_id")) if value is not None
|
||||
]
|
||||
# issuer.id is accepted as subject identity only when no explicit subject_id exists.
|
||||
if not subject_values and issuer.get("id") is not None:
|
||||
subject_values.append(issuer["id"])
|
||||
if not tenant_values or any(str(value) != tenant_id for value in tenant_values):
|
||||
raise LineageVerificationError("task_authority tenant mismatch")
|
||||
if not subject_values or any(str(value) != subject_id for value in subject_values):
|
||||
raise LineageVerificationError("task_authority subject mismatch")
|
||||
return task
|
||||
|
||||
def _authorized_job(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, grant_id: str,
|
||||
task_id: str, effect_id: str, job_id: str,
|
||||
) -> None:
|
||||
authority=self.job_authority
|
||||
try:
|
||||
if hasattr(authority,"get_job"):
|
||||
job=authority.get_job(job_id)
|
||||
elif hasattr(authority,"read"):
|
||||
job=authority.read(job_id)
|
||||
elif callable(authority):
|
||||
job=authority(job_id)
|
||||
else:
|
||||
raise TypeError("authority has no Job lookup")
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("job_authority did not confirm Job") from exc
|
||||
raw=_mapping(job)
|
||||
request=raw.get("request") or raw.get("binding")
|
||||
if request is None and isinstance(raw.get("request_json"),str):
|
||||
try:
|
||||
request=json.loads(raw["request_json"])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise LineageVerificationError("job_authority returned invalid request") from exc
|
||||
if not isinstance(request,Mapping):
|
||||
request=raw
|
||||
if isinstance(request.get("binding"),Mapping):
|
||||
request=request["binding"]
|
||||
bindings={
|
||||
"job_id":raw.get("job_id") or request.get("job_id"),
|
||||
"tenant_id":request.get("tenant_id"),"subject_id":request.get("subject_id"),
|
||||
"mission_id":request.get("mission_id"),"grant_id":request.get("grant_id"),
|
||||
"task_id":request.get("task_id"),"effect_id":request.get("effect_id"),
|
||||
}
|
||||
expected={"job_id":job_id,"tenant_id":tenant_id,"subject_id":subject_id,
|
||||
"mission_id":mission_id,"grant_id":grant_id,"task_id":task_id,"effect_id":effect_id}
|
||||
if any(str(bindings[key] or "") != value for key,value in expected.items()):
|
||||
raise LineageVerificationError("job_authority binding mismatch")
|
||||
|
||||
@staticmethod
|
||||
def _check_task_context(task: Any, mission_id: str, grant_id: str) -> None:
|
||||
_, plan = _task_plan(task)
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
mission_values = [value for value in (metadata.get("mission_id"), baseline.get("mission_id")) if value is not None]
|
||||
grant_values = [value for value in (metadata.get("grant_id"), baseline.get("grant_id")) if value is not None]
|
||||
if any(str(value) != mission_id for value in mission_values):
|
||||
raise LineageVerificationError("task_authority mission mismatch")
|
||||
if any(str(value) != grant_id for value in grant_values):
|
||||
raise LineageVerificationError("task_authority grant mismatch")
|
||||
|
||||
@staticmethod
|
||||
def _task_component(task: Any, requested: str | None) -> str:
|
||||
_, plan = _task_plan(task)
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
values=[value for value in (metadata.get("component_id"),baseline.get("component_id")) if value is not None]
|
||||
if requested is not None:
|
||||
requested=_required(requested,"component_id")
|
||||
if any(str(value) != requested for value in values):
|
||||
raise LineageVerificationError("task_authority component mismatch")
|
||||
return requested
|
||||
if not values or any(str(value) != str(values[0]) for value in values):
|
||||
raise LineageVerificationError("task_authority component missing or inconsistent")
|
||||
return _required(values[0],"component_id")
|
||||
|
||||
def link_task(
|
||||
self,
|
||||
tenant_id: str,
|
||||
subject_id: str,
|
||||
mission_id: str,
|
||||
grant_id: str,
|
||||
task_id: str,
|
||||
*,
|
||||
component_id: str | None = None,
|
||||
source_task_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, grant_id, task_id = (
|
||||
_required(tenant_id, "tenant_id"), _required(subject_id, "subject_id"),
|
||||
_required(mission_id, "mission_id"), _required(grant_id, "grant_id"),
|
||||
_required(task_id, "task_id"),
|
||||
)
|
||||
task = self._authorized_task(tenant_id, subject_id, task_id)
|
||||
self._check_task_context(task, mission_id, grant_id)
|
||||
component_id=self._task_component(task,component_id)
|
||||
source_task_id = _required(source_task_id, "source_task_id") if source_task_id else None
|
||||
raw_task, task_plan = _task_plan(task)
|
||||
authoritative_source = task_plan.get("parent_task_id") or raw_task.get("parent_task_id")
|
||||
if authoritative_source is not None:
|
||||
authoritative_source = _required(authoritative_source, "authoritative source_task_id")
|
||||
if source_task_id is not None and source_task_id != authoritative_source:
|
||||
raise LineageVerificationError("source_task_id does not match Task authority")
|
||||
source_task_id = authoritative_source
|
||||
metadata_json = _json(dict(metadata or {}))
|
||||
values = (subject_id, mission_id, component_id, grant_id, source_task_id)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM lineage_tasks WHERE tenant_id=? AND task_id=?", (tenant_id, task_id)
|
||||
).fetchone()
|
||||
if existing:
|
||||
if tuple(existing[key] for key in ("subject_id", "mission_id", "component_id", "grant_id", "source_task_id")) != values:
|
||||
raise LineageConflict("task_id already has different lineage")
|
||||
return self._task_row(existing)
|
||||
if source_task_id:
|
||||
self._require_row(connection, "lineage_tasks", "task_id", tenant_id, source_task_id, subject_id, mission_id, component_id)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_tasks VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id, task_id, subject_id, mission_id, component_id, grant_id, source_task_id, metadata_json, _now()),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM lineage_tasks WHERE tenant_id=? AND task_id=?", (tenant_id, task_id)
|
||||
).fetchone()
|
||||
return self._task_row(row)
|
||||
|
||||
@staticmethod
|
||||
def _require_row(connection, table, id_column, tenant_id, object_id, subject_id, mission_id, component_id=None):
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {table} WHERE tenant_id=? AND {id_column}=?", (tenant_id, object_id)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LineageConflict(f"{id_column} has no linked authority record")
|
||||
if row["subject_id"] != subject_id or row["mission_id"] != mission_id:
|
||||
raise LineageConflict(f"{id_column} belongs to different subject or mission")
|
||||
if component_id is not None and row["component_id"] != component_id:
|
||||
raise LineageConflict(f"{id_column} belongs to different component")
|
||||
return row
|
||||
|
||||
def link_effect(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, task_id: str,
|
||||
effect_id: str, job_id: str, *, source_effect_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, task_id, effect_id, job_id = map(
|
||||
lambda pair: _required(pair[0], pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(task_id,"task_id"),(effect_id,"effect_id"),(job_id,"job_id")),
|
||||
)
|
||||
source_effect_id = _required(source_effect_id, "source_effect_id") if source_effect_id else None
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
task=self._require_row(connection, "lineage_tasks", "task_id", tenant_id, task_id, subject_id, mission_id)
|
||||
component_id=task["component_id"]
|
||||
grant_id=task["grant_id"]
|
||||
self._authorized_job(tenant_id,subject_id,mission_id,grant_id,task_id,effect_id,job_id)
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM lineage_effects WHERE tenant_id=? AND effect_id=?", (tenant_id, effect_id)
|
||||
).fetchone()
|
||||
expected = (subject_id, mission_id, component_id, grant_id, task_id, job_id, source_effect_id)
|
||||
if existing:
|
||||
actual = tuple(existing[key] for key in ("subject_id","mission_id","component_id","grant_id","task_id","job_id","source_effect_id"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("effect_id already has different task/job identity")
|
||||
return self._effect_row(existing)
|
||||
job = connection.execute(
|
||||
"SELECT effect_id,task_id,subject_id FROM lineage_effects WHERE tenant_id=? AND job_id=?",
|
||||
(tenant_id, job_id),
|
||||
).fetchone()
|
||||
if job:
|
||||
raise LineageConflict("job_id already belongs to exactly one Task/effect")
|
||||
if source_effect_id:
|
||||
self._require_row(connection, "lineage_effects", "effect_id", tenant_id, source_effect_id, subject_id, mission_id, component_id)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_effects VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,effect_id,subject_id,mission_id,component_id,grant_id,task_id,job_id,source_effect_id,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row = connection.execute("SELECT * FROM lineage_effects WHERE tenant_id=? AND effect_id=?",(tenant_id,effect_id)).fetchone()
|
||||
return self._effect_row(row)
|
||||
|
||||
def link_release(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, effect_id: str,
|
||||
release_id: str, artifact: Any, *, metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, effect_id, release_id = map(
|
||||
lambda pair: _required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(effect_id,"effect_id"),(release_id,"release_id")),
|
||||
)
|
||||
artifact_json = canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
effect = self._require_row(connection,"lineage_effects","effect_id",tenant_id,effect_id,subject_id,mission_id)
|
||||
existing = connection.execute("SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=?",(tenant_id,release_id)).fetchone()
|
||||
expected = (subject_id,mission_id,effect["component_id"],effect["grant_id"],effect_id,effect["task_id"],effect_id,artifact_json)
|
||||
if existing:
|
||||
actual = tuple(existing[key] for key in ("subject_id","mission_id","component_id","grant_id","effect_id","source_task_id","source_effect_id","artifact_json"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("release_id already has different source lineage")
|
||||
return self._release_row(existing)
|
||||
producer = connection.execute("SELECT release_id FROM lineage_releases WHERE tenant_id=? AND effect_id=?",(tenant_id,effect_id)).fetchone()
|
||||
if producer:
|
||||
raise LineageConflict("release-producing effect already points to a unique release")
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_releases VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,release_id,subject_id,mission_id,effect["component_id"],effect["grant_id"],effect_id,effect["task_id"],effect_id,artifact_json,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=?",(tenant_id,release_id)).fetchone()
|
||||
return self._release_row(row)
|
||||
|
||||
def link_deployment(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, release_id: str,
|
||||
deployment_id: str, component_id: str, environment: str, target_revision: int, artifact: Any,
|
||||
*, metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id,release_id,deployment_id,component_id,environment=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(release_id,"release_id"),(deployment_id,"deployment_id"),(component_id,"component_id"),(environment,"environment")),
|
||||
)
|
||||
if not isinstance(target_revision,int) or isinstance(target_revision,bool) or target_revision < 1:
|
||||
raise ValueError("invalid target_revision")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
release=self._require_row(connection,"lineage_releases","release_id",tenant_id,release_id,subject_id,mission_id,component_id)
|
||||
if release["artifact_json"] != artifact_json:
|
||||
raise LineageConflict("deployment artifact does not match release")
|
||||
existing=connection.execute("SELECT * FROM lineage_deployments WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
expected=(subject_id,mission_id,component_id,release_id,environment,target_revision,artifact_json)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","release_id","environment","target_revision","artifact_json"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("deployment_id already has different release identity")
|
||||
return self._deployment_row(existing)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_deployments VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,deployment_id,subject_id,mission_id,component_id,release_id,environment,target_revision,artifact_json,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_deployments WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
return self._deployment_row(row)
|
||||
|
||||
def record_acceptance(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, acceptance_id: str,
|
||||
deployment_id: str, release_id: str, artifact: Any, accepted: bool, *,
|
||||
evidence: Any, verifier: Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence],
|
||||
) -> dict[str, Any]:
|
||||
if not callable(verifier):
|
||||
raise LineageVerificationError("acceptance verifier is required")
|
||||
tenant_id,subject_id,mission_id,component_id,acceptance_id,deployment_id,release_id=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),(component_id,"component_id"),
|
||||
(acceptance_id,"acceptance_id"),(deployment_id,"deployment_id"),(release_id,"release_id")),
|
||||
)
|
||||
if type(accepted) is not bool:
|
||||
raise ValueError("accepted must be boolean")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
deployment=self._require_row(connection,"lineage_deployments","deployment_id",tenant_id,deployment_id,subject_id,mission_id,component_id)
|
||||
if deployment["release_id"] != release_id:
|
||||
raise LineageConflict("acceptance release_id does not match deployment")
|
||||
if deployment["artifact_json"] != artifact_json:
|
||||
raise LineageConflict("acceptance artifact does not match deployment")
|
||||
expected={"acceptance_id":acceptance_id,"tenant_id":tenant_id,"subject_id":subject_id,"component_id":component_id,
|
||||
"deployment_id":deployment_id,"release_id":release_id,"artifact":decode_artifact(artifact_json),
|
||||
"accepted":accepted}
|
||||
try:
|
||||
verified=verifier(evidence,expected)
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("acceptance evidence was not verified") from exc
|
||||
if not isinstance(verified,VerifiedAcceptanceEvidence):
|
||||
raise LineageVerificationError("verifier must return VerifiedAcceptanceEvidence")
|
||||
bindings=(verified.acceptance_id,verified.tenant_id,verified.subject_id,verified.component_id,verified.deployment_id,verified.release_id,canonical_artifact(verified.artifact),verified.accepted)
|
||||
if bindings != (acceptance_id,tenant_id,subject_id,component_id,deployment_id,release_id,artifact_json,accepted):
|
||||
raise LineageVerificationError("verified acceptance evidence binding mismatch")
|
||||
evidence_id=_required(verified.evidence_id,"evidence_id")
|
||||
evidence_json=_json({
|
||||
"evidence_id":evidence_id,
|
||||
"observed":dict(verified.observed or {}),
|
||||
"observed_at":verified.observed_at,
|
||||
"signed_envelope":dict(evidence) if isinstance(evidence,Mapping) else None,
|
||||
})
|
||||
existing=connection.execute("SELECT * FROM lineage_acceptances WHERE tenant_id=? AND acceptance_id=?",(tenant_id,acceptance_id)).fetchone()
|
||||
expected_row=(subject_id,mission_id,component_id,deployment_id,release_id,artifact_json,int(accepted),evidence_id,evidence_json)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","deployment_id","release_id","artifact_json","accepted","evidence_id","evidence_json"))
|
||||
if actual != expected_row:
|
||||
raise LineageConflict("acceptance_id already has different binding")
|
||||
return self._acceptance_row(existing)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_acceptances VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,acceptance_id,subject_id,mission_id,component_id,deployment_id,release_id,artifact_json,int(accepted),evidence_id,evidence_json,_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_acceptances WHERE tenant_id=? AND acceptance_id=?",(tenant_id,acceptance_id)).fetchone()
|
||||
return self._acceptance_row(row)
|
||||
|
||||
def explain(self, tenant_id: str, subject_id: str, mission_id: str) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id=(_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),_required(mission_id,"mission_id"))
|
||||
specs=(("tasks","lineage_tasks",self._task_row),("effects","lineage_effects",self._effect_row),
|
||||
("releases","lineage_releases",self._release_row),("deployments","lineage_deployments",self._deployment_row),
|
||||
("acceptances","lineage_acceptances",self._acceptance_row))
|
||||
result={"tenant_id":tenant_id,"subject_id":subject_id,"mission_id":mission_id}
|
||||
with self._connect() as connection:
|
||||
for key,table,converter in specs:
|
||||
rows=connection.execute(f"SELECT * FROM {table} WHERE tenant_id=? AND subject_id=? AND mission_id=? ORDER BY created_at",(tenant_id,subject_id,mission_id)).fetchall()
|
||||
result[key]=[converter(row) for row in rows]
|
||||
return result
|
||||
|
||||
def get_release(self, tenant_id: str, subject_id: str, release_id: str) -> dict[str, Any]:
|
||||
"""Resolve an exact tenant/subject release without disclosing other scopes."""
|
||||
|
||||
tenant_id, subject_id, release_id = (
|
||||
_required(tenant_id, "tenant_id"), _required(subject_id, "subject_id"),
|
||||
_required(release_id, "release_id"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=? AND subject_id=?",
|
||||
(tenant_id, release_id, subject_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release lineage not found")
|
||||
return self._release_row(row)
|
||||
|
||||
def get_release_for_component(self, tenant_id: str, component_id: str, release_id: str) -> dict[str, Any]:
|
||||
tenant_id,component_id,release_id=(_required(tenant_id,"tenant_id"),_required(component_id,"component_id"),_required(release_id,"release_id"))
|
||||
with self._connect() as connection:
|
||||
row=connection.execute(
|
||||
"SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=? AND component_id=?",
|
||||
(tenant_id,release_id,component_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release component lineage not found")
|
||||
return self._release_row(row)
|
||||
|
||||
@staticmethod
|
||||
def _common(row):
|
||||
return {key:row[key] for key in row.keys() if not key.endswith("_json")}
|
||||
|
||||
@classmethod
|
||||
def _task_row(cls,row):
|
||||
value=cls._common(row); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _effect_row(cls,row):
|
||||
value=cls._common(row); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _release_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _deployment_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _acceptance_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["accepted"]=bool(row["accepted"]); value["evidence"]=json.loads(row["evidence_json"]); return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LineageConflict", "LineageError", "LineageLedger", "LineageVerificationError",
|
||||
"VerifiedAcceptanceEvidence", "canonical_artifact", "decode_artifact",
|
||||
]
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Declarative, non-executing operation registry and timeout safety rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
EFFECT_CLASSES = frozenset(
|
||||
{"READ_ONLY", "IDEMPOTENT", "CONDITIONAL", "RECONCILABLE", "IRREVERSIBLE"}
|
||||
)
|
||||
_FORBIDDEN_EXECUTION_FIELDS = frozenset({"command", "shell", "argv", "executable", "script"})
|
||||
_HANDLER_REF = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,126}$")
|
||||
|
||||
|
||||
def _invalid(code: str, field: str = "") -> ValueError:
|
||||
return ValueError(f"{code}:{field}" if field else code)
|
||||
|
||||
|
||||
def _text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise _invalid("field_invalid", field)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _policy(value: object, field: str, allowed_modes: set[str]) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise _invalid("policy_invalid", field)
|
||||
result = deepcopy(dict(value))
|
||||
mode = _text(result.get("mode"), f"{field}.mode").upper()
|
||||
if mode not in allowed_modes:
|
||||
raise _invalid("policy_mode_invalid", f"{field}.mode")
|
||||
result["mode"] = mode
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationDefinition:
|
||||
operation_id: str
|
||||
version: int | str
|
||||
effect_class: str
|
||||
handler_ref: str
|
||||
input_schema: str | Mapping[str, Any]
|
||||
required_permissions: tuple[str, ...]
|
||||
timeout_seconds: float
|
||||
readback: Mapping[str, Any]
|
||||
retry: Mapping[str, Any]
|
||||
reconcile: Mapping[str, Any]
|
||||
rollback: Mapping[str, Any]
|
||||
owner_boundary: Mapping[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> "OperationDefinition":
|
||||
if not isinstance(data, Mapping):
|
||||
raise _invalid("operation_definition_invalid")
|
||||
forbidden = sorted(_FORBIDDEN_EXECUTION_FIELDS.intersection(data))
|
||||
if forbidden:
|
||||
raise _invalid("arbitrary_execution_forbidden", forbidden[0])
|
||||
operation_id = _text(data.get("operation_id"), "operation_id")
|
||||
version = data.get("version")
|
||||
if (
|
||||
isinstance(version, bool)
|
||||
or not isinstance(version, (int, str))
|
||||
or (isinstance(version, int) and version < 1)
|
||||
or (isinstance(version, str) and not version.strip())
|
||||
):
|
||||
raise _invalid("field_invalid", "version")
|
||||
effect_class = _text(data.get("effect_class"), "effect_class").upper()
|
||||
if effect_class not in EFFECT_CLASSES:
|
||||
raise _invalid("effect_class_invalid", "effect_class")
|
||||
handler_ref = _text(data.get("handler_ref", data.get("handler")), "handler_ref")
|
||||
if not _HANDLER_REF.fullmatch(handler_ref):
|
||||
raise _invalid("handler_ref_invalid", "handler_ref")
|
||||
input_schema = data.get("input_schema")
|
||||
if not isinstance(input_schema, (str, Mapping)) or not input_schema:
|
||||
raise _invalid("field_invalid", "input_schema")
|
||||
input_schema = deepcopy(input_schema)
|
||||
permissions = data.get("required_permissions", data.get("permissions"))
|
||||
if not isinstance(permissions, Sequence) or isinstance(permissions, (str, bytes, bytearray)):
|
||||
raise _invalid("field_invalid", "required_permissions")
|
||||
required_permissions = tuple(
|
||||
_text(permission, f"required_permissions[{index}]")
|
||||
for index, permission in enumerate(permissions)
|
||||
)
|
||||
if len(set(required_permissions)) != len(required_permissions):
|
||||
raise _invalid("duplicate_permission", "required_permissions")
|
||||
timeout_seconds = data.get("timeout_seconds")
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(timeout_seconds)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise _invalid("field_invalid", "timeout_seconds")
|
||||
|
||||
readback = _policy(data.get("readback"), "readback", {"NONE", "STATE", "EXACT"})
|
||||
if "required" in readback and not isinstance(readback["required"], bool):
|
||||
raise _invalid("field_invalid", "readback.required")
|
||||
retry = _policy(
|
||||
data.get("retry"),
|
||||
"retry",
|
||||
{"NEVER", "SAFE", "AFTER_READBACK", "RECONCILE_FIRST"},
|
||||
)
|
||||
attempts = retry.get("max_attempts", 1)
|
||||
if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
|
||||
raise _invalid("field_invalid", "retry.max_attempts")
|
||||
retry["max_attempts"] = attempts
|
||||
reconcile = _policy(
|
||||
data.get("reconcile"), "reconcile", {"NONE", "EXACT_READBACK", "CUSTOM", "REQUIRED"}
|
||||
)
|
||||
rollback = _policy(
|
||||
data.get("rollback"), "rollback", {"NONE", "OPERATION", "COMPENSATE"}
|
||||
)
|
||||
if rollback["mode"] == "OPERATION":
|
||||
_text(rollback.get("operation_id"), "rollback.operation_id")
|
||||
owner_boundary = _policy(
|
||||
data.get("owner_boundary"),
|
||||
"owner_boundary",
|
||||
{"NONE", "WITHIN_GRANT", "EXPLICIT_AUTHORIZATION", "CROSS_BOUNDARY_DENIED"},
|
||||
)
|
||||
|
||||
restart_operation = (
|
||||
"service.restart" in operation_id
|
||||
or "service-restart" in operation_id
|
||||
or handler_ref.endswith("service_restart")
|
||||
)
|
||||
if restart_operation and (
|
||||
readback["mode"] != "EXACT"
|
||||
or not readback.get("required", False)
|
||||
or reconcile["mode"] == "NONE"
|
||||
):
|
||||
raise _invalid("service_restart_exact_readback_required")
|
||||
|
||||
return cls(
|
||||
operation_id=operation_id,
|
||||
version=version,
|
||||
effect_class=effect_class,
|
||||
handler_ref=handler_ref,
|
||||
input_schema=input_schema,
|
||||
required_permissions=required_permissions,
|
||||
timeout_seconds=float(timeout_seconds),
|
||||
readback=readback,
|
||||
retry=retry,
|
||||
reconcile=reconcile,
|
||||
rollback=rollback,
|
||||
owner_boundary=owner_boundary,
|
||||
)
|
||||
|
||||
def timeout_action(self, outcome: str, attempts: int = 1) -> str:
|
||||
return operation_timeout_action(self, outcome=outcome, attempts=attempts)
|
||||
|
||||
@property
|
||||
def permissions(self) -> tuple[str, ...]:
|
||||
return self.required_permissions
|
||||
|
||||
|
||||
class OperationRegistry:
|
||||
"""Lookup-only registry; it deliberately has no execute or shell interface."""
|
||||
|
||||
def __init__(self, definitions: Iterable[OperationDefinition | Mapping[str, Any]]) -> None:
|
||||
self._definitions: dict[str, OperationDefinition] = {}
|
||||
for raw in definitions:
|
||||
definition = raw if isinstance(raw, OperationDefinition) else OperationDefinition.from_dict(raw)
|
||||
if definition.operation_id in self._definitions:
|
||||
raise _invalid("duplicate_operation_id", definition.operation_id)
|
||||
self._definitions[definition.operation_id] = definition
|
||||
|
||||
def get(self, operation_id: str) -> OperationDefinition:
|
||||
return self._definitions[_text(operation_id, "operation_id")]
|
||||
|
||||
@property
|
||||
def definitions(self) -> tuple[OperationDefinition, ...]:
|
||||
return tuple(self._definitions.values())
|
||||
|
||||
|
||||
def operation_timeout_action(
|
||||
definition: OperationDefinition,
|
||||
outcome: str = "AMBIGUOUS",
|
||||
attempts: int = 1,
|
||||
) -> str:
|
||||
"""Return ``RETRY``, ``RECONCILE`` or ``STOP`` after a timeout.
|
||||
|
||||
An unknown response from a mutating sink never authorizes a blind repeat.
|
||||
Exact readback/reconciliation must first establish whether the effect happened.
|
||||
"""
|
||||
|
||||
if not isinstance(definition, OperationDefinition):
|
||||
raise _invalid("operation_definition_invalid")
|
||||
normalized_outcome = _text(outcome, "outcome").upper()
|
||||
if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
|
||||
raise _invalid("field_invalid", "attempts")
|
||||
if definition.effect_class == "IRREVERSIBLE":
|
||||
return "STOP"
|
||||
if definition.effect_class != "READ_ONLY" and normalized_outcome in {
|
||||
"AMBIGUOUS",
|
||||
"UNKNOWN",
|
||||
"MAY_HAVE_SUCCEEDED",
|
||||
}:
|
||||
if definition.reconcile["mode"] != "NONE" or definition.readback.get("required", False):
|
||||
return "RECONCILE"
|
||||
return "STOP"
|
||||
retry_mode = definition.retry["mode"]
|
||||
if attempts >= definition.retry["max_attempts"] or retry_mode == "NEVER":
|
||||
return "STOP"
|
||||
if retry_mode in {"AFTER_READBACK", "RECONCILE_FIRST"} and normalized_outcome not in {
|
||||
"NOT_OBSERVED",
|
||||
"PROVEN_ABSENT",
|
||||
}:
|
||||
return "RECONCILE" if definition.reconcile["mode"] != "NONE" else "STOP"
|
||||
return "RETRY"
|
||||
|
||||
|
||||
def _timestamp(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
return None
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def verify_service_restart(
|
||||
before: Mapping[str, Any], after: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a restart with instance identity and start-time evidence.
|
||||
|
||||
Merely observing an active unit proves current activity, not that the requested
|
||||
restart occurred.
|
||||
"""
|
||||
|
||||
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
|
||||
return {"status": "UNKNOWN", "reasons": ["restart_observation_invalid"]}
|
||||
if not isinstance(after.get("active"), bool):
|
||||
return {"status": "UNKNOWN", "reasons": ["service_active_invalid"]}
|
||||
if after["active"] is False:
|
||||
return {"status": "DRIFT", "reasons": ["service_not_active"]}
|
||||
before_id = before.get("instance_id")
|
||||
after_id = after.get("instance_id")
|
||||
before_started = _timestamp(before.get("started_at"))
|
||||
after_started = _timestamp(after.get("started_at"))
|
||||
if (
|
||||
not isinstance(before_id, str)
|
||||
or not before_id
|
||||
or not isinstance(after_id, str)
|
||||
or not after_id
|
||||
or before_started is None
|
||||
or after_started is None
|
||||
):
|
||||
return {"status": "UNKNOWN", "reasons": ["restart_identity_missing"]}
|
||||
if before_id == after_id or after_started <= before_started:
|
||||
return {"status": "DRIFT", "reasons": ["restart_not_observed"]}
|
||||
return {"status": "VERIFIED", "reasons": []}
|
||||
@@ -0,0 +1,866 @@
|
||||
"""Bounded, task-only optimization proposals for Baseline 1.1.
|
||||
|
||||
This module deliberately has no configuration or policy write adapter. Its only
|
||||
effect boundary is ``submit_task``, supplied by the authenticated root service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
ENVELOPE_SCHEMA = "aios.optimization-envelope.v1"
|
||||
PROPOSAL_SCHEMA = "aios.optimization-proposal.v1"
|
||||
TASK_REQUEST_SCHEMA = "aios.optimization-task-request.v1"
|
||||
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}")
|
||||
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
||||
|
||||
# An envelope may narrow these limits but cannot introduce another path or
|
||||
# expand a range. Every entry describes a low-risk operational tuning knob.
|
||||
_HARD_PARAMETER_LIMITS: dict[str, tuple[float, float, bool]] = {
|
||||
"retry.max_attempts": (1, 10, True),
|
||||
"retry.backoff_seconds": (0, 60, False),
|
||||
"backoff.initial_seconds": (0, 60, False),
|
||||
"backoff.maximum_seconds": (0.1, 600, False),
|
||||
"routing.candidate_weight_percent": (0, 25, False),
|
||||
"routing.weight_percent": (0, 25, False),
|
||||
"evidence_reuse.max_age_seconds": (0, 3600, True),
|
||||
"evidence_reuse.ttl_seconds": (0, 3600, True),
|
||||
"timeout.operation_seconds": (0.1, 300, False),
|
||||
"timeout.seconds": (0.1, 300, False),
|
||||
"poll.interval_seconds": (0.1, 300, False),
|
||||
"notification_dedup.window_seconds": (0, 3600, True),
|
||||
"test_selection.optional_sample_percent": (1, 100, False),
|
||||
"test_selection.optional_percentage": (1, 100, False),
|
||||
}
|
||||
_NON_PRODUCTION_ENVIRONMENTS = frozenset({"development", "test", "staging", "canary"})
|
||||
|
||||
|
||||
class OptimizationError(RuntimeError):
|
||||
"""A fail-closed optimization boundary error with a stable code."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise OptimizationError("OPTIMIZATION_DOCUMENT_NOT_CANONICAL") from exc
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return "sha256:" + hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _number(value: Any, code: str, *, minimum: float | None = None,
|
||||
maximum: float | None = None) -> int | float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise OptimizationError(code)
|
||||
if not math.isfinite(float(value)):
|
||||
raise OptimizationError(code)
|
||||
if minimum is not None and value < minimum:
|
||||
raise OptimizationError(code)
|
||||
if maximum is not None and value > maximum:
|
||||
raise OptimizationError(code)
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: Any, code: str) -> str:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise OptimizationError(code)
|
||||
return value
|
||||
|
||||
|
||||
def _unique_identifiers(value: Any, code: str, *, maximum: int = 256) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)) or len(value) > maximum:
|
||||
raise OptimizationError(code)
|
||||
items = tuple(_identifier(item, code) for item in value)
|
||||
if len(set(items)) != len(items):
|
||||
raise OptimizationError(code)
|
||||
return items
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterRange:
|
||||
minimum: int | float
|
||||
maximum: int | float
|
||||
integer: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QualityFloor:
|
||||
direction: str
|
||||
threshold: int | float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionRules:
|
||||
objective_metric: str
|
||||
minimum_improvement: int | float
|
||||
mandatory_tests: tuple[str, ...]
|
||||
mandatory_quality_gates: tuple[str, ...]
|
||||
required_receipt_schema: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptimizationEnvelope:
|
||||
schema: str
|
||||
envelope_id: str
|
||||
tenant_id: str
|
||||
allowed_parameters: Mapping[str, ParameterRange]
|
||||
allowed_components: tuple[str, ...]
|
||||
allowed_environments: tuple[str, ...]
|
||||
max_change_size: int
|
||||
max_compute_budget: int | float
|
||||
quality_floor: Mapping[str, QualityFloor]
|
||||
rollback_requirement: bool
|
||||
promotion_rules: PromotionRules
|
||||
source_config_digest: str
|
||||
key_id: str
|
||||
signature: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
data: Mapping[str, Any],
|
||||
*,
|
||||
verify_envelope: Callable[[str, bytes, str], bool] | None,
|
||||
) -> "OptimizationEnvelope":
|
||||
"""Parse a trusted-config envelope after authenticating its exact bytes.
|
||||
|
||||
``verify_envelope`` is intentionally mandatory. The caller cannot mark
|
||||
a document trusted with a boolean or construct an unrestricted envelope.
|
||||
"""
|
||||
if verify_envelope is None or not callable(verify_envelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_VERIFIER_REQUIRED")
|
||||
if not isinstance(data, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_INVALID")
|
||||
required = {
|
||||
"schema", "envelope_id", "tenant_id", "allowed_parameters",
|
||||
"allowed_components", "allowed_environments", "max_change_size",
|
||||
"max_compute_budget", "quality_floor", "rollback_requirement",
|
||||
"promotion_rules", "source_config_digest", "key_id", "signature",
|
||||
}
|
||||
if set(data) != required:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_FIELDS_INVALID")
|
||||
unsigned = dict(data)
|
||||
signature = unsigned.pop("signature")
|
||||
key_id = data.get("key_id")
|
||||
if not isinstance(signature, str) or not signature or not isinstance(key_id, str):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = verify_envelope(key_id, canonical_json_bytes(unsigned), signature)
|
||||
except Exception as exc:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID") from exc
|
||||
if verified is not True:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID")
|
||||
|
||||
if data["schema"] != ENVELOPE_SCHEMA:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SCHEMA_INVALID")
|
||||
envelope_id = _identifier(data["envelope_id"], "OPTIMIZATION_ENVELOPE_ID_INVALID")
|
||||
tenant_id = _identifier(data["tenant_id"], "OPTIMIZATION_TENANT_INVALID")
|
||||
if not _DIGEST.fullmatch(str(data["source_config_digest"])):
|
||||
raise OptimizationError("OPTIMIZATION_SOURCE_CONFIG_DIGEST_INVALID")
|
||||
key_id = _identifier(key_id, "OPTIMIZATION_ENVELOPE_KEY_ID_INVALID")
|
||||
|
||||
raw_parameters = data["allowed_parameters"]
|
||||
if not isinstance(raw_parameters, Mapping) or not raw_parameters or len(raw_parameters) > 16:
|
||||
raise OptimizationError("OPTIMIZATION_ALLOWED_PARAMETERS_INVALID")
|
||||
parameters: dict[str, ParameterRange] = {}
|
||||
for path, raw_rule in raw_parameters.items():
|
||||
if path not in _HARD_PARAMETER_LIMITS:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_PATH_FORBIDDEN")
|
||||
if not isinstance(raw_rule, Mapping) or not {"minimum", "maximum"} <= set(raw_rule):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
if set(raw_rule) - {"minimum", "maximum", "integer"}:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
hard_min, hard_max, hard_integer = _HARD_PARAMETER_LIMITS[path]
|
||||
minimum = _number(raw_rule["minimum"], "OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
maximum = _number(raw_rule["maximum"], "OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
integer = raw_rule.get("integer", hard_integer)
|
||||
if type(integer) is not bool or minimum > maximum:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
if minimum < hard_min or maximum > hard_max or (hard_integer and not integer):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_TOO_BROAD")
|
||||
if integer and (type(minimum) is not int or type(maximum) is not int):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
parameters[path] = ParameterRange(minimum, maximum, integer)
|
||||
|
||||
components = _unique_identifiers(
|
||||
data["allowed_components"], "OPTIMIZATION_COMPONENTS_INVALID", maximum=32
|
||||
)
|
||||
environments = _unique_identifiers(
|
||||
data["allowed_environments"], "OPTIMIZATION_ENVIRONMENTS_INVALID", maximum=4
|
||||
)
|
||||
if not components or not environments or not set(environments) <= _NON_PRODUCTION_ENVIRONMENTS:
|
||||
raise OptimizationError("OPTIMIZATION_ENVIRONMENTS_INVALID")
|
||||
max_change_size = _number(
|
||||
data["max_change_size"], "OPTIMIZATION_CHANGE_SIZE_INVALID", minimum=1, maximum=16
|
||||
)
|
||||
if type(max_change_size) is not int:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_SIZE_INVALID")
|
||||
max_compute_budget = _number(
|
||||
data["max_compute_budget"],
|
||||
"OPTIMIZATION_COMPUTE_BUDGET_INVALID",
|
||||
minimum=0.001,
|
||||
maximum=10_000,
|
||||
)
|
||||
|
||||
raw_quality = data["quality_floor"]
|
||||
if not isinstance(raw_quality, Mapping) or not raw_quality or len(raw_quality) > 16:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_FLOOR_INVALID")
|
||||
quality: dict[str, QualityFloor] = {}
|
||||
for metric, raw_rule in raw_quality.items():
|
||||
metric = _identifier(metric, "OPTIMIZATION_QUALITY_METRIC_INVALID")
|
||||
if not isinstance(raw_rule, Mapping) or set(raw_rule) != {"direction", "threshold"}:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_FLOOR_INVALID")
|
||||
if raw_rule["direction"] not in {"higher", "lower"}:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_DIRECTION_INVALID")
|
||||
threshold = _number(
|
||||
raw_rule["threshold"], "OPTIMIZATION_QUALITY_FLOOR_INVALID",
|
||||
minimum=-1e12, maximum=1e12,
|
||||
)
|
||||
quality[metric] = QualityFloor(raw_rule["direction"], threshold)
|
||||
|
||||
if type(data["rollback_requirement"]) is not bool:
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_REQUIREMENT_INVALID")
|
||||
raw_promotion = data["promotion_rules"]
|
||||
promotion_fields = {
|
||||
"objective_metric", "minimum_improvement", "mandatory_tests",
|
||||
"mandatory_quality_gates", "required_receipt_schema",
|
||||
}
|
||||
if not isinstance(raw_promotion, Mapping) or set(raw_promotion) != promotion_fields:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RULES_INVALID")
|
||||
objective = _identifier(
|
||||
raw_promotion["objective_metric"], "OPTIMIZATION_OBJECTIVE_METRIC_INVALID"
|
||||
)
|
||||
if objective not in quality:
|
||||
raise OptimizationError("OPTIMIZATION_OBJECTIVE_METRIC_INVALID")
|
||||
minimum_improvement = _number(
|
||||
raw_promotion["minimum_improvement"],
|
||||
"OPTIMIZATION_MINIMUM_IMPROVEMENT_INVALID",
|
||||
minimum=0,
|
||||
maximum=1e12,
|
||||
)
|
||||
mandatory_tests = _unique_identifiers(
|
||||
raw_promotion["mandatory_tests"], "OPTIMIZATION_MANDATORY_TESTS_INVALID"
|
||||
)
|
||||
mandatory_gates = _unique_identifiers(
|
||||
raw_promotion["mandatory_quality_gates"], "OPTIMIZATION_MANDATORY_GATES_INVALID"
|
||||
)
|
||||
if not mandatory_tests or not mandatory_gates:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RULES_INVALID")
|
||||
receipt_schema = _identifier(
|
||||
raw_promotion["required_receipt_schema"], "OPTIMIZATION_RECEIPT_SCHEMA_INVALID"
|
||||
)
|
||||
if not receipt_schema.startswith("aios."):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_SCHEMA_INVALID")
|
||||
promotion = PromotionRules(
|
||||
objective, minimum_improvement, mandatory_tests, mandatory_gates, receipt_schema
|
||||
)
|
||||
return cls(
|
||||
ENVELOPE_SCHEMA, envelope_id, tenant_id, parameters, components,
|
||||
environments, max_change_size, max_compute_budget, quality,
|
||||
data["rollback_requirement"], promotion, data["source_config_digest"],
|
||||
key_id, signature,
|
||||
)
|
||||
|
||||
|
||||
_PROPOSAL_REQUIRED = {
|
||||
"schema", "proposal_id", "envelope_id", "tenant_id", "component",
|
||||
"environment", "changes", "compute_budget", "tests", "quality_gates",
|
||||
"baseline_metrics", "rollback_plan",
|
||||
}
|
||||
|
||||
|
||||
def validate_proposal(envelope: OptimizationEnvelope, proposal: Mapping[str, Any]) -> dict:
|
||||
"""Return a canonical candidate constrained by ``envelope``."""
|
||||
if not isinstance(envelope, OptimizationEnvelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_REQUIRED")
|
||||
if not isinstance(proposal, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_INVALID")
|
||||
allowed_fields = _PROPOSAL_REQUIRED | {"evidence_reuse"}
|
||||
if set(proposal) - allowed_fields or not _PROPOSAL_REQUIRED <= set(proposal):
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_FIELDS_INVALID")
|
||||
if proposal["schema"] != PROPOSAL_SCHEMA:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_SCHEMA_INVALID")
|
||||
proposal_id = _identifier(proposal["proposal_id"], "OPTIMIZATION_PROPOSAL_ID_INVALID")
|
||||
if proposal["envelope_id"] != envelope.envelope_id:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_BINDING_MISMATCH")
|
||||
if proposal["tenant_id"] != envelope.tenant_id:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_TENANT_MISMATCH")
|
||||
component = _identifier(proposal["component"], "OPTIMIZATION_COMPONENT_INVALID")
|
||||
if component not in envelope.allowed_components:
|
||||
raise OptimizationError("OPTIMIZATION_COMPONENT_FORBIDDEN")
|
||||
environment = _identifier(proposal["environment"], "OPTIMIZATION_ENVIRONMENT_INVALID")
|
||||
if environment not in envelope.allowed_environments:
|
||||
raise OptimizationError("OPTIMIZATION_ENVIRONMENT_FORBIDDEN")
|
||||
|
||||
raw_changes = proposal["changes"]
|
||||
if not isinstance(raw_changes, list) or not raw_changes or len(raw_changes) > envelope.max_change_size:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_SIZE_EXCEEDED")
|
||||
changes: list[dict[str, Any]] = []
|
||||
paths: set[str] = set()
|
||||
for change in raw_changes:
|
||||
if not isinstance(change, Mapping) or set(change) != {"path", "value"}:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_INVALID")
|
||||
path = change["path"]
|
||||
if not isinstance(path, str) or path not in envelope.allowed_parameters:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_PATH_FORBIDDEN")
|
||||
if path in paths:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_DUPLICATE")
|
||||
paths.add(path)
|
||||
rule = envelope.allowed_parameters[path]
|
||||
value = _number(change["value"], "OPTIMIZATION_PARAMETER_VALUE_INVALID")
|
||||
if rule.integer and type(value) is not int:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_VALUE_INVALID")
|
||||
if value < rule.minimum or value > rule.maximum:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_OUT_OF_RANGE")
|
||||
changes.append({"path": path, "value": value})
|
||||
|
||||
compute_budget = _number(
|
||||
proposal["compute_budget"],
|
||||
"OPTIMIZATION_COMPUTE_BUDGET_EXCEEDED",
|
||||
minimum=0.001,
|
||||
maximum=float(envelope.max_compute_budget),
|
||||
)
|
||||
tests = _unique_identifiers(proposal["tests"], "OPTIMIZATION_TESTS_INVALID")
|
||||
gates = _unique_identifiers(proposal["quality_gates"], "OPTIMIZATION_QUALITY_GATES_INVALID")
|
||||
for mandatory in envelope.promotion_rules.mandatory_tests:
|
||||
if mandatory not in tests:
|
||||
raise OptimizationError("OPTIMIZATION_MANDATORY_TEST_MISSING")
|
||||
for mandatory in envelope.promotion_rules.mandatory_quality_gates:
|
||||
if mandatory not in gates:
|
||||
raise OptimizationError("OPTIMIZATION_MANDATORY_GATE_MISSING")
|
||||
|
||||
raw_baseline = proposal["baseline_metrics"]
|
||||
if not isinstance(raw_baseline, Mapping) or set(raw_baseline) != set(envelope.quality_floor):
|
||||
raise OptimizationError("OPTIMIZATION_BASELINE_METRICS_INVALID")
|
||||
baseline = {
|
||||
metric: _number(raw_baseline[metric], "OPTIMIZATION_BASELINE_METRICS_INVALID")
|
||||
for metric in sorted(raw_baseline)
|
||||
}
|
||||
|
||||
rollback_plan = proposal["rollback_plan"]
|
||||
if envelope.rollback_requirement:
|
||||
if (
|
||||
not isinstance(rollback_plan, Mapping)
|
||||
or set(rollback_plan) != {"strategy", "reference"}
|
||||
or rollback_plan.get("strategy") != "restore_previous_values"
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_PLAN_REQUIRED")
|
||||
_identifier(rollback_plan.get("reference"), "OPTIMIZATION_ROLLBACK_PLAN_INVALID")
|
||||
normalized_rollback: dict[str, str] | None = {
|
||||
"strategy": "restore_previous_values",
|
||||
"reference": rollback_plan["reference"],
|
||||
}
|
||||
else:
|
||||
if rollback_plan is not None:
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_PLAN_INVALID")
|
||||
normalized_rollback = None
|
||||
|
||||
normalized_evidence = None
|
||||
if "evidence_reuse" in proposal:
|
||||
evidence = proposal["evidence_reuse"]
|
||||
if not isinstance(evidence, Mapping) or set(evidence) != {"scope", "evidence_ids"}:
|
||||
raise OptimizationError("OPTIMIZATION_EVIDENCE_REUSE_INVALID")
|
||||
expected_scope = {
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"component": component,
|
||||
"environment": environment,
|
||||
}
|
||||
if evidence["scope"] != expected_scope:
|
||||
raise OptimizationError("OPTIMIZATION_EVIDENCE_SCOPE_MISMATCH")
|
||||
evidence_ids = _unique_identifiers(
|
||||
evidence["evidence_ids"], "OPTIMIZATION_EVIDENCE_REUSE_INVALID", maximum=128
|
||||
)
|
||||
normalized_evidence = {"scope": expected_scope, "evidence_ids": list(evidence_ids)}
|
||||
|
||||
normalized = {
|
||||
"schema": PROPOSAL_SCHEMA,
|
||||
"proposal_id": proposal_id,
|
||||
"envelope_id": envelope.envelope_id,
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"component": component,
|
||||
"environment": environment,
|
||||
"changes": changes,
|
||||
"compute_budget": float(compute_budget),
|
||||
"tests": list(tests),
|
||||
"quality_gates": list(gates),
|
||||
"baseline_metrics": baseline,
|
||||
"rollback_plan": normalized_rollback,
|
||||
}
|
||||
if normalized_evidence is not None:
|
||||
normalized["evidence_reuse"] = normalized_evidence
|
||||
if len(canonical_json_bytes(normalized)) > 65_536:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_TOO_LARGE")
|
||||
return normalized
|
||||
|
||||
|
||||
class OptimizationController:
|
||||
"""Durable proposal state machine whose only external effect is task admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
envelope: OptimizationEnvelope,
|
||||
submit_task: Callable[[Mapping[str, Any]], Mapping[str, Any]],
|
||||
*,
|
||||
verify_receipt: Callable[[Mapping[str, Any]], bool],
|
||||
):
|
||||
if not isinstance(envelope, OptimizationEnvelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_REQUIRED")
|
||||
if not callable(submit_task):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMITTER_REQUIRED")
|
||||
if not callable(verify_receipt):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_VERIFIER_REQUIRED")
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.envelope = envelope
|
||||
self.submit_task = submit_task
|
||||
self.verify_receipt = verify_receipt
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS optimization_proposals (
|
||||
proposal_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
envelope_id TEXT NOT NULL,
|
||||
proposal_digest TEXT NOT NULL,
|
||||
proposal_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
evaluation_task_id TEXT,
|
||||
promotion_task_id TEXT,
|
||||
baseline_json TEXT NOT NULL,
|
||||
candidate_json TEXT,
|
||||
effect_json TEXT,
|
||||
evaluation_json TEXT,
|
||||
receipt_json TEXT,
|
||||
error_code TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _context(self, value: Mapping[str, Any]) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_AUTHENTICATED_CONTEXT_REQUIRED")
|
||||
fields = ("tenant_id", "subject_id", "grant_id", "mission_id", "service_identity")
|
||||
try:
|
||||
context = {
|
||||
field: _identifier(value[field], "OPTIMIZATION_AUTHENTICATED_CONTEXT_INVALID")
|
||||
for field in fields
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise OptimizationError("OPTIMIZATION_AUTHENTICATED_CONTEXT_REQUIRED") from exc
|
||||
if context["service_identity"] != "aios-root-service":
|
||||
raise OptimizationError("OPTIMIZATION_ROOT_SERVICE_REQUIRED")
|
||||
if context["tenant_id"] != self.envelope.tenant_id:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_TENANT_MISMATCH")
|
||||
return context
|
||||
|
||||
def _row(self, proposal_id: str) -> sqlite3.Row:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM optimization_proposals WHERE proposal_id=?", (proposal_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_NOT_FOUND")
|
||||
return row
|
||||
|
||||
def _authorized_row(self, proposal_id: str, context: Mapping[str, Any]) -> sqlite3.Row:
|
||||
checked = self._context(context)
|
||||
row = self._row(_identifier(proposal_id, "OPTIMIZATION_PROPOSAL_ID_INVALID"))
|
||||
if row["tenant_id"] != checked["tenant_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_TENANT_MISMATCH")
|
||||
if row["subject_id"] != checked["subject_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_SUBJECT_MISMATCH")
|
||||
if row["grant_id"] != checked["grant_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_GRANT_MISMATCH")
|
||||
if row["mission_id"] != checked["mission_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_MISSION_MISMATCH")
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def _decode(value: str | None) -> Any:
|
||||
return None if value is None else json.loads(value)
|
||||
|
||||
def _record(self, row: sqlite3.Row) -> dict:
|
||||
proposal = self._decode(row["proposal_json"])
|
||||
result = dict(proposal)
|
||||
result.update(
|
||||
{
|
||||
"proposal_id": row["proposal_id"],
|
||||
"proposal_digest": row["proposal_digest"],
|
||||
"status": row["status"],
|
||||
"evaluation_task_id": row["evaluation_task_id"],
|
||||
"promotion_task_id": row["promotion_task_id"],
|
||||
"baseline_metrics": self._decode(row["baseline_json"]),
|
||||
"candidate_metrics": self._decode(row["candidate_json"]),
|
||||
"effect_comparison": self._decode(row["effect_json"]),
|
||||
"error_code": row["error_code"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def get_proposal(self, proposal_id: str, *, authenticated_context: Mapping[str, Any]) -> dict:
|
||||
return self._record(self._authorized_row(proposal_id, authenticated_context))
|
||||
|
||||
def _persist_invalid(self, proposal: Mapping[str, Any], context: Mapping[str, str],
|
||||
error: OptimizationError) -> None:
|
||||
proposal_id = proposal.get("proposal_id")
|
||||
tenant_id = proposal.get("tenant_id")
|
||||
if (
|
||||
not isinstance(proposal_id, str)
|
||||
or _IDENTIFIER.fullmatch(proposal_id) is None
|
||||
or tenant_id != self.envelope.tenant_id
|
||||
):
|
||||
return
|
||||
try:
|
||||
encoded = canonical_json_bytes(proposal)
|
||||
except OptimizationError:
|
||||
return
|
||||
if len(encoded) > 65_536:
|
||||
return
|
||||
digest = "sha256:" + hashlib.sha256(encoded).hexdigest()
|
||||
baseline = proposal.get("baseline_metrics")
|
||||
if not isinstance(baseline, Mapping):
|
||||
baseline = {}
|
||||
now = time.time()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO optimization_proposals
|
||||
(proposal_id,tenant_id,subject_id,grant_id,mission_id,envelope_id,
|
||||
proposal_digest,proposal_json,status,baseline_json,error_code,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
proposal_id, self.envelope.tenant_id, context["subject_id"],
|
||||
context["grant_id"], context["mission_id"], self.envelope.envelope_id,
|
||||
digest, encoded.decode("utf-8"), "STOPPED_INVALID",
|
||||
canonical_json_bytes(baseline).decode("utf-8"), error.code, now, now,
|
||||
),
|
||||
)
|
||||
|
||||
def propose(self, proposal: Mapping[str, Any], *, authenticated_context: Mapping[str, Any]) -> dict:
|
||||
context = self._context(authenticated_context)
|
||||
try:
|
||||
normalized = validate_proposal(self.envelope, proposal)
|
||||
except OptimizationError as exc:
|
||||
if isinstance(proposal, Mapping):
|
||||
self._persist_invalid(proposal, context, exc)
|
||||
raise
|
||||
proposal_digest = _digest(normalized)
|
||||
proposal_id = normalized["proposal_id"]
|
||||
now = time.time()
|
||||
encoded = canonical_json_bytes(normalized).decode("utf-8")
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM optimization_proposals WHERE proposal_id=?", (proposal_id,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing["proposal_digest"] != proposal_digest:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_ID_CONFLICT")
|
||||
if (
|
||||
existing["tenant_id"] != context["tenant_id"]
|
||||
or existing["subject_id"] != context["subject_id"]
|
||||
or existing["grant_id"] != context["grant_id"]
|
||||
or existing["mission_id"] != context["mission_id"]
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_BINDING_MISMATCH")
|
||||
if existing["status"] != "PROPOSAL_RECORDED":
|
||||
return self._record(existing)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO optimization_proposals
|
||||
(proposal_id,tenant_id,subject_id,grant_id,mission_id,envelope_id,
|
||||
proposal_digest,proposal_json,status,baseline_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
proposal_id, context["tenant_id"], context["subject_id"],
|
||||
context["grant_id"], context["mission_id"], self.envelope.envelope_id,
|
||||
proposal_digest, encoded, "PROPOSAL_RECORDED",
|
||||
canonical_json_bytes(normalized["baseline_metrics"]).decode("utf-8"),
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
|
||||
requested_task_id = f"optimization-evaluate-{proposal_id}"
|
||||
task_request = {
|
||||
"schema": TASK_REQUEST_SCHEMA,
|
||||
"task_id": requested_task_id,
|
||||
"action": "evaluate",
|
||||
"tenant_id": context["tenant_id"],
|
||||
"subject_id": context["subject_id"],
|
||||
"grant_id": context["grant_id"],
|
||||
"mission_id": context["mission_id"],
|
||||
"proposal_id": proposal_id,
|
||||
"proposal_digest": proposal_digest,
|
||||
"component": normalized["component"],
|
||||
"environment": normalized["environment"],
|
||||
"compute_budget": normalized["compute_budget"],
|
||||
}
|
||||
try:
|
||||
receipt = self.submit_task(task_request)
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_INVALID")
|
||||
task_id = _identifier(
|
||||
receipt.get("task_id"), "OPTIMIZATION_TASK_SUBMISSION_INVALID"
|
||||
)
|
||||
except Exception as exc:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,error_code=?,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='PROPOSAL_RECORDED'",
|
||||
("STOPPED_SUBMISSION_FAILED", "OPTIMIZATION_TASK_SUBMISSION_FAILED", time.time(), proposal_id),
|
||||
)
|
||||
if isinstance(exc, OptimizationError):
|
||||
raise
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_FAILED") from exc
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,evaluation_task_id=?,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='PROPOSAL_RECORDED'",
|
||||
("EVALUATION_SUBMITTED", task_id, time.time(), proposal_id),
|
||||
)
|
||||
return self.get_proposal(proposal_id, authenticated_context=context)
|
||||
|
||||
def _trusted_receipt(self, receipt: Any) -> Mapping[str, Any]:
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_INVALID")
|
||||
try:
|
||||
trusted = self.verify_receipt(receipt)
|
||||
except Exception as exc:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_UNTRUSTED") from exc
|
||||
if trusted is not True:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_UNTRUSTED")
|
||||
return receipt
|
||||
|
||||
def record_evaluation(
|
||||
self,
|
||||
proposal_id: str,
|
||||
evaluation: Mapping[str, Any],
|
||||
*,
|
||||
authenticated_context: Mapping[str, Any],
|
||||
) -> dict:
|
||||
row = self._authorized_row(proposal_id, authenticated_context)
|
||||
if row["status"] != "EVALUATION_SUBMITTED":
|
||||
raise OptimizationError("OPTIMIZATION_EVALUATION_STATE_INVALID")
|
||||
if not isinstance(evaluation, Mapping) or set(evaluation) != {
|
||||
"candidate_metrics", "compute_used", "tests_passed",
|
||||
"quality_gates_passed", "receipt",
|
||||
}:
|
||||
raise OptimizationError("OPTIMIZATION_EVALUATION_INVALID")
|
||||
result_body = {key: evaluation[key] for key in evaluation if key != "receipt"}
|
||||
receipt = self._trusted_receipt(evaluation["receipt"])
|
||||
required_receipt = {
|
||||
"schema", "task_id", "proposal_id", "proposal_digest", "action",
|
||||
"success", "result_digest", "key_id", "signature",
|
||||
}
|
||||
if set(receipt) != required_receipt:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_INVALID")
|
||||
if receipt["schema"] != self.envelope.promotion_rules.required_receipt_schema:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_SCHEMA_MISMATCH")
|
||||
if receipt["task_id"] != row["evaluation_task_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_TASK_MISMATCH")
|
||||
if receipt["proposal_id"] != row["proposal_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_PROPOSAL_MISMATCH")
|
||||
if receipt["proposal_digest"] != row["proposal_digest"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_PROPOSAL_DIGEST_MISMATCH")
|
||||
if receipt["action"] != "evaluate" or receipt["success"] is not True:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_RESULT_INVALID")
|
||||
if receipt["result_digest"] != _digest(result_body):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_RESULT_DIGEST_MISMATCH")
|
||||
|
||||
proposal = self._decode(row["proposal_json"])
|
||||
compute_used = _number(
|
||||
evaluation["compute_used"], "OPTIMIZATION_EVALUATION_BUDGET_EXCEEDED", minimum=0
|
||||
)
|
||||
if compute_used > proposal["compute_budget"] or compute_used > self.envelope.max_compute_budget:
|
||||
status = "STOPPED_BUDGET_EXCEEDED"
|
||||
error_code = "OPTIMIZATION_EVALUATION_BUDGET_EXCEEDED"
|
||||
return self._store_evaluation(row, evaluation, receipt, {}, status, error_code)
|
||||
tests_passed = _unique_identifiers(
|
||||
evaluation["tests_passed"], "OPTIMIZATION_EVALUATION_TESTS_INVALID"
|
||||
)
|
||||
gates_passed = _unique_identifiers(
|
||||
evaluation["quality_gates_passed"], "OPTIMIZATION_EVALUATION_GATES_INVALID"
|
||||
)
|
||||
if not set(self.envelope.promotion_rules.mandatory_tests) <= set(tests_passed):
|
||||
return self._store_evaluation(
|
||||
row, evaluation, receipt, {}, "STOPPED_MANDATORY_TEST_FAILED",
|
||||
"OPTIMIZATION_MANDATORY_TEST_FAILED",
|
||||
)
|
||||
if not set(self.envelope.promotion_rules.mandatory_quality_gates) <= set(gates_passed):
|
||||
return self._store_evaluation(
|
||||
row, evaluation, receipt, {}, "STOPPED_MANDATORY_GATE_FAILED",
|
||||
"OPTIMIZATION_MANDATORY_GATE_FAILED",
|
||||
)
|
||||
raw_candidate = evaluation["candidate_metrics"]
|
||||
if not isinstance(raw_candidate, Mapping) or set(raw_candidate) != set(self.envelope.quality_floor):
|
||||
raise OptimizationError("OPTIMIZATION_CANDIDATE_METRICS_INVALID")
|
||||
candidate = {
|
||||
metric: _number(raw_candidate[metric], "OPTIMIZATION_CANDIDATE_METRICS_INVALID")
|
||||
for metric in sorted(raw_candidate)
|
||||
}
|
||||
baseline = self._decode(row["baseline_json"])
|
||||
effect: dict[str, float] = {}
|
||||
quality_regression = False
|
||||
for metric in sorted(self.envelope.quality_floor):
|
||||
rule = self.envelope.quality_floor[metric]
|
||||
if rule.direction == "higher":
|
||||
delta = float(candidate[metric]) - float(baseline[metric])
|
||||
below_floor = candidate[metric] < rule.threshold
|
||||
else:
|
||||
delta = float(baseline[metric]) - float(candidate[metric])
|
||||
below_floor = candidate[metric] > rule.threshold
|
||||
effect[metric] = round(delta, 12)
|
||||
quality_regression = quality_regression or below_floor or delta < -1e-12
|
||||
objective = self.envelope.promotion_rules.objective_metric
|
||||
if quality_regression:
|
||||
status, error_code = "STOPPED_QUALITY_REGRESSION", "OPTIMIZATION_QUALITY_REGRESSION"
|
||||
elif effect[objective] + 1e-12 < self.envelope.promotion_rules.minimum_improvement:
|
||||
status, error_code = "STOPPED_NO_IMPROVEMENT", "OPTIMIZATION_NO_IMPROVEMENT"
|
||||
else:
|
||||
status, error_code = "ELIGIBLE", None
|
||||
return self._store_evaluation(row, evaluation, receipt, effect, status, error_code, candidate)
|
||||
|
||||
def _store_evaluation(
|
||||
self,
|
||||
row: sqlite3.Row,
|
||||
evaluation: Mapping[str, Any],
|
||||
receipt: Mapping[str, Any],
|
||||
effect: Mapping[str, Any],
|
||||
status: str,
|
||||
error_code: str | None,
|
||||
candidate: Mapping[str, Any] | None = None,
|
||||
) -> dict:
|
||||
candidate = dict(candidate or {})
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE optimization_proposals
|
||||
SET status=?,candidate_json=?,effect_json=?,evaluation_json=?,receipt_json=?,
|
||||
error_code=?,updated_at=?
|
||||
WHERE proposal_id=?
|
||||
""",
|
||||
(
|
||||
status, canonical_json_bytes(candidate).decode("utf-8"),
|
||||
canonical_json_bytes(effect).decode("utf-8"),
|
||||
canonical_json_bytes({k: evaluation[k] for k in evaluation if k != "receipt"}).decode("utf-8"),
|
||||
canonical_json_bytes(receipt).decode("utf-8"), error_code, time.time(),
|
||||
row["proposal_id"],
|
||||
),
|
||||
)
|
||||
return self._record(self._row(row["proposal_id"]))
|
||||
|
||||
def promote(
|
||||
self,
|
||||
proposal_id: str,
|
||||
promotion: Mapping[str, Any],
|
||||
*,
|
||||
authenticated_context: Mapping[str, Any],
|
||||
) -> dict:
|
||||
context = self._context(authenticated_context)
|
||||
row = self._authorized_row(proposal_id, context)
|
||||
if not isinstance(promotion, Mapping) or set(promotion) != {
|
||||
"evaluation_task_id", "proposal_digest"
|
||||
}:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_REQUEST_INVALID")
|
||||
if promotion["evaluation_task_id"] != row["evaluation_task_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_TASK_MISMATCH")
|
||||
if promotion["proposal_digest"] != row["proposal_digest"]:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_PROPOSAL_MISMATCH")
|
||||
if row["status"] == "PROMOTION_SUBMITTED":
|
||||
return self._record(row)
|
||||
if row["status"] != "ELIGIBLE":
|
||||
raise OptimizationError("OPTIMIZATION_NOT_PROMOTABLE")
|
||||
stored_receipt = self._trusted_receipt(self._decode(row["receipt_json"]))
|
||||
if (
|
||||
stored_receipt.get("task_id") != row["evaluation_task_id"]
|
||||
or stored_receipt.get("proposal_digest") != row["proposal_digest"]
|
||||
or stored_receipt.get("action") != "evaluate"
|
||||
or stored_receipt.get("success") is not True
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RECEIPT_BINDING_INVALID")
|
||||
evaluation = self._decode(row["evaluation_json"])
|
||||
if stored_receipt.get("result_digest") != _digest(evaluation):
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_QUALITY_RESULT_INVALID")
|
||||
|
||||
task_request = {
|
||||
"schema": TASK_REQUEST_SCHEMA,
|
||||
"task_id": f"optimization-promote-{proposal_id}",
|
||||
"action": "promote",
|
||||
"tenant_id": context["tenant_id"],
|
||||
"subject_id": context["subject_id"],
|
||||
"grant_id": context["grant_id"],
|
||||
"mission_id": context["mission_id"],
|
||||
"proposal_id": row["proposal_id"],
|
||||
"proposal_digest": row["proposal_digest"],
|
||||
"evaluation_task_id": row["evaluation_task_id"],
|
||||
"evaluation_result_digest": stored_receipt["result_digest"],
|
||||
}
|
||||
try:
|
||||
submission = self.submit_task(task_request)
|
||||
if not isinstance(submission, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_INVALID")
|
||||
task_id = _identifier(
|
||||
submission.get("task_id"), "OPTIMIZATION_TASK_SUBMISSION_INVALID"
|
||||
)
|
||||
except Exception as exc:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,error_code=?,updated_at=? WHERE proposal_id=?",
|
||||
("STOPPED_PROMOTION_SUBMISSION_FAILED", "OPTIMIZATION_TASK_SUBMISSION_FAILED", time.time(), proposal_id),
|
||||
)
|
||||
if isinstance(exc, OptimizationError):
|
||||
raise
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_FAILED") from exc
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,promotion_task_id=?,error_code=NULL,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='ELIGIBLE'",
|
||||
("PROMOTION_SUBMITTED", task_id, time.time(), proposal_id),
|
||||
)
|
||||
return self.get_proposal(proposal_id, authenticated_context=context)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OptimizationController", "OptimizationEnvelope", "OptimizationError",
|
||||
"canonical_json_bytes", "validate_proposal",
|
||||
]
|
||||
@@ -0,0 +1,742 @@
|
||||
"""Release-owned deployment targets and a receipt-gated persistent saga.
|
||||
|
||||
The module persists intent before invoking a governed operation adapter. It
|
||||
does not contain a shell runner, production signer, or success simulator. A
|
||||
configured verifier must translate an adapter/sink receipt into a typed,
|
||||
operation-bound :class:`VerifiedReceipt` before a stage can advance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from .lineage import (
|
||||
LineageLedger,
|
||||
VerifiedAcceptanceEvidence,
|
||||
canonical_artifact,
|
||||
decode_artifact,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseConflict(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseVerificationError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseAuthorizationError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseReconciliationRequired(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedReceipt:
|
||||
"""Trusted verifier output, fully bound to one persisted stage effect."""
|
||||
|
||||
receipt_id: str
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
component_id: str
|
||||
task_id: str
|
||||
source_task_id: str
|
||||
source_effect_id: str
|
||||
grant_id: str
|
||||
job_id: str
|
||||
effect_id: str
|
||||
operation_id: str
|
||||
deployment_id: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
observed: Mapping[str, Any]
|
||||
outcome: str = "SUCCEEDED"
|
||||
observed_at: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvironmentDeploymentTarget:
|
||||
tenant_id: str
|
||||
component_id: str
|
||||
environment: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
revision: int
|
||||
stage: str
|
||||
source_revision: str | None
|
||||
observed_main_revision: str | None
|
||||
previous_release_id: str | None
|
||||
previous_artifact: Any | None
|
||||
rollback_release_id: str | None
|
||||
rollback_artifact: Any | None
|
||||
rollback_compatible: bool
|
||||
updated_by_subject_id: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text or len(text) > 512 or any(ord(char) < 32 for char in text):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _recipe_digest(stages: Sequence[Mapping[str, Any]]) -> str:
|
||||
return hashlib.sha256(_json(list(stages)).encode()).hexdigest()
|
||||
|
||||
|
||||
class ReleaseLedger:
|
||||
"""The release domain authority for dynamic targets and deployment sagas."""
|
||||
|
||||
TARGET_STAGES = {"STAGED", "ACTIVE", "ROLLBACK_STAGED"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
lineage: LineageLedger,
|
||||
receipt_verifier: Callable[[Any, Mapping[str, Any]], VerifiedReceipt],
|
||||
target_authorizer: Callable[[str,str,str,str,str], bool],
|
||||
):
|
||||
if not isinstance(lineage, LineageLedger):
|
||||
raise ValueError("lineage ledger is required")
|
||||
if not callable(receipt_verifier):
|
||||
raise ValueError("receipt_verifier is required")
|
||||
if not callable(target_authorizer):
|
||||
raise ValueError("target_authorizer is required")
|
||||
self.path = str(path)
|
||||
self.lineage = lineage
|
||||
self.receipt_verifier = receipt_verifier
|
||||
self.target_authorizer = target_authorizer
|
||||
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
tables={row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "release_targets" in tables and "release_schema_meta" not in tables:
|
||||
raise ReleaseConflict("unversioned candidate release schema requires explicit migration")
|
||||
if "release_schema_meta" in tables:
|
||||
version=connection.execute("SELECT version FROM release_schema_meta WHERE domain='release'").fetchone()
|
||||
if version is None or version[0] != 2:
|
||||
raise ReleaseConflict("unsupported release schema version")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS release_schema_meta (
|
||||
domain TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_recipes (
|
||||
recipe_id TEXT PRIMARY KEY,
|
||||
recipe_digest TEXT NOT NULL,
|
||||
stages_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_targets (
|
||||
tenant_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
source_revision TEXT,
|
||||
observed_main_revision TEXT,
|
||||
previous_release_id TEXT,
|
||||
previous_artifact_json TEXT,
|
||||
rollback_release_id TEXT,
|
||||
rollback_artifact_json TEXT,
|
||||
rollback_compatible INTEGER NOT NULL CHECK (rollback_compatible IN (0,1)),
|
||||
updated_by_subject_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, component_id, environment)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_sagas (
|
||||
tenant_id TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
source_task_id TEXT NOT NULL,
|
||||
source_effect_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
recipe_id TEXT NOT NULL,
|
||||
recipe_digest TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL,
|
||||
stage_index INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, saga_id),
|
||||
UNIQUE (tenant_id, deployment_id),
|
||||
FOREIGN KEY (recipe_id) REFERENCES release_recipes(recipe_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_effect_intents (
|
||||
tenant_id TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
stage_index INTEGER NOT NULL,
|
||||
stage_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
job_id TEXT,
|
||||
state TEXT NOT NULL,
|
||||
receipt_id TEXT,
|
||||
receipt_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, saga_id, stage_index),
|
||||
UNIQUE (tenant_id, effect_id),
|
||||
FOREIGN KEY (tenant_id, saga_id)
|
||||
REFERENCES release_sagas(tenant_id, saga_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_deployment_facts (
|
||||
tenant_id TEXT NOT NULL,
|
||||
fact_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
stage_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
receipt_id TEXT NOT NULL,
|
||||
observed_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (tenant_id, effect_id, receipt_id),
|
||||
FOREIGN KEY (tenant_id, saga_id)
|
||||
REFERENCES release_sagas(tenant_id, saga_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute("INSERT OR IGNORE INTO release_schema_meta(domain,version) VALUES('release',2)")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Compatibility hook; connections are transaction-scoped."""
|
||||
|
||||
def _authorize(self, tenant_id: str, subject_id: str, component_id: str, environment: str, action: str) -> None:
|
||||
try:
|
||||
authorized=self.target_authorizer(tenant_id,subject_id,component_id,environment,action)
|
||||
except Exception as exc:
|
||||
raise ReleaseAuthorizationError("deployment target authorization unavailable") from exc
|
||||
if authorized is not True:
|
||||
raise ReleaseAuthorizationError("deployment target authorization denied")
|
||||
|
||||
def register_recipe(self, recipe_id: str, stages: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
recipe_id = _required(recipe_id, "recipe_id")
|
||||
if not isinstance(stages, Sequence) or isinstance(stages, (str, bytes)) or not stages:
|
||||
raise ValueError("recipe stages must be a non-empty sequence")
|
||||
normalized = []
|
||||
stage_ids = set()
|
||||
for item in stages:
|
||||
if not isinstance(item, Mapping) or set(item) - {"stage_id", "operation_id", "parameters"}:
|
||||
raise ValueError("invalid recipe stage")
|
||||
stage_id = _required(item.get("stage_id"), "stage_id")
|
||||
operation_id = _required(item.get("operation_id"), "operation_id")
|
||||
if stage_id in stage_ids:
|
||||
raise ValueError("duplicate recipe stage_id")
|
||||
stage_ids.add(stage_id)
|
||||
parameters = item.get("parameters", {})
|
||||
if not isinstance(parameters, Mapping):
|
||||
raise ValueError("stage parameters must be an object")
|
||||
normalized.append({"stage_id": stage_id, "operation_id": operation_id, "parameters": dict(parameters)})
|
||||
stages_json = _json(normalized)
|
||||
digest = _recipe_digest(normalized)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?", (recipe_id,)).fetchone()
|
||||
if existing:
|
||||
if existing["recipe_digest"] != digest:
|
||||
raise ReleaseConflict("recipe_id already has different immutable stages")
|
||||
else:
|
||||
connection.execute("INSERT INTO release_recipes VALUES(?,?,?,?)", (recipe_id, digest, stages_json, _now()))
|
||||
return {"recipe_id": recipe_id, "recipe_digest": digest, "stages": normalized}
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
tenant_id: str,
|
||||
subject_id: str,
|
||||
component_id: str,
|
||||
environment: str,
|
||||
release_id: str,
|
||||
artifact: Any,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
stage: str = "STAGED",
|
||||
source_revision: str | None = None,
|
||||
observed_main_revision: str | None = None,
|
||||
rollback_release_id: str | None = None,
|
||||
rollback_artifact: Any | None = None,
|
||||
rollback_compatible: bool = False,
|
||||
) -> EnvironmentDeploymentTarget:
|
||||
tenant_id, subject_id, component_id, environment, release_id = map(
|
||||
lambda pair: _required(pair[0], pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(component_id,"component_id"),(environment,"environment"),(release_id,"release_id")),
|
||||
)
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"write")
|
||||
if stage not in self.TARGET_STAGES:
|
||||
raise ValueError("invalid target stage")
|
||||
if expected_revision is not None and (not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0):
|
||||
raise ValueError("invalid expected_revision")
|
||||
if type(rollback_compatible) is not bool:
|
||||
raise ValueError("rollback_compatible must be boolean")
|
||||
artifact_json = canonical_artifact(artifact)
|
||||
try:
|
||||
linked_release = self.lineage.get_release_for_component(tenant_id, component_id, release_id)
|
||||
except KeyError as exc:
|
||||
raise ReleaseConflict("deployment target release has no lineage") from exc
|
||||
if canonical_artifact(linked_release["artifact"]) != artifact_json:
|
||||
raise ReleaseConflict("deployment target artifact does not match release")
|
||||
rollback_artifact_json = canonical_artifact(rollback_artifact) if rollback_artifact is not None else None
|
||||
source_revision = _required(source_revision, "source_revision") if source_revision else None
|
||||
observed_main_revision = _required(observed_main_revision, "observed_main_revision") if observed_main_revision else None
|
||||
rollback_release_id = _required(rollback_release_id, "rollback_release_id") if rollback_release_id else None
|
||||
if (rollback_release_id is None) != (rollback_artifact_json is None):
|
||||
raise ValueError("rollback release and artifact must be supplied together")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id, component_id, environment),
|
||||
).fetchone()
|
||||
if current is None:
|
||||
if expected_revision is not None:
|
||||
raise ReleaseConflict("expected revision does not match absent target")
|
||||
if stage == "ROLLBACK_STAGED":
|
||||
raise ReleaseConflict("rollback requires a current target")
|
||||
revision = 1
|
||||
previous_release_id = previous_artifact_json = None
|
||||
else:
|
||||
if expected_revision != current["revision"]:
|
||||
raise ReleaseConflict("expected revision does not match target")
|
||||
if stage == "ROLLBACK_STAGED":
|
||||
if not current["rollback_compatible"]:
|
||||
raise ReleaseConflict("current target has no compatible rollback")
|
||||
if release_id != current["rollback_release_id"] or artifact_json != current["rollback_artifact_json"]:
|
||||
raise ReleaseConflict("rollback target does not match compatible rollback binding")
|
||||
revision = current["revision"] + 1
|
||||
previous_release_id = current["release_id"]
|
||||
previous_artifact_json = current["artifact_json"]
|
||||
if rollback_compatible and rollback_release_id is None:
|
||||
rollback_release_id = previous_release_id
|
||||
rollback_artifact_json = previous_artifact_json
|
||||
if rollback_compatible and current is not None and rollback_release_id is None:
|
||||
raise ReleaseConflict("compatible rollback requires a previous target")
|
||||
now = _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO release_targets VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(tenant_id,component_id,environment) DO UPDATE SET
|
||||
release_id=excluded.release_id, artifact_json=excluded.artifact_json,
|
||||
revision=excluded.revision, stage=excluded.stage,
|
||||
source_revision=excluded.source_revision,
|
||||
observed_main_revision=excluded.observed_main_revision,
|
||||
previous_release_id=excluded.previous_release_id,
|
||||
previous_artifact_json=excluded.previous_artifact_json,
|
||||
rollback_release_id=excluded.rollback_release_id,
|
||||
rollback_artifact_json=excluded.rollback_artifact_json,
|
||||
rollback_compatible=excluded.rollback_compatible,
|
||||
updated_by_subject_id=excluded.updated_by_subject_id,
|
||||
updated_at=excluded.updated_at""",
|
||||
(tenant_id,component_id,environment,release_id,artifact_json,revision,stage,
|
||||
source_revision,observed_main_revision,previous_release_id,previous_artifact_json,
|
||||
rollback_release_id,rollback_artifact_json,int(rollback_compatible),subject_id,now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id,component_id,environment),
|
||||
).fetchone()
|
||||
return self._target(row)
|
||||
|
||||
def get_target(self, tenant_id: str, subject_id: str, component_id: str, environment: str) -> EnvironmentDeploymentTarget:
|
||||
tenant_id,subject_id,component_id,environment=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
||||
_required(component_id,"component_id"),_required(environment,"environment"),
|
||||
)
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"read")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id,component_id,environment),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("deployment target not found")
|
||||
return self._target(row)
|
||||
|
||||
@staticmethod
|
||||
def _target(row: sqlite3.Row) -> EnvironmentDeploymentTarget:
|
||||
return EnvironmentDeploymentTarget(
|
||||
tenant_id=row["tenant_id"], component_id=row["component_id"], environment=row["environment"],
|
||||
release_id=row["release_id"], artifact=decode_artifact(row["artifact_json"]), revision=row["revision"],
|
||||
stage=row["stage"], source_revision=row["source_revision"], observed_main_revision=row["observed_main_revision"],
|
||||
previous_release_id=row["previous_release_id"],
|
||||
previous_artifact=decode_artifact(row["previous_artifact_json"]) if row["previous_artifact_json"] else None,
|
||||
rollback_release_id=row["rollback_release_id"],
|
||||
rollback_artifact=decode_artifact(row["rollback_artifact_json"]) if row["rollback_artifact_json"] else None,
|
||||
rollback_compatible=bool(row["rollback_compatible"]),
|
||||
updated_by_subject_id=row["updated_by_subject_id"], updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
def start_saga(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, saga_id: str,
|
||||
release_id: str, deployment_id: str, environment: str, artifact: Any,
|
||||
recipe_id: str, *, expected_target_revision: int,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id,component_id,saga_id,release_id,deployment_id,environment,recipe_id=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),(component_id,"component_id"),(saga_id,"saga_id"),
|
||||
(release_id,"release_id"),(deployment_id,"deployment_id"),(environment,"environment"),(recipe_id,"recipe_id")),
|
||||
)
|
||||
if not isinstance(expected_target_revision,int) or isinstance(expected_target_revision,bool) or expected_target_revision < 1:
|
||||
raise ValueError("invalid expected_target_revision")
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"deploy")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
target=self.get_target(tenant_id,subject_id,component_id,environment)
|
||||
if target.revision != expected_target_revision:
|
||||
raise ReleaseConflict("expected target revision does not match")
|
||||
if target.stage not in {"STAGED","ROLLBACK_STAGED"}:
|
||||
raise ReleaseConflict("deployment target is not staged")
|
||||
if target.release_id != release_id or canonical_artifact(target.artifact) != artifact_json:
|
||||
raise ReleaseConflict("saga does not match deployment target")
|
||||
graph=self.lineage.explain(tenant_id,subject_id,mission_id)
|
||||
release=next((item for item in graph["releases"] if item["release_id"] == release_id),None)
|
||||
if release is None or release["component_id"] != component_id or canonical_artifact(release["artifact"]) != artifact_json:
|
||||
raise ReleaseConflict("release has no exact source lineage")
|
||||
source_task=next((item for item in graph["tasks"] if item["task_id"] == release["source_task_id"]),None)
|
||||
if source_task is None or source_task["component_id"] != component_id:
|
||||
raise ReleaseConflict("release source Task component lineage missing")
|
||||
with self._connect() as connection:
|
||||
recipe=connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
||||
if recipe is None:
|
||||
raise ReleaseConflict("recipe not found")
|
||||
# The relationship is durable before saga admission and is idempotent on retry.
|
||||
self.lineage.link_deployment(
|
||||
tenant_id,subject_id,mission_id,release_id,deployment_id,component_id,environment,
|
||||
expected_target_revision,decode_artifact(artifact_json),
|
||||
metadata={"saga_id":saga_id,"recipe_id":recipe_id,"recipe_digest":recipe["recipe_digest"]},
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
expected=(subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,recipe_id,recipe["recipe_digest"],expected_target_revision)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","source_task_id","source_effect_id","grant_id","release_id","deployment_id","environment","artifact_json","recipe_id","recipe_digest","target_revision"))
|
||||
if actual != expected:
|
||||
raise ReleaseConflict("saga_id already has different immutable binding")
|
||||
return self._saga(connection,existing)
|
||||
collision=connection.execute("SELECT saga_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
if collision:
|
||||
raise ReleaseConflict("deployment_id already has a different saga")
|
||||
now=_now()
|
||||
connection.execute(
|
||||
"INSERT INTO release_sagas VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,saga_id,subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,
|
||||
recipe_id,recipe["recipe_digest"],expected_target_revision,0,"RUNNING",0,now,now),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
return self._saga(connection,row)
|
||||
|
||||
def get_saga(self, tenant_id: str, subject_id: str, saga_id: str) -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(_required(tenant_id,"tenant_id"),_required(saga_id,"saga_id"))).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release saga not found")
|
||||
if row["subject_id"] != _required(subject_id,"subject_id"):
|
||||
raise ReleaseAuthorizationError("release saga subject denied")
|
||||
return self._saga(connection,row)
|
||||
|
||||
@staticmethod
|
||||
def _stages(connection: sqlite3.Connection, recipe_id: str) -> list[dict[str, Any]]:
|
||||
row=connection.execute("SELECT stages_json FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ReleaseConflict("persisted recipe missing")
|
||||
return json.loads(row["stages_json"])
|
||||
|
||||
def _saga(self, connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]:
|
||||
stages=self._stages(connection,row["recipe_id"])
|
||||
index=row["stage_index"]
|
||||
return {
|
||||
"tenant_id":row["tenant_id"],"subject_id":row["subject_id"],"mission_id":row["mission_id"],
|
||||
"component_id":row["component_id"],"source_task_id":row["source_task_id"],
|
||||
"source_effect_id":row["source_effect_id"],"grant_id":row["grant_id"],
|
||||
"saga_id":row["saga_id"],"release_id":row["release_id"],"deployment_id":row["deployment_id"],
|
||||
"environment":row["environment"],"artifact":decode_artifact(row["artifact_json"]),
|
||||
"recipe_id":row["recipe_id"],"recipe_digest":row["recipe_digest"],
|
||||
"target_revision":row["target_revision"],"stage_index":index,
|
||||
"stage_id":stages[index]["stage_id"] if index < len(stages) else None,
|
||||
"state":row["state"],"revision":row["revision"],"created_at":row["created_at"],"updated_at":row["updated_at"],
|
||||
}
|
||||
|
||||
def advance_saga(
|
||||
self, tenant_id: str, subject_id: str, saga_id: str, expected_revision: int,
|
||||
operation_adapter: Any,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,saga_id=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),_required(saga_id,"saga_id")
|
||||
)
|
||||
if not isinstance(expected_revision,int) or isinstance(expected_revision,bool) or expected_revision < 0:
|
||||
raise ValueError("invalid expected_revision")
|
||||
prepare=getattr(operation_adapter,"prepare",None)
|
||||
execute=getattr(operation_adapter,"execute",None)
|
||||
if not callable(prepare) or not callable(execute):
|
||||
raise ValueError("governed operation adapter requires prepare and execute")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
saga=connection.execute(
|
||||
"SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
||||
).fetchone()
|
||||
if saga is None:
|
||||
raise KeyError("release saga not found")
|
||||
if saga["subject_id"] != subject_id:
|
||||
raise ReleaseAuthorizationError("saga belongs to different subject")
|
||||
self._authorize(tenant_id,subject_id,saga["component_id"],saga["environment"],"deploy")
|
||||
if saga["revision"] != expected_revision:
|
||||
raise ReleaseConflict("expected revision does not match saga")
|
||||
if saga["state"] == "COMPLETED":
|
||||
return self._saga(connection,saga)
|
||||
stages=self._stages(connection,saga["recipe_id"])
|
||||
stage=stages[saga["stage_index"]]
|
||||
effect_id=f"release:{saga_id}:{saga['stage_index']}:{stage['stage_id']}"
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if intent is None:
|
||||
now=_now()
|
||||
connection.execute(
|
||||
"INSERT INTO release_effect_intents VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,saga_id,saga["stage_index"],stage["stage_id"],stage["operation_id"],effect_id,
|
||||
None,"PENDING_JOB",None,None,now,now),
|
||||
)
|
||||
elif (intent["effect_id"],intent["stage_id"],intent["operation_id"]) != (
|
||||
effect_id,stage["stage_id"],stage["operation_id"]
|
||||
):
|
||||
raise ReleaseConflict("persisted stage effect identity mismatch")
|
||||
operation={
|
||||
"tenant_id":tenant_id,"subject_id":subject_id,"mission_id":saga["mission_id"],
|
||||
"component_id":saga["component_id"],"task_id":saga["source_task_id"],
|
||||
"source_task_id":saga["source_task_id"],"source_effect_id":saga["source_effect_id"],
|
||||
"grant_id":saga["grant_id"],"saga_id":saga_id,
|
||||
"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
||||
"environment":saga["environment"],"artifact":decode_artifact(saga["artifact_json"]),
|
||||
"target_revision":saga["target_revision"],"stage_index":saga["stage_index"],
|
||||
"stage_id":stage["stage_id"],"operation_id":stage["operation_id"],"effect_id":effect_id,
|
||||
"parameters":stage.get("parameters",{}),
|
||||
}
|
||||
with self._connect() as connection:
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
job_id=intent["job_id"]
|
||||
if job_id is None:
|
||||
prepared=prepare(dict(operation))
|
||||
job_id=prepared.get("job_id") if isinstance(prepared,Mapping) else prepared
|
||||
job_id=_required(job_id,"prepared job_id")
|
||||
# This is the first point where the relationship graph may name a Job:
|
||||
# link_effect independently resolves the configured real Job authority.
|
||||
self.lineage.link_effect(
|
||||
tenant_id,subject_id,saga["mission_id"],saga["source_task_id"],effect_id,job_id,
|
||||
source_effect_id=saga["source_effect_id"],
|
||||
metadata={"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
||||
"component_id":saga["component_id"],"stage_id":stage["stage_id"]},
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute(
|
||||
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",
|
||||
(tenant_id,saga_id),
|
||||
).fetchone()
|
||||
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before Job binding")
|
||||
bound=connection.execute(
|
||||
"SELECT job_id FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if bound["job_id"] not in (None,job_id):
|
||||
raise ReleaseConflict("stage effect already bound to different Job")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET job_id=?,state='JOB_LINKED',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(job_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
operation["job_id"]=job_id
|
||||
with self._connect() as connection:
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if intent["receipt_json"]:
|
||||
raw_receipt=json.loads(intent["receipt_json"])
|
||||
elif intent["state"] == "EFFECT_UNKNOWN":
|
||||
reconcile=getattr(operation_adapter,"reconcile",None)
|
||||
if not callable(reconcile):
|
||||
raise ReleaseReconciliationRequired("RECONCILIATION_REQUIRED: adapter readback unavailable")
|
||||
raw_receipt=reconcile(dict(operation))
|
||||
else:
|
||||
try:
|
||||
raw_receipt=execute(dict(operation))
|
||||
except Exception:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
raise
|
||||
if not isinstance(raw_receipt,Mapping):
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
raise ReleaseVerificationError("operation adapter receipt must be an object")
|
||||
try:
|
||||
unverified_receipt_json=_json(dict(raw_receipt))
|
||||
except (TypeError,ValueError) as exc:
|
||||
raise ReleaseVerificationError("operation adapter receipt must be canonical JSON") from exc
|
||||
if len(unverified_receipt_json.encode("utf-8")) > 262_144:
|
||||
raise ReleaseVerificationError("operation adapter receipt is too large")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute(
|
||||
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
||||
).fetchone()
|
||||
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before receipt persistence")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='RECEIPT_UNVERIFIED',receipt_json=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(unverified_receipt_json,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
expected={key:operation[key] for key in (
|
||||
"tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
||||
"job_id","effect_id","operation_id","deployment_id","release_id","artifact"
|
||||
)}
|
||||
try:
|
||||
verified=self.receipt_verifier(raw_receipt,expected)
|
||||
except Exception as exc:
|
||||
raise ReleaseVerificationError("configured verifier rejected operation receipt") from exc
|
||||
if not isinstance(verified,VerifiedReceipt):
|
||||
raise ReleaseVerificationError("receipt_verifier must return VerifiedReceipt")
|
||||
if verified.outcome != "SUCCEEDED":
|
||||
raise ReleaseVerificationError("verified receipt outcome is not SUCCEEDED")
|
||||
fields=("tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
||||
"job_id","effect_id","operation_id","deployment_id","release_id","artifact")
|
||||
bindings=tuple(canonical_artifact(getattr(verified,name)) if name == "artifact" else getattr(verified,name) for name in fields)
|
||||
expected_bindings=tuple(canonical_artifact(operation[name]) if name == "artifact" else operation[name] for name in fields)
|
||||
if bindings != expected_bindings:
|
||||
wrong=next(name for name,actual,wanted in zip(fields,bindings,expected_bindings) if actual != wanted)
|
||||
raise ReleaseVerificationError(f"verified receipt {wrong} binding mismatch")
|
||||
receipt_id=_required(verified.receipt_id,"receipt_id")
|
||||
if not isinstance(verified.observed,Mapping):
|
||||
raise ReleaseVerificationError("verified receipt observation must be an object")
|
||||
observed_json=_json(dict(verified.observed))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
if current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before receipt commit")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='VERIFIED',receipt_id=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(receipt_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO release_deployment_facts
|
||||
(tenant_id,subject_id,mission_id,component_id,task_id,grant_id,job_id,deployment_id,release_id,artifact_json,saga_id,stage_id,operation_id,effect_id,receipt_id,observed_json,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(tenant_id,subject_id,saga["mission_id"],saga["component_id"],saga["source_task_id"],saga["grant_id"],job_id,
|
||||
saga["deployment_id"],saga["release_id"],saga["artifact_json"],saga_id,stage["stage_id"],stage["operation_id"],
|
||||
effect_id,receipt_id,observed_json,_now()),
|
||||
)
|
||||
next_index=saga["stage_index"]+1
|
||||
state="COMPLETED" if next_index == len(stages) else "RUNNING"
|
||||
updated=connection.execute(
|
||||
"UPDATE release_sagas SET stage_index=?,state=?,revision=revision+1,updated_at=? WHERE tenant_id=? AND saga_id=? AND revision=?",
|
||||
(next_index,state,_now(),tenant_id,saga_id,expected_revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise ReleaseConflict("expected revision changed")
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
return self._saga(connection,row)
|
||||
|
||||
|
||||
def deployment_facts(self, tenant_id: str, subject_id: str, component_id: str, deployment_id: str) -> list[dict[str, Any]]:
|
||||
tenant_id,subject_id,component_id,deployment_id=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
||||
_required(component_id,"component_id"),_required(deployment_id,"deployment_id"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
saga=connection.execute(
|
||||
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
||||
(tenant_id,deployment_id),
|
||||
).fetchone()
|
||||
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
||||
raise ReleaseAuthorizationError("deployment facts scope denied")
|
||||
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"read")
|
||||
rows=connection.execute(
|
||||
"SELECT * FROM release_deployment_facts WHERE tenant_id=? AND subject_id=? AND component_id=? AND deployment_id=? ORDER BY fact_id",
|
||||
(tenant_id,subject_id,component_id,deployment_id),
|
||||
).fetchall()
|
||||
return [{**{key:row[key] for key in row.keys() if not key.endswith("_json")},
|
||||
"artifact":decode_artifact(row["artifact_json"]),"observed":json.loads(row["observed_json"])} for row in rows]
|
||||
|
||||
def record_acceptance(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, acceptance_id: str,
|
||||
deployment_id: str, release_id: str, artifact: Any, accepted: bool, *,
|
||||
evidence: Any,
|
||||
verifier: Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence],
|
||||
) -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
saga=connection.execute(
|
||||
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
||||
(tenant_id,deployment_id),
|
||||
).fetchone()
|
||||
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
||||
raise ReleaseAuthorizationError("acceptance deployment scope denied")
|
||||
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"accept")
|
||||
return self.lineage.record_acceptance(
|
||||
tenant_id,subject_id,mission_id,component_id,acceptance_id,deployment_id,release_id,
|
||||
artifact,accepted,evidence=evidence,verifier=verifier,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EnvironmentDeploymentTarget", "ReleaseAuthorizationError", "ReleaseConflict", "ReleaseError", "ReleaseLedger",
|
||||
"ReleaseReconciliationRequired", "ReleaseVerificationError", "VerifiedReceipt",
|
||||
]
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Pure validation and deployment-truth evaluation for runtime contracts.
|
||||
|
||||
Runtime contracts describe stable structure. A release-owned deployment target
|
||||
is supplied separately so that changing a release does not rewrite structural
|
||||
configuration or turn a source branch into runtime evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
CONTRACT_SCHEMA = "aios.runtime-contract.v1"
|
||||
LIFECYCLES = frozenset({"active", "candidate", "legacy", "example"})
|
||||
_DYNAMIC_FIELDS = frozenset(
|
||||
{"desired", "desired_commit", "desired_release", "desired_target", "desired_targets"}
|
||||
)
|
||||
_SECRET_FIELDS = frozenset(
|
||||
{"secret", "secret_bytes", "token", "password", "private_key", "credential_value", "value"}
|
||||
)
|
||||
_DIGEST = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")
|
||||
_SAFE_SYMBOLIC_REF = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[/A-Za-z0-9_.:@+-]+$")
|
||||
|
||||
|
||||
def _error(code: str, path: str = "") -> ValueError:
|
||||
return ValueError(f"{code}:{path}" if path else code)
|
||||
|
||||
|
||||
def _nonempty_string(value: object, path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise _error("field_invalid", path)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _positive_int(value: object, path: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise _error("field_invalid", path)
|
||||
return value
|
||||
|
||||
|
||||
def _reject_dynamic_fields(value: object, path: str = "contract") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for key, child in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise _error("field_name_invalid", path)
|
||||
child_path = f"{path}.{key}"
|
||||
if key.lower() in _DYNAMIC_FIELDS:
|
||||
raise _error("dynamic_field_forbidden", child_path)
|
||||
_reject_dynamic_fields(child, child_path)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
for index, child in enumerate(value):
|
||||
_reject_dynamic_fields(child, f"{path}[{index}]")
|
||||
|
||||
|
||||
def _safe_path_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
if "\x00" in text or "\\" in text:
|
||||
raise _error("unsafe_path_ref", path)
|
||||
candidate = PurePosixPath(text)
|
||||
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
|
||||
raise _error("unsafe_path_ref", path)
|
||||
return text
|
||||
|
||||
|
||||
def _symbolic_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
if "\x00" in text or ".." in text or not _SAFE_SYMBOLIC_REF.fullmatch(text):
|
||||
raise _error("resolver_ref_invalid", path)
|
||||
return text
|
||||
|
||||
|
||||
def _credential_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
allowed = (
|
||||
text.startswith("credential://")
|
||||
or text.startswith("secret://")
|
||||
or text.startswith("systemd-credential:")
|
||||
)
|
||||
if not allowed or "\x00" in text or ".." in text or text.startswith("/run/credentials/"):
|
||||
raise _error("credential_ref_invalid", path)
|
||||
return text
|
||||
|
||||
|
||||
def _validate_binding_group(
|
||||
group: object,
|
||||
group_name: str,
|
||||
component_id: str,
|
||||
environment: str,
|
||||
tenant_scope: str,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(group, Mapping):
|
||||
raise _error("binding_group_invalid", group_name)
|
||||
normalized: dict[str, Any] = {}
|
||||
for name, raw in group.items():
|
||||
item_path = f"{group_name}.{name}"
|
||||
_nonempty_string(name, f"{group_name}.name")
|
||||
if isinstance(raw, str):
|
||||
reference_field = (
|
||||
"credential_ref"
|
||||
if group_name == "credential_refs"
|
||||
else "path_ref"
|
||||
if group_name == "resource_refs" and ":" not in raw
|
||||
else "resolver_ref"
|
||||
)
|
||||
item = {
|
||||
"component_id": component_id,
|
||||
"environment": environment,
|
||||
reference_field: raw,
|
||||
}
|
||||
elif isinstance(raw, Mapping):
|
||||
item = deepcopy(dict(raw))
|
||||
else:
|
||||
raise _error("binding_invalid", item_path)
|
||||
if _nonempty_string(item.get("component_id"), f"{item_path}.component_id") != component_id:
|
||||
raise _error("binding_component_mismatch", item_path)
|
||||
if _nonempty_string(item.get("environment"), f"{item_path}.environment") != environment:
|
||||
raise _error("binding_environment_mismatch", item_path)
|
||||
if "tenant_scope" in item and (
|
||||
_nonempty_string(item["tenant_scope"], f"{item_path}.tenant_scope") != tenant_scope
|
||||
):
|
||||
raise _error("binding_tenant_mismatch", item_path)
|
||||
|
||||
reference_fields = [key for key in ("resolver_ref", "path_ref", "credential_ref") if key in item]
|
||||
if len(reference_fields) != 1:
|
||||
raise _error("binding_reference_invalid", item_path)
|
||||
reference_field = reference_fields[0]
|
||||
reference_path = f"{item_path}.{reference_field}"
|
||||
if group_name == "credential_refs" or reference_field == "credential_ref":
|
||||
for key in item:
|
||||
if key.lower() in _SECRET_FIELDS:
|
||||
raise _error("secret_material_forbidden", f"{item_path}.{key}")
|
||||
item[reference_field] = _credential_ref(item[reference_field], reference_path)
|
||||
elif reference_field == "path_ref":
|
||||
item[reference_field] = _safe_path_ref(item[reference_field], reference_path)
|
||||
else:
|
||||
item[reference_field] = _symbolic_ref(item[reference_field], reference_path)
|
||||
normalized[str(name)] = item
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_contract(document: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and copy a stable ``aios.runtime-contract.v1`` document.
|
||||
|
||||
The returned mapping owns its nested containers, so callers cannot mutate the
|
||||
source after validation. Invalid contracts raise ``ValueError`` with a stable
|
||||
machine-readable code prefix.
|
||||
"""
|
||||
|
||||
if not isinstance(document, Mapping):
|
||||
raise _error("contract_invalid")
|
||||
_reject_dynamic_fields(document)
|
||||
result = deepcopy(dict(document))
|
||||
if result.get("contract_schema") != CONTRACT_SCHEMA:
|
||||
raise _error("contract_schema_invalid", "contract_schema")
|
||||
result["contract_revision"] = _positive_int(result.get("contract_revision"), "contract_revision")
|
||||
component_id = _nonempty_string(result.get("component_id"), "component_id")
|
||||
environment = _nonempty_string(result.get("environment"), "environment")
|
||||
result["component_id"] = component_id
|
||||
result["environment"] = environment
|
||||
tenant_scope = _nonempty_string(result.get("tenant_scope"), "tenant_scope")
|
||||
result["tenant_scope"] = tenant_scope
|
||||
lifecycle = _nonempty_string(result.get("lifecycle"), "lifecycle").lower()
|
||||
if lifecycle not in LIFECYCLES:
|
||||
raise _error("lifecycle_invalid", "lifecycle")
|
||||
result["lifecycle"] = lifecycle
|
||||
|
||||
if "resources" in result and "resource_refs" in result:
|
||||
raise _error("duplicate_resource_group")
|
||||
if "resources" in result:
|
||||
result["resource_refs"] = result.pop("resources")
|
||||
for group_name in ("entrypoints", "bindings", "resource_refs", "credential_refs"):
|
||||
result[group_name] = _validate_binding_group(
|
||||
result.get(group_name), group_name, component_id, environment, tenant_scope
|
||||
)
|
||||
|
||||
operations = result.get("operations")
|
||||
if (
|
||||
not isinstance(operations, Sequence)
|
||||
or isinstance(operations, (str, bytes, bytearray))
|
||||
or not operations
|
||||
):
|
||||
raise _error("operations_invalid", "operations")
|
||||
normalized_operations = [
|
||||
_nonempty_string(operation, f"operations[{index}]")
|
||||
for index, operation in enumerate(operations)
|
||||
]
|
||||
if len(set(normalized_operations)) != len(normalized_operations):
|
||||
raise _error("duplicate_operation", "operations")
|
||||
result["operations"] = normalized_operations
|
||||
return result
|
||||
|
||||
|
||||
def _unknown(*reasons: str) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"state": "UNKNOWN",
|
||||
"verification": "UNKNOWN",
|
||||
"converged": False,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
}
|
||||
|
||||
|
||||
def _drift(reasons: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "DRIFT",
|
||||
"state": "DRIFT",
|
||||
"verification": "FAILED",
|
||||
"converged": False,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
}
|
||||
|
||||
|
||||
def _parse_time(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
return None
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _digest_value(document: Mapping[str, Any], *names: str) -> object:
|
||||
for name in names:
|
||||
if name in document:
|
||||
return document[name]
|
||||
return None
|
||||
|
||||
|
||||
def _valid_digest(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(_DIGEST.fullmatch(value))
|
||||
|
||||
|
||||
def evaluate_deployment(
|
||||
contract: Mapping[str, Any],
|
||||
target: Mapping[str, Any] | None,
|
||||
observation: Mapping[str, Any] | None,
|
||||
provenance: Mapping[str, Any] | None,
|
||||
now: datetime | str,
|
||||
) -> dict[str, Any]:
|
||||
"""Evaluate one target using fresh runtime and provenance evidence.
|
||||
|
||||
Missing or malformed evidence is ``UNKNOWN``. ``DRIFT`` is reserved for a
|
||||
complete evidence set that proves an incompatible target or runtime fact.
|
||||
Repository HEAD is intentionally not an input to this decision.
|
||||
"""
|
||||
|
||||
stable = validate_contract(contract)
|
||||
missing: list[str] = []
|
||||
if target is None:
|
||||
missing.append("target_missing")
|
||||
elif not isinstance(target, Mapping):
|
||||
missing.append("target_invalid")
|
||||
if observation is None:
|
||||
missing.append("observation_missing")
|
||||
elif not isinstance(observation, Mapping):
|
||||
missing.append("observation_invalid")
|
||||
if provenance is None:
|
||||
missing.append("provenance_missing")
|
||||
elif not isinstance(provenance, Mapping):
|
||||
missing.append("provenance_invalid")
|
||||
current_time = _parse_time(now)
|
||||
if current_time is None:
|
||||
missing.append("now_invalid")
|
||||
if missing:
|
||||
return _unknown(*missing)
|
||||
assert target is not None and observation is not None and provenance is not None
|
||||
assert current_time is not None
|
||||
|
||||
target_errors: list[str] = []
|
||||
for field in ("component_id", "environment", "release_id", "deployment_id"):
|
||||
if not isinstance(target.get(field), str) or not str(target[field]).strip():
|
||||
target_errors.append(f"target_{field}_invalid")
|
||||
if "tenant_scope" in target and (
|
||||
not isinstance(target["tenant_scope"], str) or not target["tenant_scope"].strip()
|
||||
):
|
||||
target_errors.append("target_tenant_scope_invalid")
|
||||
target_artifact = target.get("artifact_digest")
|
||||
if not _valid_digest(target_artifact):
|
||||
target_errors.append("target_artifact_digest_invalid")
|
||||
if isinstance(target.get("revision"), bool) or not isinstance(target.get("revision"), int) or target.get("revision", 0) < 1:
|
||||
target_errors.append("target_revision_invalid")
|
||||
|
||||
observed_at = _parse_time(observation.get("observed_at"))
|
||||
max_age = observation.get("max_age_seconds", observation.get("max_age"))
|
||||
observed_artifact = _digest_value(
|
||||
observation, "actual_artifact_digest", "actual_artifact", "artifact_digest"
|
||||
)
|
||||
observation_errors: list[str] = []
|
||||
if observed_at is None:
|
||||
observation_errors.append("observation_observed_at_invalid")
|
||||
if isinstance(max_age, bool) or not isinstance(max_age, (int, float)) or max_age <= 0:
|
||||
observation_errors.append("observation_max_age_invalid")
|
||||
if not isinstance(observation.get("source"), str) or not observation.get("source", "").strip():
|
||||
observation_errors.append("observation_source_invalid")
|
||||
if not _valid_digest(observed_artifact):
|
||||
observation_errors.append("observation_actual_artifact_invalid")
|
||||
if not isinstance(observation.get("healthy"), bool):
|
||||
observation_errors.append("observation_healthy_invalid")
|
||||
for field in ("component_id", "environment", "tenant_scope", "deployment_id"):
|
||||
if field in observation and (
|
||||
not isinstance(observation[field], str) or not observation[field].strip()
|
||||
):
|
||||
observation_errors.append(f"observation_{field}_invalid")
|
||||
if "revision" in observation and (
|
||||
isinstance(observation["revision"], bool)
|
||||
or not isinstance(observation["revision"], int)
|
||||
or observation["revision"] < 1
|
||||
):
|
||||
observation_errors.append("observation_revision_invalid")
|
||||
|
||||
artifact_provenance = _digest_value(provenance, "artifact_digest", "artifact_hash")
|
||||
source_provenance = _digest_value(provenance, "source_digest", "source_hash")
|
||||
builder_provenance = _digest_value(provenance, "builder_digest", "builder_hash")
|
||||
provenance_errors: list[str] = []
|
||||
if not _valid_digest(artifact_provenance):
|
||||
provenance_errors.append("provenance_artifact_digest_invalid")
|
||||
if not _valid_digest(source_provenance):
|
||||
provenance_errors.append("provenance_source_digest_invalid")
|
||||
if not _valid_digest(builder_provenance):
|
||||
provenance_errors.append("provenance_builder_digest_invalid")
|
||||
|
||||
evidence_errors = target_errors + observation_errors + provenance_errors
|
||||
if evidence_errors:
|
||||
return _unknown(*evidence_errors)
|
||||
assert observed_at is not None and isinstance(max_age, (int, float))
|
||||
age = (current_time - observed_at).total_seconds()
|
||||
if age < 0:
|
||||
return _unknown("observation_from_future")
|
||||
if age > max_age:
|
||||
return _unknown("observation_stale")
|
||||
|
||||
drift: list[str] = []
|
||||
if target["component_id"] != stable["component_id"]:
|
||||
drift.append("target_component_id_mismatch")
|
||||
if target["environment"] != stable["environment"]:
|
||||
drift.append("target_environment_mismatch")
|
||||
if "tenant_scope" in target and target["tenant_scope"] != stable["tenant_scope"]:
|
||||
drift.append("target_tenant_scope_mismatch")
|
||||
if "tenant_scope" in observation and observation["tenant_scope"] != stable["tenant_scope"]:
|
||||
drift.append("observation_tenant_scope_mismatch")
|
||||
for field in ("component_id", "environment", "deployment_id", "revision"):
|
||||
if field in observation and observation[field] != target[field]:
|
||||
drift.append(f"observation_{field}_mismatch")
|
||||
if observed_artifact != target_artifact:
|
||||
drift.append("artifact_mismatch")
|
||||
if artifact_provenance != target_artifact:
|
||||
drift.append("provenance_artifact_mismatch")
|
||||
if observation["healthy"] is False:
|
||||
drift.append("observation_unhealthy")
|
||||
if drift:
|
||||
return _drift(drift)
|
||||
return {
|
||||
"status": "VERIFIED",
|
||||
"state": "CONVERGED",
|
||||
"verification": "VERIFIED",
|
||||
"converged": True,
|
||||
"reasons": [],
|
||||
"component_id": target["component_id"],
|
||||
"environment": target["environment"],
|
||||
"release_id": target["release_id"],
|
||||
"deployment_id": target["deployment_id"],
|
||||
"artifact_digest": target_artifact,
|
||||
"revision": target["revision"],
|
||||
"observed_at": observation["observed_at"],
|
||||
"observation_source": observation["source"],
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Thin authenticated application service over existing TaskPlaneStore and JobStore.
|
||||
|
||||
This module owns no second Task lifecycle store. Added task runtime columns live
|
||||
in the supplied Task authority database; the lineage ledger owns only relations.
|
||||
The built-in host adapter is READ_ONLY. Mutating operations require separately
|
||||
registered governed adapters with receiver fencing/readback, never arbitrary argv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
from .runtime_contract import validate_contract
|
||||
from .worker_policy import eligible
|
||||
from .authorization import AuthorizationError
|
||||
from .host import HostError
|
||||
|
||||
|
||||
class ServiceError(RuntimeError):
|
||||
def __init__(self,code,status=409):
|
||||
super().__init__(code)
|
||||
self.code=code
|
||||
self.status=status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
role: str
|
||||
worker_id: str | None=None
|
||||
|
||||
|
||||
class BaselineService:
|
||||
def __init__(self,store,job_root,authorization,contracts,operations,worker_profiles,host,lineage,machine_key,
|
||||
*,machine_key_id='baseline-machine',trusted_receipt_keys=None):
|
||||
self.store=store
|
||||
self.signer=ReceiptSigner(machine_key,key_id=machine_key_id,trusted_public_keys=trusted_receipt_keys)
|
||||
self.jobs=GitOpsJobController(JobStore(Path(job_root)),generic_receipt_verifier=self.signer.verify)
|
||||
self.authorization=authorization
|
||||
self.contracts={}
|
||||
for entry in contracts:
|
||||
value=validate_contract(entry)
|
||||
key=(value['tenant_scope'],value['environment'],value['component_id'])
|
||||
if key in self.contracts: raise ServiceError('CONTRACT_DUPLICATE')
|
||||
self.contracts[key]=value
|
||||
self.operations=operations
|
||||
self.worker_profiles=dict(worker_profiles)
|
||||
self.host=host
|
||||
self.lineage=lineage
|
||||
self.storage_blocked=False
|
||||
self._execute_lock=threading.Lock()
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_task_runtime(
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(task_id),ready_at REAL,
|
||||
blocker_code TEXT,job_id TEXT,effect_id TEXT NOT NULL UNIQUE)''')
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
raise ServiceError('USER_IDENTITY_REQUIRED',403)
|
||||
|
||||
def _worker(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role!='worker':
|
||||
raise ServiceError('WORKER_IDENTITY_REQUIRED',403)
|
||||
profile=self.worker_profiles.get(identity.worker_id)
|
||||
if not profile or profile.identity!=identity.subject_id or identity.tenant_id not in profile.tenant_scope:
|
||||
raise ServiceError('WORKER_SCOPE_DENIED',403)
|
||||
return profile
|
||||
|
||||
def _task(self,identity,task_id):
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if meta.get('tenant_id')!=identity.tenant_id or meta.get('subject_id')!=identity.subject_id:
|
||||
raise ServiceError('TASK_SCOPE_DENIED',403)
|
||||
return task
|
||||
|
||||
def _scope(self,task):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
return {**{key:meta[key] for key in ('tenant_id','subject_id','mission_id','operation','component_id','environment')},'task_id':task.task_id}
|
||||
|
||||
def _principal(self,task):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
return self.authorization.issue_principal(meta['grant_id'],ttl_seconds=60,**self._scope(task))
|
||||
|
||||
def _fresh_write(self):
|
||||
if self.storage_blocked: raise ServiceError('STORAGE_RECOVERY_REQUIRED',503)
|
||||
try:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=blocker_code WHERE 0')
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('TASK_STORAGE_UNAVAILABLE',503) from exc
|
||||
|
||||
def submit_goal(self,identity,request,*,idempotency_key):
|
||||
self._user(identity)
|
||||
allowed={'mission_id','grant_id','operation','component_id','environment','inputs','intent'}
|
||||
if type(request) is not dict or set(request)!=allowed or not isinstance(idempotency_key,str) or not 1<=len(idempotency_key)<=128:
|
||||
raise ServiceError('GOAL_SCHEMA_INVALID',400)
|
||||
if any(not isinstance(request[key],str) or not request[key] or len(request[key])>2048 for key in allowed-{'inputs'}):
|
||||
raise ServiceError('GOAL_SCHEMA_INVALID',400)
|
||||
if request['inputs']!={}: raise ServiceError('OPERATION_INPUT_SCHEMA_INVALID',400)
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',request['mission_id']): raise ServiceError('MISSION_ID_INVALID',400)
|
||||
operation=self.operations.get(request['operation'])
|
||||
if operation.handler_ref!='host.service_status' or operation.effect_class!='READ_ONLY' or operation.required_permissions!=(operation.operation_id,):
|
||||
raise ServiceError('REGISTERED_HANDLER_UNAVAILABLE',503)
|
||||
contract=self.contracts.get((identity.tenant_id,request['environment'],request['component_id']))
|
||||
if not contract or contract['lifecycle']!='active' or request['operation'] not in contract['operations']:
|
||||
raise ServiceError('CONTRACT_OPERATION_UNAVAILABLE',403)
|
||||
host_key=identity.tenant_id+':'+request['environment']+':'+request['component_id']
|
||||
if host_key not in self.host.bindings: raise ServiceError('HOST_BINDING_UNAVAILABLE',503)
|
||||
key=digest({'tenant':identity.tenant_id,'subject':identity.subject_id,'key':idempotency_key})
|
||||
task_id='task-'+key[:40]
|
||||
effect_id='effect-'+digest({'tenant':identity.tenant_id,'task':task_id,'request':request})[:40]
|
||||
meta={**request,'tenant_id':identity.tenant_id,'subject_id':identity.subject_id,'effect_id':effect_id,
|
||||
'operation_version':operation.version,'contract_revision':contract['contract_revision']}
|
||||
plan=TaskPlan.from_dict({'task_id':task_id,'task_type':'host.diagnostic','intent':request['intent'],
|
||||
'issuer':{'id':identity.subject_id,'tenant_id':identity.tenant_id,'subject_id':identity.subject_id},
|
||||
'required_capabilities':[request['operation']],'risk':'low','approval_policy':'governed-grant',
|
||||
'correlation_id':request['mission_id'],'metadata':{'baseline':meta},
|
||||
'budgets':{'attempts':operation.retry['max_attempts'],'time_seconds':30*operation.retry['max_attempts']},'evidence_requirements':['diagnostic']})
|
||||
# Validate business authorization before creating an executable task.
|
||||
self.authorization.issue_principal(request['grant_id'],ttl_seconds=60,tenant_id=identity.tenant_id,
|
||||
subject_id=identity.subject_id,task_id=task_id,**{key:request[key] for key in ('mission_id','operation','component_id','environment')})
|
||||
self._fresh_write()
|
||||
try:
|
||||
with self.store._transaction():
|
||||
task=self.store.create_task_with_event(plan,idempotency_key=key)
|
||||
self.store.conn.execute('INSERT OR IGNORE INTO baseline_task_runtime(task_id,ready_at,effect_id) VALUES(?,?,?)',
|
||||
(task_id,time.time(),effect_id))
|
||||
self.lineage.link_task(identity.tenant_id,identity.subject_id,request['mission_id'],request['grant_id'],task_id)
|
||||
return self.get_task(identity,task_id)
|
||||
except TaskPlaneHTTPError as exc:
|
||||
raise ServiceError('TASK_CONFLICT') from exc
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('TASK_STORAGE_UNAVAILABLE',503) from exc
|
||||
|
||||
def get_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
task=self._task(identity,task_id)
|
||||
with self.store._transaction():
|
||||
row=self.store.conn.execute('SELECT * FROM baseline_task_runtime WHERE task_id=?',(task_id,)).fetchone()
|
||||
result=task.to_dict()
|
||||
if row:
|
||||
result.update({'ready_at':row['ready_at'],'blocker_code':row['blocker_code'],'effect_id':row['effect_id'],'job_id':row['job_id']})
|
||||
if row['job_id']:
|
||||
job=self.jobs.store.read(row['job_id'])
|
||||
result['job_state']=job['state']
|
||||
result['side_effect_state']=job['side_effect_state']
|
||||
envelope=json.loads(job['receipt_json']) if job.get('receipt_json') else None
|
||||
if self._receipt_valid(task,envelope): result['receipt']=envelope['payload']
|
||||
result['remaining_budget']=self.store.get_task_budget(task_id).to_dict()
|
||||
return result
|
||||
|
||||
def explain(self,identity,mission_id):
|
||||
self._user(identity)
|
||||
result=self.lineage.explain(identity.tenant_id,identity.subject_id,mission_id)
|
||||
result['tasks_runtime']=[self.get_task(identity,t.task_id) for t in self.store.list_tasks()
|
||||
if t.task_plan.metadata.get('baseline',{}).get('mission_id')==mission_id and
|
||||
t.task_plan.issuer.get('tenant_id')==identity.tenant_id and t.task_plan.issuer.get('subject_id')==identity.subject_id]
|
||||
return result
|
||||
|
||||
def register_worker(self,identity):
|
||||
profile=self._worker(identity)
|
||||
try:
|
||||
existing=self.store.get_worker(profile.worker_id)
|
||||
except TaskPlaneHTTPError:
|
||||
existing=None
|
||||
now=time.time()
|
||||
worker=WorkerRegistration.from_dict({'worker_id':profile.worker_id,'worker_type':'baseline-operation-worker',
|
||||
'identity':profile.identity,'capabilities':list(profile.capabilities),'trust_zone':'governed-host',
|
||||
'max_concurrency':profile.max_concurrency,'heartbeat_interval_seconds':profile.heartbeat_seconds,
|
||||
'supported_protocols':['aios-baseline-worker-v1'],'last_heartbeat':now})
|
||||
self.store.register_worker(worker,actor_identity=profile.identity,is_admin=False,
|
||||
expected_revision=existing.registration_revision if existing else None)
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('INSERT INTO baseline_worker_policy VALUES(?,?) ON CONFLICT(worker_id) DO UPDATE SET policy_json=excluded.policy_json',
|
||||
(profile.worker_id,canonical(profile.__dict__).decode()))
|
||||
return {'worker_id':profile.worker_id,'registered':True,'fallback_executor':profile.fallback_executor}
|
||||
|
||||
def heartbeat(self,identity):
|
||||
profile=self._worker(identity)
|
||||
worker=self.store.get_worker(profile.worker_id)
|
||||
self.store.heartbeat_worker(profile.worker_id,actor_identity=profile.identity,expected_revision=worker.registration_revision)
|
||||
return {'worker_id':profile.worker_id,'alive':True}
|
||||
|
||||
def claim_next(self,identity):
|
||||
profile=self._worker(identity)
|
||||
worker=self.store.get_worker(profile.worker_id)
|
||||
if worker.disabled: raise ServiceError('WORKER_DISABLED',403)
|
||||
self._fresh_write()
|
||||
for task in sorted(self.store.list_tasks(),key=lambda t:(t.created_at,t.task_id)):
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if not meta or task.status not in {'CREATED','QUEUED','FAILED_RECOVERABLE'}: continue
|
||||
with self.store._transaction():
|
||||
row=self.store.conn.execute('SELECT ready_at FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
if not row: continue
|
||||
now=time.time()
|
||||
task_scope={**meta,'ready_at':row['ready_at']}
|
||||
if not eligible(profile,task_scope,now=now,last_heartbeat=worker.last_heartbeat): continue
|
||||
# Priority is configuration on ordinary eligible workers; no special main actor.
|
||||
candidates=[]
|
||||
for registered in self.store.list_workers():
|
||||
peer=self.worker_profiles.get(registered.worker_id)
|
||||
if not peer or registered.disabled or not eligible(peer,task_scope,now=now,last_heartbeat=registered.last_heartbeat): continue
|
||||
with self.store._transaction():
|
||||
active=self.store.conn.execute("SELECT count(*) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",(peer.worker_id,now)).fetchone()[0]
|
||||
if active<peer.max_concurrency: candidates.append(peer)
|
||||
if not candidates or sorted(candidates,key=lambda p:(-p.priority,p.worker_id))[0].worker_id!=profile.worker_id: continue
|
||||
try:
|
||||
self._principal(task)
|
||||
with self.store._transaction():
|
||||
self.store.consume_budget(task.task_id,Budget(attempts=1,time_seconds=30))
|
||||
claim=self.store.claim_task(task.task_id,profile.worker_id,profile.identity,lease_seconds=30,
|
||||
expected_revision=task.revision,operation_digest=task.task_plan.operation_digest())
|
||||
except TaskPlaneHTTPError:
|
||||
budget=self.store.get_task_budget(task.task_id)
|
||||
if budget.attempts==0 or budget.time_seconds==0:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',('TASK_BUDGET_EXHAUSTED',task.task_id))
|
||||
continue
|
||||
except AuthorizationError:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',('AUTHORIZATION_REQUIRED',task.task_id))
|
||||
continue
|
||||
return {'task_id':task.task_id,'claim_id':claim.claim_id,'generation':claim.attempt,
|
||||
'lease_until':claim.lease_until,'operation':meta['operation']}
|
||||
return None
|
||||
|
||||
def renew(self,identity,task_id,claim_id):
|
||||
profile=self._worker(identity)
|
||||
claim=self.store.get_claim(claim_id)
|
||||
if claim.task_id!=task_id or claim.worker_id!=profile.worker_id or claim.worker_identity!=profile.identity:
|
||||
raise ServiceError('CLAIM_IDENTITY_MISMATCH',403)
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if meta.get('tenant_id') not in profile.tenant_scope or meta.get('subject_id') not in profile.subject_scope or task.status=='CANCELLED':
|
||||
raise ServiceError('CLAIM_SCOPE_OR_STATE_DENIED',403)
|
||||
self._principal(task)
|
||||
self.store.heartbeat_claim(task_id,claim_id,30)
|
||||
return {'task_id':task_id,'claim_id':claim_id,'renewed':True}
|
||||
|
||||
def _advance(self,job_id,state,**kwargs):
|
||||
row=self.jobs.store.read(job_id)
|
||||
return self.jobs.advance(job_id,state,expected_revision=int(row['revision']),**kwargs)
|
||||
|
||||
@contextmanager
|
||||
def _execution_slot(self,deadline):
|
||||
remaining=deadline-time.time()
|
||||
if remaining<=0 or not self._execute_lock.acquire(timeout=remaining):
|
||||
raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
try:
|
||||
if time.time()>=deadline: raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
yield
|
||||
finally: self._execute_lock.release()
|
||||
|
||||
def execute_claim(self,identity,task_id,claim_id,*,deadline=None):
|
||||
profile=self._worker(identity)
|
||||
deadline=time.time()+30 if deadline is None else deadline
|
||||
with self._execution_slot(deadline):
|
||||
self._fresh_write()
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
claim=self.store.get_claim(claim_id)
|
||||
if claim.task_id!=task_id or claim.worker_id!=profile.worker_id or claim.worker_identity!=profile.identity:
|
||||
raise ServiceError('CLAIM_IDENTITY_MISMATCH',403)
|
||||
if meta.get('tenant_id') not in profile.tenant_scope or meta.get('subject_id') not in profile.subject_scope:
|
||||
raise ServiceError('WORKER_SCOPE_DENIED',403)
|
||||
user=Identity(meta['tenant_id'],meta['subject_id'],'user')
|
||||
if task.status=='COMPLETED': return self.get_task(user,task_id)
|
||||
if claim.status!='ACTIVE' or claim.lease_until<=time.time() or task.status=='CANCELLED':
|
||||
raise ServiceError('CLAIM_EXPIRED_OR_CANCELLED')
|
||||
principal=self._principal(task)
|
||||
self.authorization.authorize(principal,**self._scope(task))
|
||||
operation=self.operations.get(meta['operation'])
|
||||
if operation.handler_ref!='host.service_status' or operation.effect_class!='READ_ONLY':
|
||||
raise ServiceError('HANDLER_NOT_REGISTERED',503)
|
||||
self.authorization.assert_effect_bounds(principal,operation,**self._scope(task))
|
||||
request={'schema':'aios.operation-job.v1','tenant_id':meta['tenant_id'],'subject_id':meta['subject_id'],
|
||||
'mission_id':meta['mission_id'],'grant_id':meta['grant_id'],'task_id':task_id,'effect_id':meta['effect_id'],
|
||||
'operation':meta['operation'],'operation_version':str(operation.version),'resource_kind':'systemd-service',
|
||||
'resource_id':digest({'tenant':meta['tenant_id'],'environment':meta['environment'],'component':meta['component_id']}),'input_digest':digest(meta['inputs'])}
|
||||
try:
|
||||
job=self.jobs.submit(request)
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET job_id=? WHERE task_id=?',(job.job_id,task_id))
|
||||
self.lineage.link_task(meta['tenant_id'],meta['subject_id'],meta['mission_id'],meta['grant_id'],task_id)
|
||||
self.lineage.link_effect(meta['tenant_id'],meta['subject_id'],meta['mission_id'],task_id,meta['effect_id'],job.job_id)
|
||||
row=self.jobs.store.read(job.job_id)
|
||||
if row['state']=='COMPLETED':
|
||||
self._close_task(task,claim,json.loads(row['receipt_json']))
|
||||
return self.get_task(user,task_id)
|
||||
if row['state'] not in {'REQUESTED','AUTHORIZED'}:
|
||||
raise ServiceError('JOB_RECONCILIATION_REQUIRED')
|
||||
self.jobs.assert_effect_admission(exclude_job_id=job.job_id)
|
||||
self.authorization.reserve_effect(meta['grant_id'],meta['effect_id'],principal,**self._scope(task))
|
||||
if row['state']=='REQUESTED': self._advance(job.job_id,JobState.AUTHORIZED,side_effect_state='AUTHORIZED')
|
||||
row=self.jobs.store.read(job.job_id)
|
||||
self.jobs.lease_resource_job(job.job_id,lease_id=claim_id,resource_kind=request['resource_kind'],
|
||||
resource_id=request['resource_id'],generation=None,
|
||||
lease_expires_at=datetime.fromtimestamp(min(claim.lease_until,time.time()+30,deadline),timezone.utc).isoformat(),
|
||||
expected_revision=int(row['revision']))
|
||||
effect_lease=self.jobs.get_execution_lease(job.job_id)
|
||||
fence_generation=int(effect_lease['generation'])
|
||||
self._advance(job.job_id,JobState.EXECUTING,side_effect_state='IN_PROGRESS')
|
||||
# Last authorization/claim check directly before the registered host boundary.
|
||||
self.authorization.authorize(principal,**self._scope(task))
|
||||
if self.store.get_task(task_id).status=='CANCELLED' or self.store.get_claim(claim_id).lease_until<=time.time():
|
||||
raise ServiceError('CLAIM_EXPIRED_OR_CANCELLED')
|
||||
self.jobs.assert_execution_lease(job.job_id,generation=fence_generation,lease_id=claim_id)
|
||||
if time.time()>=deadline: raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
observation=self.host.status(meta['component_id'],meta['environment'],tenant_id=meta['tenant_id'],deadline=deadline)
|
||||
if time.time()>=deadline or self.store.get_claim(claim_id).lease_until<=time.time():
|
||||
raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
envelope=self.signer.sign({'task_id':task_id,'job_id':job.job_id,'effect_id':meta['effect_id'],
|
||||
'tenant_id':meta['tenant_id'],'subject_id':meta['subject_id'],'mission_id':meta['mission_id'],
|
||||
'grant_id':meta['grant_id'],'claim_id':claim_id,'generation':fence_generation,
|
||||
'operation':meta['operation'],'operation_version':str(operation.version),
|
||||
'input_digest':request['input_digest'],'resource_kind':request['resource_kind'],
|
||||
'resource_id':request['resource_id'],'observation':observation,'verified':True})
|
||||
self._advance(job.job_id,JobState.LOCAL_VERIFIED,side_effect_state='READBACK_VERIFIED',receipt=envelope)
|
||||
self._advance(job.job_id,JobState.COMPLETED,side_effect_state='READBACK_VERIFIED',receipt=envelope)
|
||||
self._close_task(task,claim,envelope)
|
||||
return self.get_task(user,task_id)
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('STORAGE_RECOVERY_REQUIRED',503) from exc
|
||||
except HostError as exc:
|
||||
try:
|
||||
self._advance(job.job_id,JobState.RECONCILIATION_REQUIRED,side_effect_state='UNKNOWN')
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',(str(exc),task_id))
|
||||
except (sqlite3.Error,OSError,JobError): self.storage_blocked=True
|
||||
raise ServiceError(str(exc),503) from exc
|
||||
except JobError as exc:
|
||||
if 'UNAVAILABLE' in str(exc) or 'STORAGE' in str(exc): self.storage_blocked=True
|
||||
raise ServiceError(str(exc),503 if self.storage_blocked else 409) from exc
|
||||
|
||||
def _receipt_valid(self,task,envelope):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
if not self.signer.verify(envelope): return False
|
||||
payload=envelope['payload']
|
||||
with self.store._transaction():
|
||||
runtime=self.store.conn.execute('SELECT job_id FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
if not runtime or not runtime['job_id']: return False
|
||||
expected={**{k:meta[k] for k in ('effect_id','tenant_id','subject_id','mission_id','grant_id','operation')},
|
||||
'task_id':task.task_id,'job_id':runtime['job_id'],'operation_version':str(meta['operation_version']),
|
||||
'input_digest':digest(meta['inputs']),'resource_kind':'systemd-service',
|
||||
'resource_id':digest({'tenant':meta['tenant_id'],'environment':meta['environment'],'component':meta['component_id']})}
|
||||
if any(payload.get(k)!=v for k,v in expected.items()) or payload.get('verified') is not True: return False
|
||||
try:
|
||||
lease=self.jobs.get_execution_lease(runtime['job_id'])
|
||||
source_claim=self.store.get_claim(payload.get('claim_id'))
|
||||
if payload.get('claim_id')!=lease['lease_id'] or payload.get('generation')!=lease['generation']: return False
|
||||
if source_claim.task_id!=task.task_id or source_claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
except (JobError,TaskPlaneHTTPError,TypeError): return False
|
||||
observation=payload.get('observation')
|
||||
if type(observation) is not dict: return False
|
||||
for k in ('operation','tenant_id','component_id','environment'):
|
||||
if observation.get(k)!=meta[k]: return False
|
||||
binding=meta['tenant_id']+':'+meta['environment']+':'+meta['component_id']
|
||||
if observation.get('unit')!=self.host.bindings.get(binding) or observation.get('source')!='systemd.show': return False
|
||||
return True
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
task=self._task(identity,task_id)
|
||||
self.store.cancel_task_with_event(task_id,task.revision,'cancelled by authenticated subject')
|
||||
return self.get_task(identity,task_id)
|
||||
|
||||
def _verify_probe(self,probe):
|
||||
try:
|
||||
self.signer.public_key.verify(base64.b64decode(probe.signature,validate=True),canonical(probe.digest_payload()))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def recover(self):
|
||||
"""Reconcile expired baseline claims from durable signed receipts; never fake a result."""
|
||||
with self._execute_lock:
|
||||
return self._recover_locked()
|
||||
|
||||
def _recover_locked(self):
|
||||
recovered=[]
|
||||
with self.store._transaction():
|
||||
rows=self.store.conn.execute("SELECT claim_id FROM claims WHERE status='ACTIVE' AND lease_until<?",(time.time(),)).fetchall()
|
||||
for row in rows:
|
||||
claim=self.store.get_claim(row['claim_id'])
|
||||
task=self.store.get_task(claim.task_id)
|
||||
meta=task.task_plan.metadata.get('baseline')
|
||||
if not meta: continue
|
||||
with self.store._transaction():
|
||||
rt=self.store.conn.execute('SELECT job_id FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
outcome=ProbeOutcome.NOT_APPLIED
|
||||
ref='read-only-no-persistent-effect'
|
||||
if rt and rt['job_id']:
|
||||
job=self.jobs.store.read(rt['job_id'])
|
||||
env=json.loads(job['receipt_json']) if job.get('receipt_json') else None
|
||||
if job['state']=='COMPLETED':
|
||||
if self._receipt_valid(task,env):
|
||||
outcome=ProbeOutcome.APPLIED_VERIFIED
|
||||
ref=digest(env)
|
||||
else: outcome=ProbeOutcome.INDETERMINATE
|
||||
elif self.operations.get(meta['operation']).effect_class!='READ_ONLY':
|
||||
outcome=ProbeOutcome.INDETERMINATE
|
||||
now=time.time()
|
||||
probe=ProbeEvidence.create(task_id=task.task_id,claim_id=claim.claim_id,worker_id=claim.worker_id,
|
||||
worker_identity=claim.worker_identity,operation_digest=claim.operation_digest,outcome=outcome,
|
||||
observed_at=now,expires_at=now+30,probe_executor_identity='baseline-reconciler',attestation_ref=ref)
|
||||
from dataclasses import replace
|
||||
probe=replace(probe,signature=base64.b64encode(self.signer.private_key.sign(canonical(probe.digest_payload()))).decode())
|
||||
self.store.recover_claim_with_probe_and_event(task.task_id,claim.claim_id,probe)
|
||||
if outcome==ProbeOutcome.NOT_APPLIED and rt and rt['job_id']:
|
||||
job=self.jobs.store.read(rt['job_id'])
|
||||
if job['state']!='COMPLETED':
|
||||
self._advance(rt['job_id'],JobState.RECONCILIATION_REQUIRED,side_effect_state='UNKNOWN')
|
||||
self.jobs.reconcile(rt['job_id'],lambda _:{'state':'REQUESTED','side_effect_state':'READ_ONLY_SAFE_TO_RETRY'})
|
||||
recovered.append({'task_id':task.task_id,'outcome':outcome.value})
|
||||
# A successful writable transaction permits recovery processing, not blind effect replay.
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=blocker_code WHERE 0')
|
||||
try:
|
||||
self.jobs.assert_effect_admission()
|
||||
self.storage_blocked=False
|
||||
except JobError:
|
||||
self.storage_blocked=True
|
||||
return recovered
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Authenticated loopback HTTP transport for the Baseline 1.1 service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
from .service import Identity, ServiceError
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 65_536
|
||||
MAX_RESPONSE_BYTES = 1_048_576
|
||||
MAX_DEADLINE_SECONDS = 30
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}")
|
||||
_IDENTITY_FIELDS = frozenset({"tenant_id", "subject_id", "role", "worker_id", "identity"})
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
def __init__(self, code: str, status: int | None = None):
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class _RequestError(RuntimeError):
|
||||
def __init__(self, code: str, status: int):
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def _safe_identifier(value: Any, code: str = "HTTP_PATH_INVALID") -> str:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise _RequestError(code, 400)
|
||||
return value
|
||||
|
||||
|
||||
def _has_identity_field(value: Any) -> bool:
|
||||
if isinstance(value, Mapping):
|
||||
if any(key in _IDENTITY_FIELDS for key in value):
|
||||
return True
|
||||
return any(_has_identity_field(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_has_identity_field(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _loopback_host(host: str, code: str) -> str:
|
||||
if not isinstance(host, str):
|
||||
raise TransportError(code)
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError as exc:
|
||||
raise TransportError(code) from exc
|
||||
if not address.is_loopback or address.version != 4:
|
||||
raise TransportError(code)
|
||||
return str(address)
|
||||
|
||||
|
||||
class _BaselineHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address, handler, service, identities):
|
||||
self.baseline_service = service
|
||||
self.trusted_identities = identities
|
||||
self.request_slots=threading.BoundedSemaphore(16)
|
||||
super().__init__(address, handler)
|
||||
|
||||
def get_request(self):
|
||||
connection, address = super().get_request()
|
||||
connection.settimeout(MAX_DEADLINE_SECONDS)
|
||||
return connection, address
|
||||
|
||||
def process_request(self,request,client_address):
|
||||
if not self.request_slots.acquire(blocking=False):
|
||||
try:
|
||||
body=b'{"error":{"code":"HTTP_CAPACITY_EXCEEDED"}}'
|
||||
request.sendall(b'HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: '+str(len(body)).encode()+b'\r\n\r\n'+body)
|
||||
except OSError: pass
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try: super().process_request(request,client_address)
|
||||
except Exception:
|
||||
self.request_slots.release()
|
||||
raise
|
||||
|
||||
def process_request_thread(self,request,client_address):
|
||||
try: super().process_request_thread(request,client_address)
|
||||
finally: self.request_slots.release()
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "AIOSBaselineHTTP/1"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
# Request paths can contain tenant-owned identifiers; do not emit them or
|
||||
# bearer-related failure details through the default stderr logger.
|
||||
return
|
||||
|
||||
def send_error(self, code, message=None, explain=None):
|
||||
# BaseHTTPRequestHandler otherwise emits an HTML page which may include
|
||||
# parser details. Keep pre-dispatch protocol failures in the same closed
|
||||
# JSON error format.
|
||||
status = code if isinstance(code, int) and 400 <= code <= 599 else 400
|
||||
self._send(status, {"error": {"code": "HTTP_PROTOCOL_ERROR"}})
|
||||
|
||||
def _send(self, status: int, value: Any) -> None:
|
||||
try:
|
||||
body = json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError):
|
||||
status = 500
|
||||
body = b'{"error":{"code":"HTTP_RESPONSE_INVALID"}}'
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
status = 500
|
||||
body = b'{"error":{"code":"HTTP_RESPONSE_TOO_LARGE"}}'
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except OSError:
|
||||
# A peer can disconnect or let its deadline expire while a response
|
||||
# is being written. Do not let the server print a handler traceback.
|
||||
pass
|
||||
self.close_connection = True
|
||||
|
||||
def _error(self, error: _RequestError) -> None:
|
||||
self._send(error.status, {"error": {"code": error.code}})
|
||||
|
||||
def _deadline(self) -> float:
|
||||
values = self.headers.get_all("X-AIOS-Deadline", [])
|
||||
if len(values) != 1:
|
||||
raise _RequestError("HTTP_DEADLINE_REQUIRED", 400)
|
||||
try:
|
||||
deadline = float(values[0])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise _RequestError("HTTP_DEADLINE_INVALID", 400) from exc
|
||||
now = time.time()
|
||||
if not math.isfinite(deadline) or deadline > now + MAX_DEADLINE_SECONDS:
|
||||
raise _RequestError("HTTP_DEADLINE_INVALID", 400)
|
||||
if deadline <= now:
|
||||
raise _RequestError("HTTP_DEADLINE_EXPIRED", 408)
|
||||
self.connection.settimeout(deadline - now)
|
||||
return deadline
|
||||
|
||||
def _identity(self) -> Identity:
|
||||
values = self.headers.get_all("Authorization", [])
|
||||
if len(values) != 1 or not values[0].startswith("Bearer "):
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
token = values[0][7:]
|
||||
if not token or any(ord(char) < 33 or ord(char) > 126 for char in token):
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
identity = self.server.trusted_identities.get(token)
|
||||
if identity is None:
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
return identity
|
||||
|
||||
def _json_body(self) -> dict:
|
||||
if self.headers.get("Transfer-Encoding") is not None:
|
||||
raise _RequestError("HTTP_TRANSFER_ENCODING_FORBIDDEN", 400)
|
||||
content_types = self.headers.get_all("Content-Type", [])
|
||||
if content_types != ["application/json"]:
|
||||
raise _RequestError("HTTP_CONTENT_TYPE_INVALID", 415)
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if len(lengths) != 1 or not lengths[0].isdigit():
|
||||
raise _RequestError("HTTP_CONTENT_LENGTH_INVALID", 411)
|
||||
length = int(lengths[0])
|
||||
if length > MAX_REQUEST_BYTES:
|
||||
raise _RequestError("HTTP_BODY_TOO_LARGE", 413)
|
||||
if length <= 0:
|
||||
raise _RequestError("HTTP_BODY_REQUIRED", 400)
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise _RequestError("HTTP_BODY_TRUNCATED", 400)
|
||||
try:
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise _RequestError("HTTP_JSON_DUPLICATE_KEY", 400)
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
def reject_constant(_value):
|
||||
raise _RequestError("HTTP_JSON_NON_FINITE", 400)
|
||||
|
||||
value = json.loads(
|
||||
raw.decode("utf-8"), object_pairs_hook=unique_object,
|
||||
parse_constant=reject_constant,
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise _RequestError("HTTP_JSON_INVALID", 400) from exc
|
||||
if type(value) is not dict:
|
||||
raise _RequestError("HTTP_JSON_OBJECT_REQUIRED", 400)
|
||||
if _has_identity_field(value):
|
||||
raise _RequestError("HTTP_IDENTITY_FIELD_FORBIDDEN", 400)
|
||||
return value
|
||||
|
||||
def _path(self) -> list[str]:
|
||||
parsed = urlsplit(self.path)
|
||||
if parsed.query or parsed.fragment or not parsed.path.startswith("/"):
|
||||
raise _RequestError("HTTP_PATH_INVALID", 400)
|
||||
raw_segments = parsed.path.split("/")[1:]
|
||||
if any(not segment for segment in raw_segments):
|
||||
raise _RequestError("HTTP_NOT_FOUND", 404)
|
||||
segments = [unquote(segment) for segment in raw_segments]
|
||||
if any("/" in segment or "\\" in segment for segment in segments):
|
||||
raise _RequestError("HTTP_PATH_INVALID", 400)
|
||||
return segments
|
||||
|
||||
@staticmethod
|
||||
def _exact(body: Mapping[str, Any], fields: set[str]) -> None:
|
||||
if set(body) != fields:
|
||||
raise _RequestError("HTTP_BODY_SCHEMA_INVALID", 400)
|
||||
|
||||
def _dispatch(self, method: str, identity: Identity, segments: list[str], body: dict | None):
|
||||
service = self.server.baseline_service
|
||||
if method == "POST" and segments == ["v1", "goals", "submit"]:
|
||||
self._exact(body, {"request", "idempotency_key"})
|
||||
if type(body["request"]) is not dict:
|
||||
raise _RequestError("HTTP_BODY_SCHEMA_INVALID", 400)
|
||||
return 201, service.submit_goal(
|
||||
identity, body["request"], idempotency_key=body["idempotency_key"]
|
||||
)
|
||||
if method == "GET" and len(segments) == 3 and segments[:2] == ["v1", "tasks"]:
|
||||
return 200, service.get_task(identity, _safe_identifier(segments[2]))
|
||||
if (
|
||||
method == "POST" and len(segments) == 4
|
||||
and segments[:2] == ["v1", "tasks"] and segments[3] == "cancel"
|
||||
):
|
||||
self._exact(body, set())
|
||||
return 200, service.cancel_task(identity, _safe_identifier(segments[2]))
|
||||
if (
|
||||
method == "GET" and len(segments) == 4
|
||||
and segments[:2] == ["v1", "missions"] and segments[3] == "explain"
|
||||
):
|
||||
return 200, service.explain(identity, _safe_identifier(segments[2]))
|
||||
if method == "POST" and segments == ["v1", "workers", "register"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.register_worker(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "heartbeat"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.heartbeat(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "claim"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.claim_next(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "renew"]:
|
||||
self._exact(body, {"task_id", "claim_id"})
|
||||
return 200, service.renew(
|
||||
identity, _safe_identifier(body["task_id"]), _safe_identifier(body["claim_id"])
|
||||
)
|
||||
if method == "POST" and segments == ["v1", "workers", "execute"]:
|
||||
self._exact(body, {"task_id", "claim_id"})
|
||||
return 200, service.execute_claim(
|
||||
identity, _safe_identifier(body["task_id"]), _safe_identifier(body["claim_id"]),deadline=self.request_deadline
|
||||
)
|
||||
raise _RequestError("HTTP_NOT_FOUND", 404)
|
||||
|
||||
def _handle(self, method: str) -> None:
|
||||
try:
|
||||
self.request_deadline=self._deadline()
|
||||
identity = self._identity()
|
||||
segments = self._path()
|
||||
body = self._json_body() if method == "POST" else None
|
||||
if method == "GET":
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if self.headers.get("Transfer-Encoding") is not None:
|
||||
raise _RequestError("HTTP_GET_BODY_FORBIDDEN", 400)
|
||||
if len(lengths) > 1 or (lengths and (not lengths[0].isdigit() or int(lengths[0]) != 0)):
|
||||
raise _RequestError("HTTP_GET_BODY_FORBIDDEN", 400)
|
||||
status, result = self._dispatch(method, identity, segments, body)
|
||||
self._send(status, result)
|
||||
except _RequestError as exc:
|
||||
self._error(exc)
|
||||
except ServiceError as exc:
|
||||
status = exc.status if isinstance(exc.status, int) and 400 <= exc.status <= 599 else 500
|
||||
self._send(status, {"error": {"code": exc.code}})
|
||||
except TimeoutError:
|
||||
self._send(408, {"error": {"code": "HTTP_DEADLINE_EXPIRED"}})
|
||||
except Exception:
|
||||
# No exception string, traceback, request headers, or token crosses
|
||||
# this boundary.
|
||||
self._send(500, {"error": {"code": "HTTP_INTERNAL_ERROR"}})
|
||||
|
||||
def do_GET(self):
|
||||
self._handle("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._handle("POST")
|
||||
|
||||
def _method_not_allowed(self):
|
||||
try:
|
||||
self._deadline()
|
||||
self._identity()
|
||||
self._send(405, {"error": {"code": "HTTP_METHOD_NOT_ALLOWED"}})
|
||||
except _RequestError as exc:
|
||||
self._error(exc)
|
||||
|
||||
do_PUT = _method_not_allowed
|
||||
do_PATCH = _method_not_allowed
|
||||
do_DELETE = _method_not_allowed
|
||||
do_OPTIONS = _method_not_allowed
|
||||
do_HEAD = _method_not_allowed
|
||||
do_TRACE = _method_not_allowed
|
||||
do_CONNECT = _method_not_allowed
|
||||
|
||||
|
||||
def serve(
|
||||
service: Any,
|
||||
tokens: Mapping[str, Identity],
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Build an unstarted real HTTP server bound only to an IPv4 loopback."""
|
||||
host = _loopback_host(host, "HTTP_LOOPBACK_REQUIRED")
|
||||
if type(port) is not int or not 0 <= port <= 65_535:
|
||||
raise TransportError("HTTP_PORT_INVALID")
|
||||
if not isinstance(tokens, Mapping) or not tokens:
|
||||
raise TransportError("HTTP_TRUSTED_IDENTITIES_REQUIRED")
|
||||
identities: dict[str, Identity] = {}
|
||||
for token, identity in tokens.items():
|
||||
if (
|
||||
not isinstance(token, str) or not 32<=len(token)<=512
|
||||
or any(ord(char) < 33 or ord(char) > 126 for char in token)
|
||||
or not isinstance(identity, Identity)
|
||||
):
|
||||
raise TransportError("HTTP_TRUSTED_IDENTITY_INVALID")
|
||||
identities[token] = identity
|
||||
return _BaselineHTTPServer((host, port), _Handler, service, identities)
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
"""Small urllib client for the loopback API; redirects and proxies are off."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_seconds: float = 15,
|
||||
max_response_bytes: int = MAX_RESPONSE_BYTES,
|
||||
):
|
||||
try:
|
||||
parsed = urlsplit(base_url)
|
||||
parsed_port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise TransportError("TRANSPORT_BASE_URL_INVALID") from exc
|
||||
if (
|
||||
parsed.scheme != "http" or parsed.username is not None or parsed.password is not None
|
||||
or parsed.query or parsed.fragment or parsed.path not in {"", "/"}
|
||||
or parsed.hostname is None or parsed_port is None
|
||||
):
|
||||
raise TransportError("TRANSPORT_BASE_URL_INVALID")
|
||||
_loopback_host(parsed.hostname, "TRANSPORT_LOOPBACK_REQUIRED")
|
||||
if not isinstance(token, str) or not 32<=len(token)<=512 or any(
|
||||
ord(char) < 33 or ord(char) > 126 for char in token
|
||||
):
|
||||
raise TransportError("TRANSPORT_TOKEN_INVALID")
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(float(timeout_seconds))
|
||||
or not 0.1 <= timeout_seconds <= MAX_DEADLINE_SECONDS
|
||||
):
|
||||
raise TransportError("TRANSPORT_TIMEOUT_INVALID")
|
||||
if type(max_response_bytes) is not int or not 1 <= max_response_bytes <= 4 * 1024 * 1024:
|
||||
raise TransportError("TRANSPORT_RESPONSE_LIMIT_INVALID")
|
||||
self.base_url = f"http://{parsed.hostname}:{parsed_port}"
|
||||
self.token = token
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.max_response_bytes = max_response_bytes
|
||||
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
|
||||
|
||||
def _decode(self, response) -> Any:
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0]
|
||||
if content_type != "application/json":
|
||||
raise TransportError("TRANSPORT_RESPONSE_TYPE_INVALID")
|
||||
declared = response.headers.get("Content-Length")
|
||||
if declared is None or not declared.isdigit() or int(declared) > self.max_response_bytes:
|
||||
raise TransportError("TRANSPORT_RESPONSE_TOO_LARGE")
|
||||
raw = response.read(self.max_response_bytes + 1)
|
||||
if len(raw) > self.max_response_bytes or len(raw) != int(declared):
|
||||
raise TransportError("TRANSPORT_RESPONSE_SIZE_INVALID")
|
||||
try:
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate")
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
return json.loads(
|
||||
raw.decode("utf-8"), object_pairs_hook=unique_object,
|
||||
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("non-finite")),
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise TransportError("TRANSPORT_RESPONSE_JSON_INVALID") from exc
|
||||
|
||||
def _request(self, method: str, path: str, body: Mapping[str, Any] | None = None) -> Any:
|
||||
if not isinstance(path, str) or not path.startswith("/") or "?" in path or "#" in path:
|
||||
raise TransportError("TRANSPORT_PATH_INVALID")
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": "Bearer " + self.token,
|
||||
"Accept": "application/json",
|
||||
"X-AIOS-Deadline": str(time.time() + self.timeout_seconds),
|
||||
}
|
||||
if method == "POST":
|
||||
if not isinstance(body, Mapping):
|
||||
raise TransportError("TRANSPORT_BODY_INVALID")
|
||||
try:
|
||||
data = json.dumps(
|
||||
body, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TransportError("TRANSPORT_BODY_INVALID") from exc
|
||||
if not data or len(data) > MAX_REQUEST_BYTES:
|
||||
raise TransportError("TRANSPORT_BODY_TOO_LARGE")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self.base_url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with self._opener.open(request, timeout=self.timeout_seconds) as response:
|
||||
return self._decode(response)
|
||||
except HTTPError as exc:
|
||||
if 300 <= exc.code < 400:
|
||||
exc.close()
|
||||
raise TransportError("TRANSPORT_REDIRECT_REJECTED", exc.code) from None
|
||||
try:
|
||||
payload = self._decode(exc)
|
||||
except TransportError:
|
||||
payload = None
|
||||
except (TimeoutError, OSError):
|
||||
raise TransportError("TRANSPORT_UNAVAILABLE") from None
|
||||
finally:
|
||||
exc.close()
|
||||
code = None
|
||||
if isinstance(payload, Mapping) and isinstance(payload.get("error"), Mapping):
|
||||
code = payload["error"].get("code")
|
||||
if not isinstance(code, str) or _IDENTIFIER.fullmatch(code) is None:
|
||||
code = "TRANSPORT_HTTP_ERROR"
|
||||
raise TransportError(code, exc.code) from None
|
||||
except (URLError, TimeoutError, OSError):
|
||||
raise TransportError("TRANSPORT_UNAVAILABLE") from None
|
||||
|
||||
def submit_goal(self, request: Mapping[str, Any], *, idempotency_key: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/goals/submit",
|
||||
{"request": request, "idempotency_key": idempotency_key},
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
return self._request("GET", "/v1/tasks/" + quote(task_id, safe=""))
|
||||
|
||||
def cancel_task(self, task_id: str) -> dict:
|
||||
return self._request("POST", "/v1/tasks/" + quote(task_id, safe="") + "/cancel", {})
|
||||
|
||||
def explain(self, mission_id: str) -> dict:
|
||||
return self._request("GET", "/v1/missions/" + quote(mission_id, safe="") + "/explain")
|
||||
|
||||
def register_worker(self) -> dict:
|
||||
return self._request("POST", "/v1/workers/register", {})
|
||||
|
||||
def heartbeat(self) -> dict:
|
||||
return self._request("POST", "/v1/workers/heartbeat", {})
|
||||
|
||||
def claim_next(self) -> dict | None:
|
||||
return self._request("POST", "/v1/workers/claim", {})
|
||||
|
||||
def renew(self, task_id: str, claim_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/workers/renew", {"task_id": task_id, "claim_id": claim_id}
|
||||
)
|
||||
|
||||
def execute_claim(self, task_id: str, claim_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/workers/execute", {"task_id": task_id, "claim_id": claim_id}
|
||||
)
|
||||
|
||||
# Goal-oriented names used by thin clients.
|
||||
submit = submit_goal
|
||||
query = get_task
|
||||
cancel = cancel_task
|
||||
|
||||
|
||||
Transport = HttpTransport
|
||||
|
||||
__all__ = ["HttpTransport", "Transport", "TransportError", "serve"]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Verification-only Ed25519 boundaries for release and acceptance evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from .lineage import VerifiedAcceptanceEvidence, canonical_artifact
|
||||
from .release import VerifiedReceipt
|
||||
|
||||
|
||||
RELEASE_RECEIPT_DOMAIN = b"aios.release-operation-receipt.v1"
|
||||
ACCEPTANCE_EVIDENCE_DOMAIN = b"aios.release-acceptance-evidence.v1"
|
||||
|
||||
_ENVELOPE_FIELDS = {"schema", "key_id", "payload", "signature"}
|
||||
_RECEIPT_FIELDS = {
|
||||
"receipt_id", "tenant_id", "subject_id", "component_id", "task_id",
|
||||
"source_task_id", "source_effect_id", "grant_id", "job_id", "effect_id", "operation_id",
|
||||
"deployment_id", "release_id", "artifact", "outcome", "observed", "observed_at",
|
||||
}
|
||||
_ACCEPTANCE_FIELDS = {
|
||||
"acceptance_id", "tenant_id", "subject_id", "component_id", "deployment_id", "release_id",
|
||||
"artifact", "accepted", "evidence_id", "observed", "observed_at",
|
||||
}
|
||||
_B64URL = re.compile(r"[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
class SignatureVerificationError(ValueError):
|
||||
"""A configured trust root did not authenticate an exact evidence payload."""
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SignatureVerificationError("signed payload is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _decode_urlsafe(value: Any, name: str, expected_size: int) -> bytes:
|
||||
if not isinstance(value, str) or not value or not _B64URL.fullmatch(value) or "=" in value:
|
||||
raise SignatureVerificationError(f"invalid {name} encoding")
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise SignatureVerificationError(f"invalid {name} encoding") from exc
|
||||
if len(raw) != expected_size:
|
||||
raise SignatureVerificationError(f"invalid {name} size")
|
||||
return raw
|
||||
|
||||
|
||||
def _public_key(value: Any) -> Ed25519PublicKey:
|
||||
if isinstance(value, Ed25519PublicKey):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
raw = value
|
||||
elif isinstance(value, str):
|
||||
raw = _decode_urlsafe(value, "trusted public key", 32)
|
||||
else:
|
||||
raise ValueError("trusted public key must be Ed25519PublicKey, raw bytes, or base64url")
|
||||
if len(raw) != 32:
|
||||
raise ValueError("trusted Ed25519 public key must be 32 bytes")
|
||||
return Ed25519PublicKey.from_public_bytes(raw)
|
||||
|
||||
|
||||
def _trusted_keys(values: Mapping[str, Any]) -> Mapping[str, Ed25519PublicKey]:
|
||||
if not isinstance(values, Mapping) or not values:
|
||||
raise ValueError("trusted_public_keys must be a non-empty mapping")
|
||||
keys: dict[str, Ed25519PublicKey] = {}
|
||||
for key_id, value in values.items():
|
||||
if not isinstance(key_id, str) or not key_id or len(key_id) > 128:
|
||||
raise ValueError("invalid trusted key_id")
|
||||
keys[key_id] = _public_key(value)
|
||||
return MappingProxyType(keys)
|
||||
|
||||
|
||||
def _verified_payload(
|
||||
envelope: Any,
|
||||
keys: Mapping[str, Ed25519PublicKey],
|
||||
*,
|
||||
schema: str,
|
||||
domain: bytes,
|
||||
payload_fields: set[str],
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(envelope, Mapping) or set(envelope) != _ENVELOPE_FIELDS:
|
||||
raise SignatureVerificationError("signed envelope fields invalid")
|
||||
if envelope.get("schema") != schema:
|
||||
raise SignatureVerificationError("signed envelope schema mismatch")
|
||||
key_id = envelope.get("key_id")
|
||||
if not isinstance(key_id, str) or key_id not in keys:
|
||||
raise SignatureVerificationError("untrusted signing key_id")
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, Mapping) or set(payload) != payload_fields:
|
||||
raise SignatureVerificationError("signed payload fields invalid")
|
||||
signature = _decode_urlsafe(envelope.get("signature"), "signature", 64)
|
||||
try:
|
||||
keys[key_id].verify(signature, domain + b"\x00" + _canonical(dict(payload)))
|
||||
except InvalidSignature as exc:
|
||||
raise SignatureVerificationError("Ed25519 signature invalid") from exc
|
||||
return payload
|
||||
|
||||
|
||||
def _text(payload: Mapping[str, Any], name: str) -> str:
|
||||
value = payload.get(name)
|
||||
if not isinstance(value, str) or not value or len(value) > 512:
|
||||
raise SignatureVerificationError(f"signed {name} invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _expected(payload: Mapping[str, Any], expected: Mapping[str, Any], fields: tuple[str, ...]) -> None:
|
||||
for name in fields:
|
||||
if name not in expected:
|
||||
raise SignatureVerificationError(f"verifier expected {name} missing")
|
||||
actual = canonical_artifact(payload[name]) if name == "artifact" else payload[name]
|
||||
wanted = canonical_artifact(expected[name]) if name == "artifact" else expected[name]
|
||||
if actual != wanted:
|
||||
raise SignatureVerificationError(f"signed {name} binding mismatch")
|
||||
|
||||
|
||||
def ed25519_release_receipt_verifier(
|
||||
trusted_public_keys: Mapping[str, Any],
|
||||
) -> Callable[[Any, Mapping[str, Any]], VerifiedReceipt]:
|
||||
"""Build a receipt verifier from local/configured public keys only."""
|
||||
|
||||
keys = _trusted_keys(trusted_public_keys)
|
||||
|
||||
def verify(envelope: Any, expected: Mapping[str, Any]) -> VerifiedReceipt:
|
||||
payload = _verified_payload(
|
||||
envelope, keys,
|
||||
schema="aios.release-operation-receipt-envelope.v1",
|
||||
domain=RELEASE_RECEIPT_DOMAIN,
|
||||
payload_fields=_RECEIPT_FIELDS,
|
||||
)
|
||||
_expected(
|
||||
payload, expected,
|
||||
("tenant_id", "subject_id", "component_id", "task_id", "source_task_id", "source_effect_id",
|
||||
"grant_id", "job_id", "effect_id", "operation_id", "deployment_id", "release_id", "artifact"),
|
||||
)
|
||||
if payload.get("outcome") != "SUCCEEDED":
|
||||
raise SignatureVerificationError("signed receipt outcome is not SUCCEEDED")
|
||||
if not isinstance(payload.get("observed"), Mapping):
|
||||
raise SignatureVerificationError("signed receipt observation invalid")
|
||||
return VerifiedReceipt(
|
||||
receipt_id=_text(payload, "receipt_id"), tenant_id=_text(payload, "tenant_id"),
|
||||
subject_id=_text(payload, "subject_id"), effect_id=_text(payload, "effect_id"),
|
||||
component_id=_text(payload,"component_id"), task_id=_text(payload,"task_id"),
|
||||
source_task_id=_text(payload,"source_task_id"), source_effect_id=_text(payload,"source_effect_id"),
|
||||
grant_id=_text(payload,"grant_id"), job_id=_text(payload,"job_id"),
|
||||
operation_id=_text(payload, "operation_id"), deployment_id=_text(payload, "deployment_id"),
|
||||
release_id=_text(payload, "release_id"), artifact=payload["artifact"],
|
||||
observed=dict(payload["observed"]), outcome="SUCCEEDED",
|
||||
observed_at=_text(payload, "observed_at"),
|
||||
)
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
def ed25519_acceptance_evidence_verifier(
|
||||
trusted_public_keys: Mapping[str, Any],
|
||||
) -> Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence]:
|
||||
"""Build an acceptance verifier in a domain separate from stage receipts."""
|
||||
|
||||
keys = _trusted_keys(trusted_public_keys)
|
||||
|
||||
def verify(envelope: Any, expected: Mapping[str, Any]) -> VerifiedAcceptanceEvidence:
|
||||
payload = _verified_payload(
|
||||
envelope, keys,
|
||||
schema="aios.release-acceptance-evidence-envelope.v1",
|
||||
domain=ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
payload_fields=_ACCEPTANCE_FIELDS,
|
||||
)
|
||||
_expected(
|
||||
payload, expected,
|
||||
("acceptance_id", "tenant_id", "subject_id", "component_id", "deployment_id", "release_id", "artifact", "accepted"),
|
||||
)
|
||||
if type(payload.get("accepted")) is not bool:
|
||||
raise SignatureVerificationError("signed accepted decision invalid")
|
||||
if not isinstance(payload.get("observed"), Mapping):
|
||||
raise SignatureVerificationError("signed acceptance observation invalid")
|
||||
return VerifiedAcceptanceEvidence(
|
||||
acceptance_id=_text(payload, "acceptance_id"), tenant_id=_text(payload, "tenant_id"),
|
||||
subject_id=_text(payload, "subject_id"), deployment_id=_text(payload, "deployment_id"),
|
||||
component_id=_text(payload,"component_id"),
|
||||
release_id=_text(payload, "release_id"), artifact=payload["artifact"],
|
||||
accepted=payload["accepted"], evidence_id=_text(payload, "evidence_id"),
|
||||
observed=dict(payload["observed"]), observed_at=_text(payload, "observed_at"),
|
||||
)
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTANCE_EVIDENCE_DOMAIN", "RELEASE_RECEIPT_DOMAIN",
|
||||
"SignatureVerificationError", "ed25519_acceptance_evidence_verifier",
|
||||
"ed25519_release_receipt_verifier",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Thin resident worker loop over the authenticated HTTP transport.
|
||||
|
||||
Worker policy, operation handlers, effects, receipts, and completion remain in
|
||||
the trusted server. This process only maintains its registered transport
|
||||
identity and asks the service to run an offered claim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .transport import TransportError
|
||||
|
||||
|
||||
class WorkerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OperationWorker:
|
||||
HEARTBEAT_INTERVAL_SECONDS = 15.0
|
||||
|
||||
def __init__(self, transport: Any, poll_seconds: float = 1.0, stop_event=None):
|
||||
required = (
|
||||
"register_worker", "heartbeat", "claim_next", "renew", "execute_claim"
|
||||
)
|
||||
if transport is None or any(not callable(getattr(transport, name, None)) for name in required):
|
||||
raise WorkerError("WORKER_TRANSPORT_INVALID")
|
||||
if (
|
||||
isinstance(poll_seconds, bool)
|
||||
or not isinstance(poll_seconds, (int, float))
|
||||
or not math.isfinite(float(poll_seconds))
|
||||
or not 0.001 <= poll_seconds <= 60
|
||||
):
|
||||
raise WorkerError("WORKER_POLL_INTERVAL_INVALID")
|
||||
if stop_event is not None and (
|
||||
not callable(getattr(stop_event, "is_set", None))
|
||||
or not callable(getattr(stop_event, "wait", None))
|
||||
):
|
||||
raise WorkerError("WORKER_STOP_EVENT_INVALID")
|
||||
self.transport = transport
|
||||
self.poll_seconds = float(poll_seconds)
|
||||
self.stop_event = stop_event if stop_event is not None else threading.Event()
|
||||
self.registered = False
|
||||
self.worker_id: str | None = None
|
||||
self._last_heartbeat = 0.0
|
||||
self.last_error: str | None = None
|
||||
|
||||
def _register(self) -> None:
|
||||
result = self.transport.register_worker()
|
||||
if (
|
||||
not isinstance(result, dict) or result.get("registered") is not True
|
||||
or not isinstance(result.get("worker_id"), str) or not result["worker_id"]
|
||||
):
|
||||
raise WorkerError("WORKER_REGISTRATION_RESULT_INVALID")
|
||||
self.worker_id = result["worker_id"]
|
||||
self.registered = True
|
||||
|
||||
def _heartbeat(self, *, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
if not force and now < self._last_heartbeat + self.HEARTBEAT_INTERVAL_SECONDS:
|
||||
return
|
||||
result = self.transport.heartbeat()
|
||||
if (
|
||||
not isinstance(result, dict) or result.get("alive") is not True
|
||||
or result.get("worker_id") != self.worker_id
|
||||
):
|
||||
raise WorkerError("WORKER_HEARTBEAT_RESULT_INVALID")
|
||||
self._last_heartbeat = time.monotonic()
|
||||
|
||||
def run_once(self):
|
||||
"""Perform one register/heartbeat/claim/renew/execute iteration."""
|
||||
if self.stop_event.is_set():
|
||||
return None
|
||||
just_registered = False
|
||||
if not self.registered:
|
||||
self._register()
|
||||
just_registered = True
|
||||
self._heartbeat(force=just_registered)
|
||||
claim = self.transport.claim_next()
|
||||
if claim is None:
|
||||
self.last_error = None
|
||||
return None
|
||||
required = {"task_id", "claim_id", "generation", "lease_until", "operation"}
|
||||
if not isinstance(claim, dict) or set(claim) != required:
|
||||
raise WorkerError("WORKER_CLAIM_RESULT_INVALID")
|
||||
task_id = claim["task_id"]
|
||||
claim_id = claim["claim_id"]
|
||||
if not isinstance(task_id, str) or not task_id or not isinstance(claim_id, str) or not claim_id:
|
||||
raise WorkerError("WORKER_CLAIM_RESULT_INVALID")
|
||||
renewed = self.transport.renew(task_id, claim_id)
|
||||
if (
|
||||
not isinstance(renewed, dict) or renewed.get("renewed") is not True
|
||||
or renewed.get("task_id") != task_id or renewed.get("claim_id") != claim_id
|
||||
):
|
||||
raise WorkerError("WORKER_RENEW_RESULT_INVALID")
|
||||
result = self.transport.execute_claim(task_id, claim_id)
|
||||
if not isinstance(result, dict) or result.get("task_id") != task_id:
|
||||
raise WorkerError("WORKER_EXECUTION_RESULT_INVALID")
|
||||
self.last_error = None
|
||||
return result
|
||||
|
||||
def run_forever(self) -> None:
|
||||
"""Run until the supplied stop event is set, using interruptible waits."""
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
self.run_once()
|
||||
except TransportError as exc:
|
||||
# Transport failures are observable and retried on the next
|
||||
# bounded poll. Re-register after a server restart; programming
|
||||
# and schema errors still terminate.
|
||||
self.last_error = exc.code
|
||||
self.registered = False
|
||||
self.stop_event.wait(self.poll_seconds)
|
||||
|
||||
|
||||
Worker = OperationWorker
|
||||
|
||||
__all__ = ["OperationWorker", "Worker", "WorkerError"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Registered worker eligibility; fallback is deployment policy, never a special actor."""
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerProfile:
|
||||
worker_id: str
|
||||
identity: str
|
||||
tenant_scope: tuple[str, ...]
|
||||
subject_scope: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
priority: int
|
||||
fallback_executor: bool
|
||||
heartbeat_seconds: int
|
||||
max_concurrency: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping):
|
||||
if set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError('WORKER_PROFILE_FIELDS_INVALID')
|
||||
data = dict(value)
|
||||
for name in ('worker_id', 'identity'):
|
||||
if not isinstance(data[name], str) or not data[name] or len(data[name]) > 128:
|
||||
raise ValueError('WORKER_ID_INVALID')
|
||||
for name in ('tenant_scope', 'subject_scope', 'capabilities'):
|
||||
items = data[name]
|
||||
if not isinstance(items, (list, tuple)) or not items or any(not isinstance(x, str) or not x or '*' in x for x in items):
|
||||
raise ValueError('WORKER_SCOPE_INVALID')
|
||||
data[name] = tuple(sorted(set(items)))
|
||||
if type(data['fallback_executor']) is not bool:
|
||||
raise ValueError('WORKER_FALLBACK_INVALID')
|
||||
for name, lower, upper in (('priority', 0, 1000), ('heartbeat_seconds', 1, 300), ('max_concurrency', 1, 32)):
|
||||
if type(data[name]) is not int or not lower <= data[name] <= upper:
|
||||
raise ValueError('WORKER_LIMIT_INVALID')
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def eligible(worker: WorkerProfile, task: Mapping, *, now: float, last_heartbeat: float,
|
||||
fallback_seconds: int = 600) -> bool:
|
||||
ready_at = task.get('ready_at')
|
||||
if any(type(value) not in (int,float) or not math.isfinite(value) for value in (ready_at,now,last_heartbeat)) or ready_at > now:
|
||||
return False
|
||||
if last_heartbeat > now or now > last_heartbeat + worker.heartbeat_seconds * 3:
|
||||
return False
|
||||
if task.get('tenant_id') not in worker.tenant_scope or task.get('subject_id') not in worker.subject_scope:
|
||||
return False
|
||||
if task.get('operation') not in worker.capabilities:
|
||||
return False
|
||||
return not worker.fallback_executor or now >= ready_at + fallback_seconds
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Read-only validation of recovered campaign authorization evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RecoveryEvidenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _digest(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_MISSING")
|
||||
info = path.stat(follow_symlinks=False)
|
||||
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o022:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PERMISSIONS")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
|
||||
def load_recovered_authorization(path: str | Path) -> dict[str, Any]:
|
||||
"""Verify the recovery receipt, sidecar, and all referenced immutable files."""
|
||||
receipt_path = Path(path)
|
||||
receipt = _secure_json(receipt_path)
|
||||
sidecar = receipt_path.with_suffix(receipt_path.suffix + ".sha256")
|
||||
if sidecar.is_symlink() or not sidecar.is_file():
|
||||
raise RecoveryEvidenceError("RECOVERY_DIGEST_SIDECAR_MISSING")
|
||||
expected = sidecar.read_text(encoding="ascii").split()[0]
|
||||
actual = _digest(receipt_path)
|
||||
if expected != actual:
|
||||
raise RecoveryEvidenceError("RECOVERY_DIGEST_MISMATCH")
|
||||
required = {
|
||||
"schema",
|
||||
"campaign_id",
|
||||
"original_event_id",
|
||||
"original_authorization_digest",
|
||||
"signed_grant_id",
|
||||
"signed_grant_digest",
|
||||
"task_id",
|
||||
"repository",
|
||||
"original_base_oid",
|
||||
"branch",
|
||||
"expected_branch_oid",
|
||||
"recovered_from_existing_evidence",
|
||||
"new_owner_interaction",
|
||||
"recovery_state",
|
||||
"production_side_effect_executed",
|
||||
"global_writes_enabled",
|
||||
"grant_consumed",
|
||||
"owner_authorization_consumed",
|
||||
"original_receipts_immutable",
|
||||
"recovered_at",
|
||||
"evidence_files",
|
||||
}
|
||||
if set(receipt) != required or receipt["schema"] != "aios.owner-authorization-recovery.v1":
|
||||
raise RecoveryEvidenceError("RECOVERY_SCHEMA_INVALID")
|
||||
if receipt["recovered_from_existing_evidence"] is not True or receipt["new_owner_interaction"] is not False:
|
||||
raise RecoveryEvidenceError("RECOVERY_NOT_APPEND_ONLY")
|
||||
if receipt["production_side_effect_executed"] is not False or receipt["global_writes_enabled"] is not False:
|
||||
raise RecoveryEvidenceError("RECOVERY_SCOPE_INVALID")
|
||||
if receipt["recovery_state"] != "RECOVERED_PENDING_CONTINUATION_LEASE":
|
||||
raise RecoveryEvidenceError("RECOVERY_STATE_INVALID")
|
||||
evidence = receipt["evidence_files"]
|
||||
if not isinstance(evidence, list) or not evidence:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
||||
target = Path(item["path"])
|
||||
if _digest(target) != item["sha256"]:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
||||
return {
|
||||
"schema": receipt["schema"],
|
||||
"state": receipt["recovery_state"],
|
||||
"campaign_id": receipt["campaign_id"],
|
||||
"original_event_id": receipt["original_event_id"],
|
||||
"original_authorization_digest": receipt["original_authorization_digest"],
|
||||
"signed_grant_id": receipt["signed_grant_id"],
|
||||
"task_id": receipt["task_id"],
|
||||
"repository": receipt["repository"],
|
||||
"original_base_oid": receipt["original_base_oid"],
|
||||
"branch": receipt["branch"],
|
||||
"expected_branch_oid": receipt["expected_branch_oid"],
|
||||
"digest": actual,
|
||||
"new_owner_interaction": False,
|
||||
}
|
||||
Executable → Regular
+221
-11
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, uuid
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -22,8 +22,15 @@ from gitea_tag_publication import (
|
||||
TagPublisher,
|
||||
redact,
|
||||
validate_version,
|
||||
validate_gitea_api_url,
|
||||
)
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
try:
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
except ModuleNotFoundError:
|
||||
# The deployed service normally supplies runtime/agent-os on sys.path.
|
||||
# Keep direct, isolated contract execution bound to the repository copy.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "runtime" / "agent-os"))
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
@@ -33,6 +40,11 @@ from silent_release_hardening import (
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
)
|
||||
try:
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
try:
|
||||
from authoritative_git import (
|
||||
AuthoritativeGitError,
|
||||
@@ -58,6 +70,11 @@ except ModuleNotFoundError:
|
||||
repository_snapshot,
|
||||
run_authoritative_git,
|
||||
)
|
||||
try:
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
@@ -103,6 +120,8 @@ READ_ONLY_GET_PATHS = frozenset({
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/api/repository/tree', '/api/repository/search',
|
||||
'/api/repository/diff-index', '/api/repository/file',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
@@ -120,6 +139,12 @@ def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
return False
|
||||
if path in SILENT_RELEASE_POST_PATHS:
|
||||
return False
|
||||
if (
|
||||
method == 'POST'
|
||||
and path in ('/api/file', '/api/commit-file')
|
||||
and os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() in {'1', 'true', 'yes', 'on'}
|
||||
):
|
||||
return False
|
||||
decision = evaluate_git_write(WS)
|
||||
if decision.allowed:
|
||||
return False
|
||||
@@ -297,8 +322,7 @@ 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
|
||||
|
||||
def existing_tag_target(tag: str) -> str | None:
|
||||
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||
raise ValueError('invalid release version')
|
||||
validate_version(tag)
|
||||
try:
|
||||
result = run_authoritative_git(
|
||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||
@@ -331,9 +355,80 @@ def required_authoritative_git_dir() -> Path:
|
||||
return Path(str(snapshot["authoritative_git_dir"]))
|
||||
|
||||
|
||||
_DISCOVERY_ACTIONS = {
|
||||
'/api/repository/tree': (
|
||||
'list_repository_tree',
|
||||
{'repository', 'ref', 'limit'},
|
||||
{'prefix', 'depth', 'cursor'},
|
||||
),
|
||||
'/api/repository/search': (
|
||||
'search_repository_source',
|
||||
{'repository', 'ref', 'query', 'max_matches', 'max_bytes'},
|
||||
{'path_globs', 'cursor'},
|
||||
),
|
||||
'/api/repository/diff-index': (
|
||||
'get_diff_index',
|
||||
{'repository', 'from', 'to', 'limit'},
|
||||
{'path_prefix', 'cursor'},
|
||||
),
|
||||
'/api/repository/file': (
|
||||
'read_repository_file',
|
||||
{'repository', 'ref', 'path'},
|
||||
{'max_bytes'},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def repository_discovery_request(
|
||||
path: str, query: dict[str, list[str]]
|
||||
) -> dict[str, Any]:
|
||||
operation, required, optional = _DISCOVERY_ACTIONS[path]
|
||||
if set(query) - required - optional:
|
||||
raise ValueError('unknown repository discovery query field')
|
||||
request: dict[str, Any] = {}
|
||||
for key, values in query.items():
|
||||
if key == 'path_globs':
|
||||
if not values:
|
||||
raise ValueError('empty repository discovery path glob')
|
||||
request[key] = list(values)
|
||||
continue
|
||||
if len(values) != 1:
|
||||
raise ValueError('duplicate repository discovery query field')
|
||||
value: Any = values[0]
|
||||
if key in {'depth', 'limit', 'max_matches', 'max_bytes'}:
|
||||
try:
|
||||
value = int(value, 10)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError('invalid repository discovery integer') from exc
|
||||
request[key] = value
|
||||
if required - set(request):
|
||||
raise ValueError('missing repository discovery query field')
|
||||
if not REPOSITORY_IDENTITY:
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE')
|
||||
try:
|
||||
discovery = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY_IDENTITY,),
|
||||
git_dir=required_authoritative_git_dir(),
|
||||
cursor_secret=os.getenv('AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET'),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if isinstance(exc, RepositoryDiscoveryError):
|
||||
raise
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE') from exc
|
||||
public_to_internal = {
|
||||
'ref': 'revision',
|
||||
'path': 'file_path',
|
||||
'path_prefix': 'prefix',
|
||||
}
|
||||
internal_request = {
|
||||
public_to_internal.get(key, key): value for key, value in request.items()
|
||||
}
|
||||
return dict(getattr(discovery, operation)(internal_request))
|
||||
|
||||
|
||||
def publication_publisher() -> TagPublisher:
|
||||
authoritative_repo = required_authoritative_git_dir()
|
||||
return TagPublisher(
|
||||
publisher = TagPublisher(
|
||||
WS,
|
||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||
GiteaTagClient(
|
||||
@@ -344,6 +439,10 @@ def publication_publisher() -> TagPublisher:
|
||||
),
|
||||
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):
|
||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||
@@ -393,15 +492,71 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
return a
|
||||
def bridge_release(self, path, data):
|
||||
route = {
|
||||
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
||||
'/requestOrCreateTag': '/v2/tag/publish',
|
||||
'/getTagPublicationStatus': '/v2/tag/status',
|
||||
'/buildProductionArtifact': '/v2/release/artifact',
|
||||
'/executeApprovedProductionRelease': '/v2/release/execute',
|
||||
'/getReleaseConsistency': '/v2/release/consistency',
|
||||
'/runReleaseAcceptance': '/v2/release/acceptance',
|
||||
}.get(path)
|
||||
if route is None:
|
||||
raise ValueError('governed_release_route_invalid')
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'bridge_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error': 'method_not_allowed'},405)
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
if len(self.path) > 8192:
|
||||
return self.sendj({'error':'query_too_large'},413)
|
||||
try:
|
||||
q=urllib.parse.parse_qs(
|
||||
u.query, keep_blank_values=True, max_num_fields=64
|
||||
)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'query_invalid'},400)
|
||||
try:
|
||||
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
||||
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj(repository_discovery_request(path, q))
|
||||
if path in ('/api/base','/api/baseline','/base'):
|
||||
data=jload(WS/'baseline'/'current.json', {})
|
||||
data.setdefault('authoritative_repository', repository_snapshot(
|
||||
@@ -454,7 +609,15 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':redact(exc)},413)
|
||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||
return self.sendj({'error':redact(exc)},503)
|
||||
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||
payload = {'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'):
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
@@ -566,7 +729,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(
|
||||
service.get_capability_status(candidate_id)
|
||||
)
|
||||
return self.sendj(service.get_release_saga(candidate_id))
|
||||
result = service.get_release_saga(candidate_id)
|
||||
return self.sendj(result, 404 if result.get('status') == 'NOT_FOUND' else 200)
|
||||
if path == '/getRecoveryStatus':
|
||||
return self.sendj(
|
||||
silent_release_service().get_recovery_status()
|
||||
@@ -579,6 +743,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except FileNotFoundError:
|
||||
return self.sendj({'error':'release candidate not found'},404)
|
||||
except RepositoryDiscoveryError as e:
|
||||
return self.sendj({'error': redact(e)}, getattr(e, 'status_code', 400))
|
||||
except ValueError as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_GET', error, {'path':path})
|
||||
@@ -595,6 +761,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error':'method_not_allowed'},405)
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
@@ -666,7 +834,44 @@ class H(BaseHTTPRequestHandler):
|
||||
def file(self,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c})
|
||||
if os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() not in {'1','true','yes','on'}:
|
||||
return self.sendj({'error':'DURABLE_JOB_CONTROLLER_UNAVAILABLE','retryable':True},503)
|
||||
try:
|
||||
path = relpath(str(data.get('path') or ''))
|
||||
branch = str(data.get('branch') or '')
|
||||
expected = str(data.get('expected_branch_oid') or '')
|
||||
campaign_id = str(data.get('campaign_id') or os.getenv('AIOS_GITOPS_CAMPAIGN_ID') or '')
|
||||
supplied_digest = str(data.get('payload_digest') or '')
|
||||
content = data.get('content')
|
||||
payload_bytes = None
|
||||
if content is not None:
|
||||
payload_bytes = content.encode('utf-8') if isinstance(content, str) else json.dumps(content, sort_keys=True, ensure_ascii=False).encode('utf-8')
|
||||
payload_digest = supplied_digest or hashlib.sha256(payload_bytes or b'').hexdigest()
|
||||
if not campaign_id or not branch or not expected:
|
||||
return self.sendj({'error':'JOB_BINDING_REQUIRED','retryable':False},409)
|
||||
request = {
|
||||
'campaign_id': campaign_id,
|
||||
'repository': REPOSITORY_IDENTITY or 'aios/aios',
|
||||
'operation': 'commit-file',
|
||||
'branch': branch,
|
||||
'expected_branch_oid': expected,
|
||||
'payload_digest': payload_digest,
|
||||
# The controller persists only the binding and digest. Raw
|
||||
# content is never copied into durable state or logs.
|
||||
}
|
||||
receipt = GitOpsJobController(
|
||||
JobStore(Path(os.getenv('AIOS_GITOPS_JOB_ROOT', '/var/lib/aios-gitops/jobs')))
|
||||
).submit(request, payload=payload_bytes)
|
||||
return self.sendj({
|
||||
'job_id': receipt.job_id,
|
||||
'state': receipt.state.value,
|
||||
'side_effect_state': receipt.side_effect_state,
|
||||
'retryable': receipt.http_status == 503,
|
||||
}, receipt.http_status)
|
||||
except ServiceUnavailable as exc:
|
||||
return self.sendj({'error':str(exc),'layer':'durable_job','retryable':True,'side_effect_state':'UNKNOWN'},503)
|
||||
except JobError as exc:
|
||||
return self.sendj({'error':str(exc),'retryable':False},409)
|
||||
def commit(self,data):
|
||||
a=self.need('commit:create');
|
||||
if not a: return
|
||||
@@ -680,7 +885,10 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('tag:create');
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
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)
|
||||
try:
|
||||
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'):
|
||||
return self.sendj({
|
||||
'error':'production_tag_evidence_required',
|
||||
@@ -851,9 +1059,11 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
|
||||
|
||||
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/repository/tree','GET /api/repository/search','GET /api/repository/diff-index','GET /api/repository/file','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():
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""ChatGPT GitOps Bridge v2 contract and governed release operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_task_admission_bridge_contract import (
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS,
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
TASK_ADMISSION_ROUTES,
|
||||
)
|
||||
|
||||
|
||||
BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2"
|
||||
BRIDGE_OPERATIONS = (
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"createGovernedTask",
|
||||
"projectOnlineAgentExecution",
|
||||
"acquireWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
"getGitOpsJob",
|
||||
"resumeGitOpsJob",
|
||||
"reconcileGitOpsJob",
|
||||
"createTagApprovalRequest",
|
||||
"requestOrCreateTag",
|
||||
"getTagPublicationStatus",
|
||||
"buildProductionArtifact",
|
||||
"executeApprovedProductionRelease",
|
||||
"getReleaseConsistency",
|
||||
"runReleaseAcceptance",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
)
|
||||
|
||||
TASK_ADMISSION_OPERATIONS = tuple(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS)
|
||||
|
||||
OPERATION_REQUIRED_FIELDS = {
|
||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||
"projectOnlineAgentExecution": ("task_id", "required_capabilities"),
|
||||
"acquireWriterLease": ("schema", "task_id", "job_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"getGitOpsJob": ("task_id", "job_id"),
|
||||
"resumeGitOpsJob": ("task_id", "job_id", "expected_revision"),
|
||||
"reconcileGitOpsJob": ("task_id", "job_id", "expected_revision"),
|
||||
"createTagApprovalRequest": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "owner_approval", "execute"),
|
||||
"requestOrCreateTag": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getTagPublicationStatus": ("version",),
|
||||
"buildProductionArtifact": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"executeApprovedProductionRelease": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"getControlPlaneSnapshot": (),
|
||||
**TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
"listRepositoryTree": ("repository", "ref", "limit"),
|
||||
"searchRepositorySource": ("repository", "ref", "query", "max_matches", "max_bytes"),
|
||||
"getDiffIndex": ("repository", "from", "to", "limit"),
|
||||
"readRepositoryFile": ("repository", "ref", "path"),
|
||||
}
|
||||
|
||||
OPERATION_OPTIONAL_FIELDS = {
|
||||
"listRepositoryTree": ("prefix", "depth", "cursor"),
|
||||
"searchRepositorySource": ("path_globs", "cursor"),
|
||||
"getDiffIndex": ("path_prefix", "cursor"),
|
||||
"readRepositoryFile": ("max_bytes",),
|
||||
}
|
||||
|
||||
READ_ONLY_OPERATIONS = frozenset(
|
||||
{
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
}
|
||||
)
|
||||
|
||||
_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",
|
||||
**{operation: path for path, operation in TASK_ADMISSION_ROUTES.items()},
|
||||
"listRepositoryTree": "/v2/repository/tree",
|
||||
"searchRepositorySource": "/v2/repository/search",
|
||||
"getDiffIndex": "/v2/repository/diff-index",
|
||||
"readRepositoryFile": "/v2/repository/file",
|
||||
}
|
||||
|
||||
|
||||
def _has_complete_task_admission_handlers(
|
||||
handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None,
|
||||
) -> bool:
|
||||
return handlers is not None and all(
|
||||
callable(handlers.get(operation)) for operation in TASK_ADMISSION_OPERATIONS
|
||||
)
|
||||
|
||||
|
||||
def operation_contracts(
|
||||
handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
"""Return the runtime contract consumed by adapters and schema tooling."""
|
||||
contracts = {
|
||||
operation: {
|
||||
"method": "GET" if operation in OPERATION_OPTIONAL_FIELDS else "POST",
|
||||
"path": _OPERATION_PATHS.get(operation),
|
||||
"required_fields": list(fields),
|
||||
"optional_fields": list(OPERATION_OPTIONAL_FIELDS.get(operation, ())),
|
||||
"read_only": operation in READ_ONLY_OPERATIONS,
|
||||
}
|
||||
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
|
||||
if operation not in TASK_ADMISSION_OPERATIONS
|
||||
}
|
||||
if _has_complete_task_admission_handlers(handlers):
|
||||
contracts.update(
|
||||
{
|
||||
operation: {
|
||||
"method": "GET" if operation == "getTaskAdmissionStatus" else "POST",
|
||||
"path": _OPERATION_PATHS[operation],
|
||||
"required_fields": list(
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation]
|
||||
),
|
||||
"optional_fields": list(
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation]
|
||||
),
|
||||
}
|
||||
for operation in TASK_ADMISSION_OPERATIONS
|
||||
}
|
||||
)
|
||||
return contracts
|
||||
|
||||
|
||||
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"
|
||||
" security: [{taskPrincipalAuth: []}]\n"
|
||||
" x-aios-task-principal: Bridge resolves a fresh server-side TaskPrincipal from the authoritative admitted+claimed Task and re-verifies its Owner epoch, then checks exact task/job/repository/branch/generation/capability binding; caller JSON cannot provide a principal and missing verification is denied. An explicitly injected legacy bearer verifier may still be used by a trusted host.\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"
|
||||
" '202': {description: Existing external Job alias requires authoritative reconciliation; no replacement Job is created}\n"
|
||||
" '401': {description: No verified Task principal is available}\n"
|
||||
" '403': {description: Verified Task principal lacks exact task, external Job, or capability scope}\n"
|
||||
" '400': {description: Contract or policy rejection}\n"
|
||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
||||
" '423': {description: Narrow P0 Task-principal mutation gate is frozen; assertion is not consumed}\n"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeDriftError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BridgeV2Error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ControlPlaneReleaseManifest:
|
||||
control_plane_release_version: str
|
||||
control_plane_target_oid: str
|
||||
artifact_sha256: str
|
||||
runtime_path: Path
|
||||
bridge_schema_version: str
|
||||
repository_identity: str
|
||||
authoritative_git_dir: Path
|
||||
deployment_state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"control_plane_release_version": self.control_plane_release_version,
|
||||
"control_plane_target_oid": self.control_plane_target_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"runtime_path": str(self.runtime_path),
|
||||
"bridge_schema_version": self.bridge_schema_version,
|
||||
"repository_identity": self.repository_identity,
|
||||
"authoritative_git_dir": str(self.authoritative_git_dir),
|
||||
"deployment_state": self.deployment_state,
|
||||
}
|
||||
|
||||
def validate(self, artifact: Path, *, observed_oid: str, approved_root: Path = Path("/opt/aios/gitops-control-plane")) -> dict[str, object]:
|
||||
try:
|
||||
runtime = self.runtime_path.resolve(strict=True)
|
||||
root = approved_root.resolve(strict=True)
|
||||
artifact_path = artifact.resolve(strict=True)
|
||||
runtime.relative_to(root)
|
||||
artifact_path.relative_to(runtime)
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT") from None
|
||||
mutable_markers = {"workspace", "workspaces", "worktree", "worktrees"}
|
||||
if mutable_markers.intersection(part.lower() for part in runtime.parts):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
valid = (
|
||||
len(self.control_plane_target_oid) == 40
|
||||
and observed_oid == self.control_plane_target_oid
|
||||
and len(self.artifact_sha256) == 64
|
||||
and hashlib.sha256(artifact_path.read_bytes()).hexdigest() == self.artifact_sha256
|
||||
and self.bridge_schema_version == BRIDGE_SCHEMA_VERSION
|
||||
and self.repository_identity == "aios/aios"
|
||||
and self.authoritative_git_dir == Path("/data/aios-gitops/repo.git")
|
||||
and self.authoritative_git_dir.is_absolute()
|
||||
and self.deployment_state == "STAGING"
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
"""Thin operation adapter; all mutating handlers are injected seams."""
|
||||
|
||||
def __init__(self, *, runtime_status: Mapping[str, Any] | Callable[[], Mapping[str, Any]], handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None:
|
||||
self._runtime_status_source = runtime_status if callable(runtime_status) else dict(runtime_status)
|
||||
self.handlers = dict(handlers or {})
|
||||
|
||||
def _runtime_status(self) -> dict[str, Any]:
|
||||
value = self._runtime_status_source() if callable(self._runtime_status_source) else self._runtime_status_source
|
||||
if not isinstance(value, Mapping):
|
||||
raise BridgeV2Error("RUNTIME_STATUS_INVALID")
|
||||
return dict(value)
|
||||
|
||||
def getRuntimeStatus(self) -> dict[str, Any]:
|
||||
return {"bridge_schema_version": BRIDGE_SCHEMA_VERSION, **self._runtime_status()}
|
||||
|
||||
def getGovernedWriteCapabilities(self) -> dict[str, Any]:
|
||||
runtime_status = self._runtime_status()
|
||||
governed_writes_enabled = runtime_status.get("governed_writes_enabled") is True
|
||||
return {
|
||||
"bridge_schema_version": BRIDGE_SCHEMA_VERSION,
|
||||
"legacy_writes_enabled": False,
|
||||
"governed_writes_enabled": governed_writes_enabled,
|
||||
"governed_write_blockers": list(
|
||||
runtime_status.get("governed_write_blockers") or []
|
||||
),
|
||||
"operations": [
|
||||
*BRIDGE_OPERATIONS,
|
||||
*(
|
||||
TASK_ADMISSION_OPERATIONS
|
||||
if _has_complete_task_admission_handlers(self.handlers)
|
||||
else ()
|
||||
),
|
||||
],
|
||||
"operation_contracts": operation_contracts(self.handlers),
|
||||
"force_push": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"production_write": False,
|
||||
"approved_release_operations": True,
|
||||
"approved_release_scope": {
|
||||
"version": "V2R2C1",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def legacy_write_disabled() -> dict[str, object]:
|
||||
return {"error": "LEGACY_WRITE_DISABLED", "replacement": "createGovernedTask", "governed_writes_available": False}
|
||||
|
||||
def _call(self, operation: str, request: Mapping[str, Any]) -> Any:
|
||||
required = OPERATION_REQUIRED_FIELDS[operation]
|
||||
if any(key not in request for key in required):
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_MISSING")
|
||||
if operation in OPERATION_OPTIONAL_FIELDS:
|
||||
allowed = set(required) | set(OPERATION_OPTIONAL_FIELDS[operation])
|
||||
if set(request) - allowed:
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_UNKNOWN")
|
||||
handler = self.handlers.get(operation)
|
||||
if handler is None:
|
||||
raise BridgeV2Error("GOVERNED_HANDLER_UNAVAILABLE")
|
||||
return handler(dict(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 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 getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||
def createTagApprovalRequest(self, request: Mapping[str, Any]) -> Any: return self._call("createTagApprovalRequest", request)
|
||||
def requestOrCreateTag(self, request: Mapping[str, Any]) -> Any: return self._call("requestOrCreateTag", request)
|
||||
def getTagPublicationStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTagPublicationStatus", request)
|
||||
def buildProductionArtifact(self, request: Mapping[str, Any]) -> Any: return self._call("buildProductionArtifact", request)
|
||||
def executeApprovedProductionRelease(self, request: Mapping[str, Any]) -> Any: return self._call("executeApprovedProductionRelease", request)
|
||||
def getReleaseConsistency(self, request: Mapping[str, Any]) -> Any: return self._call("getReleaseConsistency", request)
|
||||
def runReleaseAcceptance(self, request: Mapping[str, Any]) -> Any: return self._call("runReleaseAcceptance", request)
|
||||
def getTaskAdmissionEnvelope(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionEnvelope", request)
|
||||
def submitTaskAdmissionAuthorization(self, request: Mapping[str, Any]) -> Any: return self._call("submitTaskAdmissionAuthorization", request)
|
||||
def getTaskAdmissionStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionStatus", request)
|
||||
def listRepositoryTree(self, request: Mapping[str, Any]) -> Any: return self._call("listRepositoryTree", request)
|
||||
def searchRepositorySource(self, request: Mapping[str, Any]) -> Any: return self._call("searchRepositorySource", request)
|
||||
def getDiffIndex(self, request: Mapping[str, Any]) -> Any: return self._call("getDiffIndex", request)
|
||||
def readRepositoryFile(self, request: Mapping[str, Any]) -> Any: return self._call("readRepositoryFile", request)
|
||||
|
||||
def getControlPlaneSnapshot(self, request: Mapping[str, Any] | None = None) -> Any: return self._call("getControlPlaneSnapshot", request or {})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
"""Governed continuation executor for exact existing task branches.
|
||||
|
||||
The executor composes the existing continuation lease and durable Job store.
|
||||
All Git and Gitea side effects live behind injected narrow interfaces so the
|
||||
state machine can prove Authority, Git-network, API-branch, and commit-object
|
||||
agreement before it reports completion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Callable, Mapping, Protocol
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState
|
||||
from aios_gitops_reconciliation_core import ReconciliationAction, RemoteObservation, decide_reconciliation
|
||||
from authorized_task_writer_provisioning import validate_writer_lease_request_v2
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"gh[pousr]_[A-Za-z0-9]{20,}"),
|
||||
re.compile(rb"(?i)(?:token|password|secret|api[_-]?key)\s*[:=]\s*[\"'][^\"'\s]{12,}[\"']"),
|
||||
re.compile(rb"-----BEGIN " + rb"(?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
)
|
||||
|
||||
|
||||
class ContinuationExecutionError(RuntimeError):
|
||||
"""Stable fail-closed continuation execution error."""
|
||||
|
||||
|
||||
class DetachedWorkspace(Protocol):
|
||||
def is_detached(self) -> bool: ...
|
||||
def head_oid(self) -> str: ...
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str: ...
|
||||
|
||||
|
||||
class AuthorityPort(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
class RemotePort(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None: ...
|
||||
def read_git_ref(self, branch: str) -> str | None: ...
|
||||
def read_api_branch(self, branch: str) -> str | None: ...
|
||||
def commit_visible(self, oid: str) -> bool: ...
|
||||
|
||||
|
||||
class CredentialProjection(Protocol):
|
||||
def project(self) -> None: ...
|
||||
def revoke(self) -> None: ...
|
||||
|
||||
|
||||
def _run(argv: list[str], *, cwd: Path | None = None, environment: Mapping[str, str] | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=None if environment is None else dict(environment),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthorityGitAdapter:
|
||||
"""Branch-name adapter over the existing narrow AuthorityGitExecutor."""
|
||||
|
||||
executor: AuthorityGitExecutor
|
||||
|
||||
@staticmethod
|
||||
def _ref(branch: str) -> str:
|
||||
return f"refs/heads/{branch}"
|
||||
|
||||
def branch_exists(self, branch: str) -> bool:
|
||||
return self.executor.read_ref(self._ref(branch)) is not None
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
value = self.executor.read_ref(self._ref(branch))
|
||||
if value is None:
|
||||
raise ContinuationExecutionError("EXISTING_BRANCH_MISSING")
|
||||
return value
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return self.executor.ancestry_check(older, newer)
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
||||
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DetachedGitWorkspace:
|
||||
path: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.path.is_absolute() or self.path.is_symlink() or not self.path.is_dir():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
top = Path(_run(["git", "rev-parse", "--show-toplevel"], cwd=self.path)).resolve()
|
||||
if top != self.path.resolve():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
|
||||
def is_detached(self) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "symbolic-ref", "-q", "HEAD"],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 1
|
||||
|
||||
def head_oid(self) -> str:
|
||||
return _run(["git", "rev-parse", "HEAD"], cwd=self.path)
|
||||
|
||||
def _dirty_paths(self) -> set[str]:
|
||||
command = subprocess.run(
|
||||
["git", "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
cwd=self.path,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if command.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
output = command.stdout
|
||||
result: set[str] = set()
|
||||
for line in output.splitlines():
|
||||
path = line[3:]
|
||||
if " -> " in path:
|
||||
path = path.split(" -> ", 1)[1]
|
||||
result.add(path)
|
||||
return result
|
||||
|
||||
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||
dirty = self._dirty_paths()
|
||||
expected_paths = set(files)
|
||||
if dirty and dirty != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if target.is_symlink():
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str:
|
||||
expected_paths = set(files)
|
||||
if self._dirty_paths() != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if not target.is_file() or target.is_symlink() or target.read_bytes() != content:
|
||||
raise ContinuationExecutionError("AUTHORING_PAYLOAD_MISMATCH")
|
||||
_run(["git", "add", "--", *sorted(expected_paths)], cwd=self.path)
|
||||
staged = set(_run(["git", "diff", "--cached", "--name-only"], cwd=self.path).splitlines())
|
||||
if staged != expected_paths:
|
||||
raise ContinuationExecutionError("STAGED_SCOPE_MISMATCH")
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"GIT_AUTHOR_NAME": "AIOS Governed Continuation",
|
||||
"GIT_AUTHOR_EMAIL": "aios-governed-continuation@localhost",
|
||||
"GIT_COMMITTER_NAME": "AIOS Governed Continuation",
|
||||
"GIT_COMMITTER_EMAIL": "aios-governed-continuation@localhost",
|
||||
}
|
||||
)
|
||||
_run(["git", "commit", "--no-gpg-sign", "-m", message, "--", *sorted(expected_paths)], cwd=self.path, environment=environment)
|
||||
if not self.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
return self.head_oid()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServerSideCredentialProjection:
|
||||
ssh_key_path: Path
|
||||
api_token_path: Path
|
||||
active: bool = False
|
||||
|
||||
def project(self) -> None:
|
||||
for path in (self.ssh_key_path, self.api_token_path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size < 32:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
self.active = True
|
||||
|
||||
def revoke(self) -> None:
|
||||
self.active = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaContinuationRemote:
|
||||
workspace: Path
|
||||
credential: ServerSideCredentialProjection
|
||||
remote_name: str = "gitea"
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
expected_host: str = "g.cnnetmkt.top"
|
||||
|
||||
@staticmethod
|
||||
def validate_remote_url(value: str, expected_host: str = "g.cnnetmkt.top") -> None:
|
||||
if value.startswith(("/", "file:")) or value.endswith("/data/aios-gitops/repo.git"):
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
if "://" in value:
|
||||
parsed = urlsplit(value)
|
||||
host, path = parsed.hostname, parsed.path
|
||||
else:
|
||||
location = value.split("@", 1)[-1]
|
||||
host, separator, path = location.partition(":")
|
||||
if not separator:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
path = "/" + path
|
||||
if host != expected_host or path.rstrip("/") not in {"/aios/aios.git", "/git/aios/aios.git"}:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
url = _run(["git", "remote", "get-url", self.remote_name], cwd=self.workspace)
|
||||
self.validate_remote_url(url, self.expected_host)
|
||||
if self.repository != "aios/aios":
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
return self.repository
|
||||
|
||||
def _require_projected(self) -> None:
|
||||
if not self.credential.active:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
|
||||
def read_git_ref(self, branch: str) -> str | None:
|
||||
self._require_projected()
|
||||
output = _run(["git", "ls-remote", self.remote_name, f"refs/heads/{branch}"], cwd=self.workspace)
|
||||
if not output:
|
||||
return None
|
||||
oid, ref = output.split(maxsplit=1)
|
||||
if ref != f"refs/heads/{branch}" or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
current = self.read_git_ref(branch)
|
||||
if current != expected and not (allow_absent and current is None):
|
||||
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||
|
||||
def _api(self, path: str) -> object:
|
||||
self._require_projected()
|
||||
token = self.credential.api_token_path.read_text(encoding="utf-8").strip()
|
||||
request = Request(
|
||||
self.api_base.rstrip("/") + path,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise ContinuationExecutionError(f"GITEA_API_HTTP_{exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||
raise ContinuationExecutionError("GITEA_API_UNAVAILABLE") from exc
|
||||
|
||||
def read_api_branch(self, branch: str) -> str | None:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
page = 1
|
||||
while page <= 100:
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/branches?page={page}&limit=50")
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_COLLECTION_INVALID")
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or item.get("name") != branch:
|
||||
continue
|
||||
oid = item.get("commit", {}).get("id")
|
||||
if not isinstance(oid, str) or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_INVALID")
|
||||
return oid
|
||||
if len(value) < 50:
|
||||
return None
|
||||
page += 1
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/git/commits/{oid}")
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
observed = value.get("sha") or value.get("id")
|
||||
return observed == oid
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationPermissions:
|
||||
create_target_branch: bool = False
|
||||
force_push: bool = False
|
||||
tag_write: bool = False
|
||||
baseline_write: bool = False
|
||||
production_write: bool = False
|
||||
main_write: bool = False
|
||||
|
||||
def validate(self) -> None:
|
||||
if any(asdict(self).values()):
|
||||
raise ContinuationExecutionError("MUTATION_PERMISSION_DENIED")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationRuntimeBinding:
|
||||
request: WriterLeaseRequestV2
|
||||
workspace_path: Path
|
||||
lease_not_before: datetime
|
||||
lease_expires_at: datetime
|
||||
original_receipt_valid: bool
|
||||
original_grant_consumed: bool
|
||||
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)
|
||||
class ContinuationCommit:
|
||||
idempotency_key: str
|
||||
files: Mapping[str, bytes]
|
||||
message: str
|
||||
|
||||
def payload(self) -> bytes:
|
||||
if not self.idempotency_key or not self.message.strip() or not self.files:
|
||||
raise ContinuationExecutionError("COMMIT_REQUEST_INVALID")
|
||||
normalized: dict[str, str] = {}
|
||||
for path, content in self.files.items():
|
||||
pure = PurePosixPath(path)
|
||||
if (
|
||||
not isinstance(path, str)
|
||||
or pure.is_absolute()
|
||||
or ".." in pure.parts
|
||||
or path.startswith((".git/", "baseline/", "production/"))
|
||||
or not isinstance(content, bytes)
|
||||
):
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
if any(pattern.search(content) for pattern in SECRET_PATTERNS):
|
||||
raise ContinuationExecutionError("SECRET_LIKE_PAYLOAD_REJECTED")
|
||||
normalized[path] = base64.b64encode(content).decode("ascii")
|
||||
return json.dumps(
|
||||
{"files_base64": normalized, "message": self.message},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class ContinuationExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GitOpsJobController,
|
||||
authority: AuthorityPort,
|
||||
workspace: DetachedWorkspace,
|
||||
remote: RemotePort,
|
||||
credential: CredentialProjection,
|
||||
*,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.authority = authority
|
||||
self.workspace = workspace
|
||||
self.remote = remote
|
||||
self.credential = credential
|
||||
self.now = now
|
||||
self._observed_generations: dict[str, int] = {}
|
||||
|
||||
def observe_generation(self, branch: str, generation: int) -> None:
|
||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
||||
|
||||
def _max_generation(self, branch: str) -> int:
|
||||
observed = self._observed_generations.get(branch, 0)
|
||||
with self.controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
durable = int(row["generation"] or 0)
|
||||
return max(observed, durable)
|
||||
|
||||
@staticmethod
|
||||
def _request_mapping(request: WriterLeaseRequestV2) -> dict[str, object]:
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_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": request.generation,
|
||||
"lease_seconds": request.lease_seconds,
|
||||
}
|
||||
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
||||
request = binding.request
|
||||
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
||||
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||
binding.permissions.validate()
|
||||
now = self.now()
|
||||
if now < binding.lease_not_before:
|
||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||
if now >= binding.lease_expires_at:
|
||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||
if request.generation <= self._max_generation(request.target_branch):
|
||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||
if not self.workspace.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
if self.workspace.head_oid() != request.expected_branch_oid:
|
||||
raise ContinuationExecutionError("WORKTREE_HEAD_MISMATCH")
|
||||
if self.remote.repository_identity() != request.repository:
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
def _job_request(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit, payload: bytes) -> dict[str, object]:
|
||||
request = binding.request
|
||||
return {
|
||||
"idempotency_key": commit.idempotency_key,
|
||||
"campaign_id": request.task_id,
|
||||
"task_id": request.task_id,
|
||||
"repository": request.repository,
|
||||
"operation": "CONTINUATION_COMMIT_FILES",
|
||||
"branch": request.target_branch,
|
||||
"expected_branch_oid": request.expected_branch_oid,
|
||||
"base_oid": request.base_oid,
|
||||
"branch_mode": request.branch_mode,
|
||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||
"lineage_anchor_oid": request.base_oid,
|
||||
"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]:
|
||||
payload = commit.payload()
|
||||
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
||||
current = self.controller.store.read(submitted.job_id)
|
||||
if JobState(current["state"]) == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||
self._validate_preflight(binding)
|
||||
request = binding.request
|
||||
projected = False
|
||||
try:
|
||||
self.credential.project()
|
||||
projected = True
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
submitted.job_id,
|
||||
JobState.AUTHORIZED,
|
||||
side_effect_state="AUTHORIZED",
|
||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||
)
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(
|
||||
submitted.job_id,
|
||||
lease_id=f"lease-{submitted.job_id}",
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
||||
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
||||
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
if authority_oid != new_oid:
|
||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
self.controller.advance(
|
||||
submitted.job_id,
|
||||
JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="LOCAL_VERIFIED",
|
||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||
)
|
||||
try:
|
||||
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)
|
||||
except Exception:
|
||||
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
||||
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
||||
finally:
|
||||
if projected:
|
||||
self.credential.revoke()
|
||||
|
||||
def _observe_remote(self, branch: str, oid: str) -> tuple[str | None, str | None, bool]:
|
||||
return self.remote.read_git_ref(branch), self.remote.read_api_branch(branch), self.remote.commit_visible(oid)
|
||||
|
||||
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"desired_remote_oid": new_oid,
|
||||
"remote_verified": False,
|
||||
"force_push": False,
|
||||
}
|
||||
result = self.controller.advance(
|
||||
job_id,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="REMOTE_PUSH_PENDING",
|
||||
receipt=receipt,
|
||||
)
|
||||
return result.as_dict()
|
||||
|
||||
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
branch = row["branch"]
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
git_oid, api_oid, visible = self._observe_remote(branch, new_oid)
|
||||
decision = decide_reconciliation(RemoteObservation("aios/aios", branch, new_oid, authority_oid, git_oid, api_oid, visible, datetime.now(timezone.utc).isoformat(), request.get("expected_branch_oid")))
|
||||
if decision.action is not ReconciliationAction.MARK_REMOTE_VERIFIED:
|
||||
return self._remote_pending(job_id, authority_oid, new_oid)
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"git_remote_oid": git_oid,
|
||||
"api_branch_oid": api_oid,
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
"reconciliation_decision": decision.action.value,
|
||||
"force_push": False,
|
||||
}
|
||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
||||
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if state != JobState.RECONCILIATION_REQUIRED:
|
||||
raise ContinuationExecutionError("JOB_NOT_RECONCILABLE")
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
receipt = json.loads(row["receipt_json"])
|
||||
new_oid = receipt["desired_remote_oid"]
|
||||
authority_oid = self.authority.branch_head(row["branch"])
|
||||
try:
|
||||
generation = int(request.get("generation") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ContinuationExecutionError("DURABLE_BINDING_GENERATION_INVALID") from exc
|
||||
if generation > 0:
|
||||
with self.controller.store._connect() as connection:
|
||||
earlier = connection.execute(
|
||||
"SELECT 1 FROM jobs WHERE branch=? AND state=? "
|
||||
"AND CAST(json_extract(request_json, '$.binding.generation') AS INTEGER) < ? LIMIT 1",
|
||||
(row["branch"], JobState.RECONCILIATION_REQUIRED.value, generation),
|
||||
).fetchone()
|
||||
if earlier is not None:
|
||||
raise ContinuationExecutionError("EARLIER_RECONCILIATION_REQUIRED")
|
||||
self.credential.project()
|
||||
try:
|
||||
git_oid, api_oid, visible = self._observe_remote(row["branch"], new_oid)
|
||||
if authority_oid == git_oid == api_oid == new_oid and visible:
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST")
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
expected_oid = request["expected_branch_oid"]
|
||||
if (
|
||||
self.authority.is_ancestor(new_oid, authority_oid)
|
||||
and git_oid is not None
|
||||
and git_oid == api_oid
|
||||
and self.authority.is_ancestor(git_oid, new_oid)
|
||||
):
|
||||
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")
|
||||
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)
|
||||
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.remote.push_exact(row["branch"], expected_oid, new_oid, allow_absent=allow_remote_create)
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
finally:
|
||||
self.credential.revoke()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Campaign-aware continuation leases for an existing Git branch.
|
||||
|
||||
This module is deliberately transport-neutral. It validates branch lineage
|
||||
and hands ref mutation to an injected compare-and-swap broker; it does not run
|
||||
Git, inspect credentials, or create a branch itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TARGET_BRANCH = "agent/autonomous-release-campaign-v1-20260807"
|
||||
LINEAGE_ANCHOR = "69b45e81dcd2c3707c367003b08c55fd361faae8"
|
||||
CAMPAIGN_HEAD = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
|
||||
|
||||
class ContinuationError(RuntimeError):
|
||||
"""Stable fail-closed error for lease or branch binding violations."""
|
||||
|
||||
|
||||
class BranchProbe(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
|
||||
|
||||
class RefCAS(Protocol):
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise ContinuationError(f"{field}_invalid")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WriterLeaseRequestV2:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch_mode: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
lease_seconds: int = 900
|
||||
|
||||
schema = "aios.task-writer-lease.v2"
|
||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
||||
|
||||
def validate(self, probe: BranchProbe) -> None:
|
||||
if not self.task_id or self.repository != "aios/aios":
|
||||
raise ContinuationError("request_identity_invalid")
|
||||
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
raise ContinuationError("branch_mode_invalid")
|
||||
_oid(self.base_oid, "base_oid")
|
||||
_oid(self.expected_branch_oid, "expected_branch_oid")
|
||||
if self.target_branch in {"main", "master"} or self.target_branch.startswith(("refs/", "tags/")):
|
||||
raise ContinuationError("protected_branch_rejected")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise ContinuationError("generation_invalid")
|
||||
if not isinstance(self.lease_seconds, int) or not 1 <= self.lease_seconds <= 86_400:
|
||||
raise ContinuationError("lease_duration_invalid")
|
||||
if self.branch_mode == "CONTINUE_EXISTING":
|
||||
if not probe.branch_exists(self.target_branch):
|
||||
raise ContinuationError("EXISTING_BRANCH_MISSING")
|
||||
actual = _oid(probe.branch_head(self.target_branch), "actual_branch_oid")
|
||||
if actual != self.expected_branch_oid:
|
||||
raise ContinuationError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
if not probe.is_ancestor(self.base_oid, actual):
|
||||
raise ContinuationError("BASE_NOT_ANCESTOR_OF_BRANCH")
|
||||
elif hasattr(probe, "commit_exists") and not probe.commit_exists(self.base_oid):
|
||||
raise ContinuationError("BASE_COMMIT_NOT_FOUND")
|
||||
elif self.branch_mode == "CREATE_NEW" and probe.branch_exists(self.target_branch):
|
||||
raise ContinuationError("TARGET_BRANCH_ALREADY_EXISTS")
|
||||
elif self.branch_mode == "CREATE_NEW" and self.base_oid != self.expected_branch_oid:
|
||||
raise ContinuationError("CREATE_NEW_BASE_EXPECTATION_MISMATCH")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWriterLease:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
lineage_anchor_oid: str
|
||||
current_head_oid: str
|
||||
generation: int
|
||||
lease_expires_at: datetime
|
||||
append_only: bool = True
|
||||
renewable: bool = True
|
||||
multi_commit: bool = True
|
||||
recoverable: bool = True
|
||||
state: str = "ACTIVE"
|
||||
branch_bound: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_oid(self.lineage_anchor_oid, "lineage_anchor_oid")
|
||||
_oid(self.current_head_oid, "current_head_oid")
|
||||
if not all((self.append_only, self.renewable, self.multi_commit, self.recoverable, self.branch_bound)):
|
||||
raise ContinuationError("lease_capability_downgrade")
|
||||
|
||||
|
||||
class ContinuationLeaseManager:
|
||||
"""State machine for a multi-commit lease with CAS-only ref updates."""
|
||||
|
||||
def __init__(self, probe: BranchProbe, refs: RefCAS, *, now=datetime.now) -> None:
|
||||
self.probe = probe
|
||||
self.refs = refs
|
||||
self.now = now
|
||||
|
||||
def open(self, request: WriterLeaseRequestV2) -> CampaignWriterLease:
|
||||
request.validate(self.probe)
|
||||
if request.branch_mode != "CONTINUE_EXISTING":
|
||||
raise ContinuationError("CREATE_NEW_REQUIRES_SEPARATE_PROVISIONER")
|
||||
return CampaignWriterLease(
|
||||
task_id=request.task_id,
|
||||
repository=request.repository,
|
||||
branch=request.target_branch,
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=self.now(timezone.utc) + timedelta(seconds=request.lease_seconds),
|
||||
)
|
||||
|
||||
def renew(self, lease: CampaignWriterLease, seconds: int) -> CampaignWriterLease:
|
||||
if lease.state != "ACTIVE" or not lease.renewable or seconds < 1:
|
||||
raise ContinuationError("lease_not_renewable")
|
||||
return replace(lease, lease_expires_at=self.now(timezone.utc) + timedelta(seconds=seconds))
|
||||
|
||||
def append_commit(self, lease: CampaignWriterLease, new_head_oid: str) -> CampaignWriterLease:
|
||||
if lease.state != "ACTIVE":
|
||||
raise ContinuationError("lease_not_active")
|
||||
_oid(new_head_oid, "new_head_oid")
|
||||
if not self.probe.is_ancestor(lease.current_head_oid, new_head_oid):
|
||||
raise ContinuationError("NON_FAST_FORWARD_COMMIT")
|
||||
try:
|
||||
actual = self.refs.compare_and_swap_branch(
|
||||
lease.branch, lease.current_head_oid, new_head_oid
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ContinuationError("BRANCH_LEASE_STALE") from exc
|
||||
if actual != new_head_oid:
|
||||
raise ContinuationError("BRANCH_LEASE_STALE")
|
||||
return replace(lease, current_head_oid=new_head_oid)
|
||||
|
||||
def supersede(self, lease: CampaignWriterLease) -> CampaignWriterLease:
|
||||
if lease.state not in {"ACTIVE", "RECOVERABLE_FAILURE", "RECONCILIATION_REQUIRED"}:
|
||||
raise ContinuationError("lease_not_supersedable")
|
||||
return replace(lease, generation=lease.generation + 1, state="SUPERSEDED")
|
||||
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
PLACEHOLDER_PATTERNS = (
|
||||
re.compile(r"(?im)^\s*(?:todo|fixme|tbd)\b"),
|
||||
re.compile(r"(?im)^\s*pass\s*(?:#.*)?$"),
|
||||
re.compile(r"(?im)\bnot\s+implemented\b"),
|
||||
re.compile(r"(?im)\bplaceholder\b"),
|
||||
re.compile(r"(?im)^\s*\.\.\.\s*$"),
|
||||
)
|
||||
|
||||
class Severity(str, Enum):
|
||||
BLOCKER = "BLOCKER"
|
||||
MAJOR = "MAJOR"
|
||||
MINOR = "MINOR"
|
||||
INFO = "INFO"
|
||||
|
||||
class TruthState(str, Enum):
|
||||
UNKNOWN = "UNKNOWN"
|
||||
ADMITTED_NOT_PROJECTED = "ADMITTED_NOT_PROJECTED"
|
||||
WAITING_CAPACITY = "WAITING_CAPACITY"
|
||||
QUEUED = "QUEUED"
|
||||
CLAIMED = "CLAIMED"
|
||||
RUNNING = "RUNNING"
|
||||
TERMINAL_UNPROVEN = "TERMINAL_UNPROVEN"
|
||||
COMPLETE = "COMPLETE"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Finding:
|
||||
code: str
|
||||
severity: Severity
|
||||
message: str
|
||||
evidence: dict[str, Any] = field(default_factory=dict)
|
||||
remediation: str | None = None
|
||||
|
||||
@dataclass
|
||||
class AdmissionSnapshot:
|
||||
task_id: str
|
||||
envelope_id: str | None = None
|
||||
admission_status: str | None = None
|
||||
grant_id: str | None = None
|
||||
grant_expires_at: int | None = None
|
||||
one_time_use: bool | None = None
|
||||
authority_base_oid: str | None = None
|
||||
execution_digest: str | None = None
|
||||
requested_capabilities: tuple[str, ...] = ()
|
||||
file_allowlist: tuple[str, ...] = ()
|
||||
admitted_at: int | None = None
|
||||
|
||||
@dataclass
|
||||
class TaskSnapshot:
|
||||
task_id: str
|
||||
status: str | None = None
|
||||
claimed_by: str | None = None
|
||||
lease_expires_at: int | None = None
|
||||
attempt: int | None = None
|
||||
revision: int | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
deadline: str | None = None
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class WorkerSnapshot:
|
||||
worker_id: str
|
||||
status: str | None = None
|
||||
last_seen_epoch: int | None = None
|
||||
capabilities: tuple[str, ...] = ()
|
||||
scope_task_id: str | None = None
|
||||
scope_job_id: str | None = None
|
||||
scope_branch: str | None = None
|
||||
scope_execution_digest: str | None = None
|
||||
|
||||
@dataclass
|
||||
class LeaseSnapshot:
|
||||
task_id: str
|
||||
holder: str | None = None
|
||||
expires_at: int | None = None
|
||||
state: str | None = None
|
||||
|
||||
@dataclass
|
||||
class GitSnapshot:
|
||||
branch: str
|
||||
current_oid: str | None = None
|
||||
expected_oid: str | None = None
|
||||
commit_oid: str | None = None
|
||||
post_oid: str | None = None
|
||||
|
||||
@dataclass
|
||||
class EvidenceSnapshot:
|
||||
commit_receipt: dict[str, Any] | None = None
|
||||
runtime_readback: dict[str, Any] | None = None
|
||||
external_projection_readback: dict[str, Any] | None = None
|
||||
output_hashes: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def parse_iso_epoch(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime
|
||||
try:
|
||||
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def worker_is_healthy(worker: WorkerSnapshot, now: int, heartbeat_ttl: int = 180) -> bool:
|
||||
return bool(
|
||||
worker.status == "ACTIVE"
|
||||
and worker.last_seen_epoch is not None
|
||||
and now - worker.last_seen_epoch <= heartbeat_ttl
|
||||
)
|
||||
|
||||
def worker_scope_matches(worker: WorkerSnapshot, task: TaskSnapshot) -> bool:
|
||||
meta = task.metadata or {}
|
||||
plan = meta.get("execution_plan") or meta.get("plan") or meta
|
||||
job_id = plan.get("external_job_id") if isinstance(plan, dict) else None
|
||||
branch = plan.get("target_branch") if isinstance(plan, dict) else None
|
||||
digest = plan.get("execution_digest") if isinstance(plan, dict) else None
|
||||
if worker.scope_task_id and worker.scope_task_id != task.task_id:
|
||||
return False
|
||||
if worker.scope_job_id and job_id and worker.scope_job_id != job_id:
|
||||
return False
|
||||
if worker.scope_branch and branch and worker.scope_branch != branch:
|
||||
return False
|
||||
if worker.scope_execution_digest and digest and worker.scope_execution_digest != digest:
|
||||
return False
|
||||
return True
|
||||
|
||||
def worker_has_capabilities(worker: WorkerSnapshot, required: Sequence[str]) -> bool:
|
||||
have = set(worker.capabilities)
|
||||
return set(required).issubset(have)
|
||||
|
||||
def eligible_workers(task: TaskSnapshot, workers: Sequence[WorkerSnapshot], now: int, heartbeat_ttl: int = 180) -> list[WorkerSnapshot]:
|
||||
return [
|
||||
w for w in workers
|
||||
if worker_is_healthy(w, now, heartbeat_ttl)
|
||||
and worker_has_capabilities(w, task.required_capabilities)
|
||||
and worker_scope_matches(w, task)
|
||||
]
|
||||
|
||||
def derive_truth(
|
||||
admission: AdmissionSnapshot | None,
|
||||
task: TaskSnapshot | None,
|
||||
workers: Sequence[WorkerSnapshot],
|
||||
lease: LeaseSnapshot | None,
|
||||
git: GitSnapshot | None,
|
||||
evidence: EvidenceSnapshot | None,
|
||||
now: int | None = None,
|
||||
queue_claim_threshold: int = 600,
|
||||
heartbeat_ttl: int = 180,
|
||||
) -> tuple[TruthState, list[Finding]]:
|
||||
now = int(time.time()) if now is None else now
|
||||
findings: list[Finding] = []
|
||||
|
||||
if admission and admission.admission_status == "ADMITTED" and not task:
|
||||
findings.append(Finding(
|
||||
"DUAL_PLANE_ADMITTED_TASK_MISSING",
|
||||
Severity.BLOCKER,
|
||||
"Admission plane says ADMITTED but TaskPlane has no task.",
|
||||
{"task_id": admission.task_id, "envelope_id": admission.envelope_id},
|
||||
"Use durable projection/outbox + exact readback; Admission must not imply execution.",
|
||||
))
|
||||
return TruthState.ADMITTED_NOT_PROJECTED, findings
|
||||
|
||||
if not task:
|
||||
return TruthState.UNKNOWN, findings
|
||||
|
||||
if admission and admission.grant_expires_at and admission.grant_expires_at <= now and task.status in {"QUEUED", "CLAIMED", "RUNNING"}:
|
||||
findings.append(Finding(
|
||||
"GRANT_EXPIRED_BEFORE_EXECUTION",
|
||||
Severity.BLOCKER,
|
||||
"Execution state depends on an expired grant.",
|
||||
{"expires_at": admission.grant_expires_at, "now": now, "task_state": task.status},
|
||||
"Mint runtime TaskPrincipal just-in-time at claim; do not burn grant TTL while waiting in queue.",
|
||||
))
|
||||
|
||||
if admission and admission.admission_status == "ADMITTED" and task.status in {"EXPIRED", "FAILED", "CANCELLED"}:
|
||||
findings.append(Finding(
|
||||
"DUAL_PLANE_TERMINAL_DRIFT",
|
||||
Severity.MAJOR,
|
||||
"Admission remains ADMITTED while TaskPlane is terminal.",
|
||||
{"admission": admission.admission_status, "task": task.status},
|
||||
"Expose admission_state and execution_state separately; never collapse both into one PASS/complete status.",
|
||||
))
|
||||
|
||||
if task.status == "QUEUED":
|
||||
elig = eligible_workers(task, workers, now, heartbeat_ttl)
|
||||
if not elig:
|
||||
findings.append(Finding(
|
||||
"QUEUED_WITHOUT_ELIGIBLE_WORKER",
|
||||
Severity.BLOCKER,
|
||||
"Task is QUEUED but no healthy, capability- and scope-matching worker exists.",
|
||||
{
|
||||
"required_capabilities": list(task.required_capabilities),
|
||||
"healthy_workers": [w.worker_id for w in workers if worker_is_healthy(w, now, heartbeat_ttl)],
|
||||
},
|
||||
"Gate TaskPlane projection on a SchedulabilityProof; keep Admission ADMITTED/WAITING_CAPACITY until claimable.",
|
||||
))
|
||||
return TruthState.WAITING_CAPACITY, findings
|
||||
created = parse_iso_epoch(task.created_at)
|
||||
if created is not None and now - created > queue_claim_threshold and not task.claimed_by:
|
||||
findings.append(Finding(
|
||||
"UNCLAIMED_TIMEOUT",
|
||||
Severity.BLOCKER,
|
||||
"QUEUED task exceeded claim SLA without a claim holder.",
|
||||
{"queued_seconds": now - created, "threshold": queue_claim_threshold},
|
||||
"Trigger scheduler diagnosis/takeover contract; do not extend TTL blindly.",
|
||||
))
|
||||
return TruthState.QUEUED, findings
|
||||
|
||||
if task.status == "CLAIMED":
|
||||
if not task.claimed_by:
|
||||
findings.append(Finding(
|
||||
"FAKE_CLAIM_STATE",
|
||||
Severity.BLOCKER,
|
||||
"Task says CLAIMED but claimed_by is empty.",
|
||||
{"task_id": task.task_id},
|
||||
"Reject state transition unless claim holder + fencing generation are persisted atomically.",
|
||||
))
|
||||
if lease is None or lease.holder != task.claimed_by or (lease.expires_at and lease.expires_at <= now):
|
||||
findings.append(Finding(
|
||||
"CLAIM_WITHOUT_VALID_LEASE",
|
||||
Severity.BLOCKER,
|
||||
"CLAIMED task has no valid matching lease.",
|
||||
{"claimed_by": task.claimed_by, "lease": asdict(lease) if lease else None},
|
||||
"Require claim+lease readback before exposing CLAIMED.",
|
||||
))
|
||||
return TruthState.CLAIMED, findings
|
||||
|
||||
if task.status == "RUNNING":
|
||||
if not task.claimed_by:
|
||||
findings.append(Finding("FAKE_RUNNING_NO_CLAIM", Severity.BLOCKER, "RUNNING without claim holder."))
|
||||
claimed_worker = next((w for w in workers if w.worker_id == task.claimed_by), None)
|
||||
if not claimed_worker or not worker_is_healthy(claimed_worker, now, heartbeat_ttl):
|
||||
findings.append(Finding(
|
||||
"FAKE_RUNNING_NO_HEARTBEAT",
|
||||
Severity.BLOCKER,
|
||||
"RUNNING task has no fresh worker heartbeat.",
|
||||
{"claimed_by": task.claimed_by},
|
||||
"Transition to recovery only through lease/fencing rules; never report RUNNING from a stale projection.",
|
||||
))
|
||||
if lease is None or (lease.expires_at and lease.expires_at <= now):
|
||||
findings.append(Finding("RUNNING_WITHOUT_VALID_LEASE", Severity.BLOCKER, "RUNNING without valid lease."))
|
||||
return TruthState.RUNNING, findings
|
||||
|
||||
if task.status in {"SUCCEEDED", "COMPLETE"}:
|
||||
ev = evidence or EvidenceSnapshot()
|
||||
commit = ev.commit_receipt or {}
|
||||
before = git.expected_oid if git else None
|
||||
post = (git.post_oid if git else None) or commit.get("post_oid")
|
||||
commit_oid = (git.commit_oid if git else None) or commit.get("commit_oid")
|
||||
if not commit_oid or not post or (before and post == before):
|
||||
findings.append(Finding(
|
||||
"TERMINAL_SUCCESS_UNPROVEN",
|
||||
Severity.BLOCKER,
|
||||
"Task reports success without a proven branch mutation receipt.",
|
||||
{"commit_oid": commit_oid, "post_oid": post, "pre_oid": before},
|
||||
"SUCCEEDED requires lease id, commit receipt, pre/post OID change, output hashes, and runtime/projection readback.",
|
||||
))
|
||||
return TruthState.TERMINAL_UNPROVEN, findings
|
||||
if not ev.runtime_readback:
|
||||
findings.append(Finding("SUCCESS_WITHOUT_RUNTIME_READBACK", Severity.MAJOR, "Commit exists but runtime readback is absent."))
|
||||
return TruthState.COMPLETE if not findings else TruthState.TERMINAL_UNPROVEN, findings
|
||||
|
||||
if task.status == "EXPIRED":
|
||||
if task.attempt in (None, 0) and not task.claimed_by:
|
||||
findings.append(Finding(
|
||||
"EXPIRED_WITHOUT_ANY_CLAIM",
|
||||
Severity.MAJOR,
|
||||
"Task expired without a single execution attempt.",
|
||||
{"attempt": task.attempt, "claimed_by": task.claimed_by},
|
||||
"Treat as schedulability failure, not execution failure; fix worker/principal supply before recovery.",
|
||||
))
|
||||
return TruthState.EXPIRED, findings
|
||||
|
||||
return TruthState.UNKNOWN, findings
|
||||
|
||||
def inspect_artifact(path: Path, expected_sha256: str | None = None, min_bytes: int = 8) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
if not path.is_file():
|
||||
return [Finding("ARTIFACT_MISSING", Severity.BLOCKER, f"Artifact missing: {path}")]
|
||||
data = path.read_bytes()
|
||||
if len(data) < min_bytes:
|
||||
findings.append(Finding("ARTIFACT_TOO_SMALL", Severity.BLOCKER, f"Artifact is too small: {path}", {"bytes": len(data)}))
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
if expected_sha256 and digest != expected_sha256:
|
||||
findings.append(Finding("ARTIFACT_HASH_MISMATCH", Severity.BLOCKER, f"Artifact hash mismatch: {path}", {"expected": expected_sha256, "actual": digest}))
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = ""
|
||||
if text and any(p.search(text) for p in PLACEHOLDER_PATTERNS):
|
||||
findings.append(Finding(
|
||||
"PLACEHOLDER_ARTIFACT",
|
||||
Severity.BLOCKER,
|
||||
f"Artifact contains placeholder/stub markers: {path}",
|
||||
remediation="Reject plan admission until actual bytes exist and match the declared digest.",
|
||||
))
|
||||
return findings
|
||||
|
||||
def operation_ids_from_openapi(path: Path) -> dict[str, str]:
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("PyYAML is required for OpenAPI parity checks") from exc
|
||||
doc = yaml.safe_load(path.read_text(encoding="utf-8-sig"))
|
||||
result: dict[str, str] = {}
|
||||
for route, methods in (doc.get("paths") or {}).items():
|
||||
if not isinstance(methods, dict):
|
||||
continue
|
||||
for _, spec in methods.items():
|
||||
if isinstance(spec, dict) and spec.get("operationId"):
|
||||
result[str(spec["operationId"])] = str(route)
|
||||
return result
|
||||
|
||||
ROUTE_PAIR_RE = re.compile(r'["\'](?P<route>/v\d+/[^"\']+)["\']\s*:\s*["\'](?P<op>[A-Za-z0-9_]+)["\']')
|
||||
|
||||
def operation_ids_from_server_source(path: Path) -> dict[str, str]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
out: dict[str, str] = {}
|
||||
for m in ROUTE_PAIR_RE.finditer(text):
|
||||
out[m.group("op")] = m.group("route")
|
||||
return out
|
||||
|
||||
def check_openapi_backend_parity(openapi_path: Path, server_path: Path, required_ops: Iterable[str] = ()) -> list[Finding]:
|
||||
oa = operation_ids_from_openapi(openapi_path)
|
||||
be = operation_ids_from_server_source(server_path)
|
||||
findings: list[Finding] = []
|
||||
for op in sorted(set(required_ops) | set(oa)):
|
||||
if op in oa and op not in be:
|
||||
findings.append(Finding(
|
||||
"OPENAPI_DEAD_ENDPOINT",
|
||||
Severity.BLOCKER,
|
||||
f"OpenAPI advertises {op} ({oa[op]}) but backend route mapping is absent.",
|
||||
remediation="Deploy projection and backend from the same source commit; reject schema-only rollout.",
|
||||
))
|
||||
for op in sorted(set(required_ops) | set(be)):
|
||||
if op in be and op not in oa:
|
||||
findings.append(Finding(
|
||||
"BACKEND_UNPROJECTED_OPERATION",
|
||||
Severity.MAJOR,
|
||||
f"Backend implements {op} ({be[op]}) but OpenAPI projection is absent.",
|
||||
remediation="Generate/validate external projection from the canonical backend route manifest.",
|
||||
))
|
||||
return findings
|
||||
|
||||
def check_capability_aliases(required: Sequence[str], workers: Sequence[WorkerSnapshot]) -> list[Finding]:
|
||||
findings: list[Finding] = []
|
||||
req = set(required)
|
||||
if "governed.write.files" in req:
|
||||
for w in workers:
|
||||
caps = set(w.capabilities)
|
||||
if "governed.write.file" in caps and "governed.write.files" not in caps:
|
||||
findings.append(Finding(
|
||||
"CAPABILITY_SINGULAR_PLURAL_ASYMMETRY",
|
||||
Severity.MAJOR,
|
||||
f"Worker {w.worker_id} exposes governed.write.file but task requires governed.write.files.",
|
||||
{"worker_capabilities": sorted(caps)},
|
||||
"Do not treat names as aliases implicitly; define one canonical capability or an explicit server-side compatibility mapping with tests.",
|
||||
))
|
||||
return findings
|
||||
|
||||
def git_ref(git_dir: Path, ref: str) -> str | None:
|
||||
cp = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "rev-parse", ref],
|
||||
text=True, capture_output=True
|
||||
)
|
||||
if cp.returncode != 0:
|
||||
return None
|
||||
value = cp.stdout.strip()
|
||||
return value if re.fullmatch(r"[0-9a-f]{40}", value) else None
|
||||
|
||||
def sqlite_rows(path: Path, query: str, params: Sequence[Any] = ()) -> list[sqlite3.Row]:
|
||||
uri = f"file:{path}?mode=ro&immutable=1"
|
||||
con = sqlite3.connect(uri, uri=True)
|
||||
con.row_factory = sqlite3.Row
|
||||
try:
|
||||
return list(con.execute(query, params))
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
def dump_report(path: Path, report: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n", encoding="utf-8")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Separate read, write, and release plane readiness evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeStatus:
|
||||
read_plane: Mapping[str, bool]
|
||||
write_plane: Mapping[str, bool]
|
||||
release_plane: Mapping[str, bool]
|
||||
|
||||
@property
|
||||
def release_control_plane_ready(self) -> bool:
|
||||
return all(self.read_plane.values()) and all(self.write_plane.values()) and all(self.release_plane.values())
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"READ_PLANE": dict(self.read_plane),
|
||||
"WRITE_PLANE": dict(self.write_plane),
|
||||
"RELEASE_PLANE": dict(self.release_plane),
|
||||
"release_control_plane_ready": self.release_control_plane_ready,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Workspace-independent authority/network/Gitea ref sentinel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Callable, Mapping
|
||||
|
||||
|
||||
class SentinelError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentinelInputs:
|
||||
authority_git_dir: Path
|
||||
gitea_remote: str
|
||||
gitea_api: str
|
||||
ref: str
|
||||
expected_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentinelResult:
|
||||
authority_oid: str | None
|
||||
network_oid: str | None
|
||||
gitea_oid: str | None
|
||||
equal: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def authority_oid(inputs: SentinelInputs) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(inputs.authority_git_dir), "rev-parse", inputs.ref],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SentinelError("AUTHORITY_REF_UNAVAILABLE")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def evaluate(
|
||||
inputs: SentinelInputs,
|
||||
*,
|
||||
network_reader: Callable[[str, str], str | None],
|
||||
gitea_reader: Callable[[str, str], str | None],
|
||||
) -> SentinelResult:
|
||||
try:
|
||||
local = authority_oid(inputs)
|
||||
network = network_reader(inputs.gitea_remote, inputs.ref)
|
||||
gitea = gitea_reader(inputs.gitea_api, inputs.ref)
|
||||
equal = local == network == gitea == inputs.expected_oid
|
||||
return SentinelResult(local, network, gitea, equal, None if equal else "OID_MISMATCH")
|
||||
except SentinelError as exc:
|
||||
return SentinelResult(None, None, None, False, str(exc))
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Non-sensitive control-plane snapshot assembly.
|
||||
|
||||
The base snapshot is a pure field-copy of each plane's reported state. It does
|
||||
NOT itself assert whether that state is backed by evidence — a drift_status=PASS
|
||||
or an ADMITTED status is a *claim*, not a proof alert/status change.
|
||||
|
||||
`attach_control_plane_truth` (see below) injects the read-only derived `SystemTruth`
|
||||
(states + findings) from `aios_control_plane_consistency.derive_truth` into the
|
||||
snapshot so that explain/status surfaces can expose *derived* truth alongside
|
||||
the reported state, without ever mutating the persistence planes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_control_plane_consistency import (
|
||||
AdmissionSnapshot,
|
||||
EvidenceSnapshot,
|
||||
GitSnapshot,
|
||||
LeaseSnapshot,
|
||||
TaskSnapshot,
|
||||
WorkerSnapshot,
|
||||
derive_truth,
|
||||
)
|
||||
|
||||
|
||||
def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
value = value or {}
|
||||
return {key: value.get(key) for key in keys}
|
||||
|
||||
|
||||
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 {
|
||||
"version": {"target_release": target_version},
|
||||
"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")),
|
||||
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
||||
"task": task_snapshot,
|
||||
"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")),
|
||||
"secret_material_exposed": False,
|
||||
}
|
||||
|
||||
|
||||
def getControlPlaneSnapshot(**kwargs: Any) -> dict[str, Any]:
|
||||
return build_control_plane_snapshot(**kwargs)
|
||||
|
||||
|
||||
def attach_control_plane_truth(
|
||||
snapshot: Mapping[str, Any],
|
||||
*,
|
||||
admission: AdmissionSnapshot | None = None,
|
||||
task: TaskSnapshot | None = None,
|
||||
workers: Sequence[WorkerSnapshot] = (),
|
||||
lease: LeaseSnapshot | None = None,
|
||||
git: GitSnapshot | None = None,
|
||||
evidence: EvidenceSnapshot | None = None,
|
||||
now: int | None = None,
|
||||
queue_claim_threshold: int = 600,
|
||||
heartbeat_ttl: int = 180,
|
||||
) -> dict[str, Any]:
|
||||
"""Attach read-only derived SystemTruth to a control-plane snapshot.
|
||||
|
||||
This never writes to any persistence plane. It derives a ``system_truth``
|
||||
block from the already-loaded plane snapshots and adds it under
|
||||
``snapshot["derived"]``, leaving all existing keys intact.
|
||||
|
||||
The block includes the derived truth state, any findings, and a compact
|
||||
"equivalence" guard (see config/gitops/system_truth_contract.json
|
||||
``forbidden_equivalences``) so surfaces cannot silently present a local
|
||||
plane claim as end-to-end evidence.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
truth_state, findings = derive_truth(
|
||||
admission, task, workers, lease, git, evidence, now,
|
||||
queue_claim_threshold=queue_claim_threshold,
|
||||
heartbeat_ttl=heartbeat_ttl,
|
||||
)
|
||||
out = dict(snapshot)
|
||||
if "derived" not in out:
|
||||
out["derived"] = {}
|
||||
blockers = [f for f in findings if f.severity.value == "BLOCKER"]
|
||||
out["derived"]["system_truth"] = {
|
||||
"truth_state": truth_state.value,
|
||||
"findings": [asdict(f) for f in findings],
|
||||
"blocker_count": sum(1 for f in findings if f.severity.value == "BLOCKER"),
|
||||
"major_count": sum(1 for f in findings if f.severity.value == "MAJOR"),
|
||||
# Derivation is read-only; it never confirms completion on its own.
|
||||
"evidence_flow_confirmed": truth_state.value == "COMPLETE" and not blockers,
|
||||
}
|
||||
return out
|
||||
@@ -0,0 +1,478 @@
|
||||
"""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}"
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Metadata-only, server-side credential lease broker."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialLease:
|
||||
lease_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
scopes: tuple[str, ...]
|
||||
provider: str
|
||||
state: str = "ACTIVE"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state}
|
||||
|
||||
|
||||
class FileCredentialProvider:
|
||||
def __init__(self, token_path: str | os.PathLike[str], *, actor: str = "aios-autonomy-agent") -> None:
|
||||
self.token_path = Path(token_path)
|
||||
self.actor = actor
|
||||
|
||||
def environment(self, lease: CredentialLease) -> dict[str, str]:
|
||||
if not self.token_path.is_file():
|
||||
raise RuntimeError("CREDENTIAL_PROVIDER_UNAVAILABLE")
|
||||
return {"CREDENTIAL_FILE": str(self.token_path), "GITEA_MACHINE_ACTOR": self.actor}
|
||||
|
||||
|
||||
class CredentialBroker:
|
||||
def __init__(self, provider: Any, *, now: Callable[[], datetime] | None = None) -> None:
|
||||
self.provider = provider
|
||||
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||
self._leases: dict[str, CredentialLease] = {}
|
||||
|
||||
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900) -> dict[str, Any]:
|
||||
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
||||
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
||||
now = self.now().astimezone(timezone.utc)
|
||||
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__)
|
||||
self._leases[lease.lease_id] = lease
|
||||
return lease.as_dict()
|
||||
|
||||
def _get(self, lease_id: str) -> CredentialLease:
|
||||
lease = self._leases.get(lease_id)
|
||||
if lease is None:
|
||||
raise KeyError("CREDENTIAL_LEASE_NOT_FOUND")
|
||||
if lease.state == "ACTIVE" and self.now().astimezone(timezone.utc) >= datetime.fromisoformat(lease.expires_at):
|
||||
lease = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "EXPIRED"})
|
||||
self._leases[lease_id] = lease
|
||||
return lease
|
||||
|
||||
def environment(self, lease_id: str) -> Mapping[str, str]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state != "ACTIVE":
|
||||
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
||||
return self.provider.environment(lease)
|
||||
|
||||
def status(self, lease_id: str) -> dict[str, Any]:
|
||||
return self._get(lease_id).as_dict()
|
||||
|
||||
def revoke(self, lease_id: str) -> dict[str, Any]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state == "REVOKED":
|
||||
return lease.as_dict()
|
||||
updated = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "REVOKED"})
|
||||
self._leases[lease_id] = updated
|
||||
return updated.as_dict()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Non-Owner Gitea machine capability and branch reconciliation boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
|
||||
class MachineBrokerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class GiteaTransport(Protocol):
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str: ...
|
||||
def create_or_update_pr(self, branch: str, target: str, title: str, body: str) -> Mapping[str, Any]: ...
|
||||
def publish_status(self, branch: str, state: str, description: str) -> Mapping[str, Any]: ...
|
||||
def protected_merge(self, pr_number: int) -> Mapping[str, Any]: ...
|
||||
def read_branch(self, branch: str) -> str | None: ...
|
||||
def whoami(self) -> Mapping[str, Any]: ...
|
||||
def repository(self, owner: str, name: str) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCredentialProjection:
|
||||
"""Opaque systemd credential reference; the credential value is never accepted."""
|
||||
|
||||
reference: str
|
||||
account: str
|
||||
owner_account: str = "aiman"
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "MachineCredentialProjection":
|
||||
if set(value) != {"scheme", "name", "account"}:
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
if value["scheme"] != "systemd-credential" or value["name"] != "gitea-machine-token":
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
return cls(f"{value['scheme']}:{value['name']}", str(value["account"]))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.reference != "systemd-credential:gitea-machine-token":
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
if not self.account or self.account == self.owner_account:
|
||||
raise MachineBrokerError("OWNER_MACHINE_IDENTITY_REJECTED")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignCapabilityReceipt:
|
||||
campaign_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
expected_oid: str
|
||||
capabilities: frozenset[str]
|
||||
denied: frozenset[str]
|
||||
|
||||
def require(self, capability: str) -> None:
|
||||
if capability in self.denied or capability not in self.capabilities:
|
||||
raise MachineBrokerError("CAPABILITY_DENIED")
|
||||
if self.branch in {"main", "master"}:
|
||||
raise MachineBrokerError("PROTECTED_BRANCH_DENIED")
|
||||
|
||||
|
||||
class GiteaMachineBroker:
|
||||
def __init__(self, transport: GiteaTransport, credential: MachineCredentialProjection, receipt: CampaignCapabilityReceipt) -> None:
|
||||
self.transport = transport
|
||||
self.credential = credential
|
||||
self.receipt = receipt
|
||||
|
||||
def verify_identity(self) -> Mapping[str, Any]:
|
||||
identity = dict(self.transport.whoami())
|
||||
login = identity.get("login") or identity.get("username")
|
||||
if not isinstance(login, str) or not login or login == self.credential.owner_account or login != self.credential.account:
|
||||
raise MachineBrokerError("GITEA_MACHINE_IDENTITY_INVALID")
|
||||
owner, name = self.receipt.repository.split("/", 1)
|
||||
repo = dict(self.transport.repository(owner, name))
|
||||
if repo.get("full_name") != self.receipt.repository:
|
||||
raise MachineBrokerError("GITEA_REPOSITORY_MISMATCH")
|
||||
return {"login": login, "repository": self.receipt.repository, "owner_account": self.credential.owner_account}
|
||||
|
||||
def push_branch(self, new_oid: str) -> str:
|
||||
self.receipt.require("exact_task_branch_push")
|
||||
return self.transport.compare_and_swap_branch(self.receipt.branch, self.receipt.expected_oid, new_oid)
|
||||
|
||||
def create_pr(self, title: str, body: str) -> Mapping[str, Any]:
|
||||
self.receipt.require("pr_create_update")
|
||||
return self.transport.create_or_update_pr(self.receipt.branch, "main", title, body)
|
||||
|
||||
def status(self, state: str, description: str, context: str = "aios/durable-release") -> Mapping[str, Any]:
|
||||
self.receipt.require("status_publish")
|
||||
return self.transport.publish_status(self.receipt.branch, state, description, context)
|
||||
|
||||
def merge(self, pr_number: int) -> Mapping[str, Any]:
|
||||
self.receipt.require("protected_merge")
|
||||
return self.transport.protected_merge(pr_number)
|
||||
|
||||
def readback(self) -> str | None:
|
||||
self.receipt.require("remote_readback")
|
||||
return self.transport.read_branch(self.receipt.branch)
|
||||
|
||||
|
||||
def reconcile_exact_branch(authority_oid: str, gitea_oid: str | None, expected_oid: str) -> str:
|
||||
if authority_oid != expected_oid:
|
||||
return "AUTHORITY_EXPECTATION_MISMATCH"
|
||||
if gitea_oid is None:
|
||||
return "CREATE_REQUIRED"
|
||||
if gitea_oid == expected_oid:
|
||||
return "ALREADY_IN_SYNC"
|
||||
return "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
|
||||
|
||||
def reconcile_fast_forward(
|
||||
remote_oid: str | None,
|
||||
expected_old_oid: str,
|
||||
new_oid: str,
|
||||
*,
|
||||
old_is_ancestor_of_new: bool,
|
||||
) -> str:
|
||||
"""Return a bounded action for an active Job/lease branch scope."""
|
||||
if remote_oid is None:
|
||||
return "CREATE_REQUIRED"
|
||||
if remote_oid == new_oid:
|
||||
return "ALREADY_IN_SYNC"
|
||||
if remote_oid == expected_old_oid and old_is_ancestor_of_new:
|
||||
return "FAST_FORWARD_REQUIRED"
|
||||
return "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Gitea HTTP/Git transport using only a projected systemd credential."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
class GiteaTransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
|
||||
raise GiteaTransportError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_branch(value: str) -> str:
|
||||
branch = str(value or "")
|
||||
if (
|
||||
not branch
|
||||
or len(branch) > 240
|
||||
or branch != branch.strip()
|
||||
or branch.startswith(("/", "-", "."))
|
||||
or branch.endswith(("/", ".", ".lock"))
|
||||
or ".." in branch
|
||||
or "//" in branch
|
||||
or "@{" in branch
|
||||
or "\\" in branch
|
||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
||||
or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._/-" for ch in branch)
|
||||
):
|
||||
raise GiteaTransportError("BRANCH_INVALID")
|
||||
return branch
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaMachineTransport:
|
||||
api_base: str
|
||||
repository_id: str
|
||||
remote_url: str
|
||||
authority_git_dir: Path
|
||||
credential_name: str = "gitea-machine-token"
|
||||
actor: str = "aios-autonomy-agent"
|
||||
timeout: int = 20
|
||||
expected_merge_head: str | None = None
|
||||
expected_merge_base: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.credential_name != "gitea-machine-token":
|
||||
raise GiteaTransportError("CREDENTIAL_NAME_INVALID")
|
||||
if "/" not in self.repository_id or self.repository_id.startswith("/"):
|
||||
raise GiteaTransportError("REPOSITORY_INVALID")
|
||||
if not self.api_base.startswith("https://") and not self.api_base.startswith("http://127.0.0.1"):
|
||||
raise GiteaTransportError("GITEA_API_SCOPE_INVALID")
|
||||
if not self.remote_url.startswith("https://"):
|
||||
raise GiteaTransportError("GITEA_REMOTE_SCOPE_INVALID")
|
||||
|
||||
@property
|
||||
def _credential_path(self) -> Path:
|
||||
directory = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
||||
if not directory:
|
||||
raise GiteaTransportError("CREDENTIAL_PROJECTION_MISSING")
|
||||
path = Path(directory) / self.credential_name
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise GiteaTransportError("CREDENTIAL_PROJECTION_MISSING")
|
||||
return path
|
||||
|
||||
def _token(self) -> str:
|
||||
value = self._credential_path.read_text(encoding="utf-8").strip()
|
||||
if not value:
|
||||
raise GiteaTransportError("CREDENTIAL_EMPTY")
|
||||
return value
|
||||
|
||||
def _request(self, method: str, path: str, body: Mapping[str, Any] | None = None) -> tuple[int, Any]:
|
||||
data = None if body is None else json.dumps(dict(body), separators=(",", ":")).encode()
|
||||
headers = {"Accept": "application/json", "Authorization": f"token {self._token()}"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self.api_base.rstrip("/") + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as exc:
|
||||
# Never include response bodies: Gitea may echo sensitive request context.
|
||||
raise GiteaTransportError(f"GITEA_HTTP_{exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
raise GiteaTransportError("GITEA_NETWORK_ERROR") from exc
|
||||
|
||||
def whoami(self) -> Mapping[str, Any]:
|
||||
return self._request("GET", "/user")[1]
|
||||
|
||||
def repository_info(self) -> Mapping[str, Any]:
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("GET", f"/repos/{quote(owner)}/{quote(name)}")[1]
|
||||
|
||||
def repository(self, owner: str, name: str) -> Mapping[str, Any]:
|
||||
if f"{owner}/{name}" != self.repository_id:
|
||||
raise GiteaTransportError("REPOSITORY_SCOPE_DENIED")
|
||||
return self.repository_info()
|
||||
|
||||
def read_branch(self, branch: str) -> str | None:
|
||||
branch = _safe_branch(branch)
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
try:
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/branches/{quote(branch, safe='/')}")[1]
|
||||
except GiteaTransportError as exc:
|
||||
if str(exc) == "GITEA_HTTP_404":
|
||||
return None
|
||||
raise
|
||||
sha = value.get("commit", {}).get("id") if isinstance(value, dict) else None
|
||||
return _safe_oid(sha, "remote_branch_oid")
|
||||
|
||||
def _askpass(self) -> tuple[Path, dict[str, str]]:
|
||||
credential = self._credential_path
|
||||
fd, name = tempfile.mkstemp(prefix="aios-gitea-askpass-", dir="/tmp")
|
||||
path = Path(name)
|
||||
os.fchmod(fd, 0o700)
|
||||
script = "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$GITEA_MACHINE_ACTOR\" ;; *) cat \"$CREDENTIAL_FILE\" ;; esac\n"
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
stream.write(script)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
return path, {
|
||||
"GIT_ASKPASS": str(path),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GITEA_MACHINE_ACTOR": self.actor,
|
||||
"CREDENTIAL_FILE": str(credential),
|
||||
}
|
||||
|
||||
def _push_branch_fast_forward(self, branch: str, new_oid: str) -> None:
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.authority_git_dir}", "push", self.remote_url, f"{new_oid}:refs/heads/{branch}"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CREATE_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
def fetch_branch_exact(self, branch: str, expected_oid: str) -> str:
|
||||
"""Fetch one exact remote branch tip into a quarantine ref.
|
||||
|
||||
This never updates ``main`` or any caller-visible branch. The remote
|
||||
ref is read before and after the fetch so a concurrent branch move
|
||||
cannot be silently admitted.
|
||||
"""
|
||||
|
||||
branch = _safe_branch(branch)
|
||||
expected_oid = _safe_oid(expected_oid, "expected_oid")
|
||||
remote_before = self.read_branch(branch)
|
||||
if remote_before != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MISMATCH")
|
||||
target_ref = f"refs/aios/release-sources/{expected_oid}"
|
||||
|
||||
current = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if current.returncode == 0 and current.stdout.strip().lower() == expected_oid:
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
self.remote_url,
|
||||
f"refs/heads/{branch}:{target_ref}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
local = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
local_oid = local.stdout.strip().lower()
|
||||
if local.returncode != 0 or local_oid != expected_oid:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"update-ref",
|
||||
"-d",
|
||||
target_ref,
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_MISMATCH")
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
_safe_oid(expected_oid, "expected_oid")
|
||||
_safe_oid(new_oid, "new_oid")
|
||||
current = self.read_branch(branch)
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
if current is None:
|
||||
self._push_branch_fast_forward(branch, new_oid)
|
||||
readback = self.read_branch(branch)
|
||||
if readback != new_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CREATE_READBACK_MISMATCH")
|
||||
return readback
|
||||
if current != expected_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||
# The preflight read binds the expected old tip. The Git receive-pack
|
||||
# then enforces the actual fast-forward CAS while transferring the new
|
||||
# objects; no force/lease override or arbitrary ref is permitted.
|
||||
self._push_branch_fast_forward(branch, new_oid)
|
||||
readback = self.read_branch(branch)
|
||||
if readback != new_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def _pulls(self) -> list[Mapping[str, Any]]:
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
query = urlencode({"state": "all", "base_branch": "main", "limit": 100})
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/pulls?{query}")[1]
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
def create_or_update_pr(self, branch: str, target: str, title: str, body: str) -> Mapping[str, Any]:
|
||||
if target != "main":
|
||||
raise GiteaTransportError("PR_BASE_SCOPE_DENIED")
|
||||
head = self.read_branch(branch)
|
||||
if head is None:
|
||||
raise GiteaTransportError("PR_HEAD_MISSING")
|
||||
matches = [p for p in self._pulls() if (p.get("head") or {}).get("ref") == branch and (p.get("base") or {}).get("ref") == target]
|
||||
open_matches = [p for p in matches if p.get("state") == "open"]
|
||||
if len(open_matches) > 1:
|
||||
raise GiteaTransportError("MULTIPLE_MATCHING_PRS")
|
||||
if open_matches:
|
||||
if (open_matches[0].get("head") or {}).get("sha") != head:
|
||||
raise GiteaTransportError("PR_HEAD_CONFLICT")
|
||||
return open_matches[0]
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/pulls", {"head": branch, "base": target, "title": title, "body": body, "allow_maintainer_edit": False})[1]
|
||||
|
||||
def publish_status(self, branch: str, state: str, description: str, context: str = "aios/durable-release") -> Mapping[str, Any]:
|
||||
oid = self.read_branch(branch)
|
||||
if oid is None:
|
||||
raise GiteaTransportError("STATUS_BRANCH_MISSING")
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/statuses/{oid}", {"state": state, "context": context, "description": description})[1]
|
||||
|
||||
def protected_merge(self, pr_number: int) -> Mapping[str, Any]:
|
||||
if not isinstance(pr_number, int) or pr_number < 1:
|
||||
raise GiteaTransportError("PR_NUMBER_INVALID")
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
pr = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/pulls/{pr_number}")[1]
|
||||
if pr.get("state") != "open" or (pr.get("base") or {}).get("ref") != "main":
|
||||
raise GiteaTransportError("PR_MERGE_BINDING_INVALID")
|
||||
if pr.get("mergeable") is False:
|
||||
raise GiteaTransportError("PR_NOT_MERGEABLE")
|
||||
head_oid = (pr.get("head") or {}).get("sha")
|
||||
base_oid = (pr.get("base") or {}).get("sha")
|
||||
if self.expected_merge_head is not None and head_oid != self.expected_merge_head:
|
||||
raise GiteaTransportError("PR_HEAD_EXPECTATION_MISMATCH")
|
||||
if self.expected_merge_base is not None and base_oid != self.expected_merge_base:
|
||||
raise GiteaTransportError("PR_BASE_EXPECTATION_MISMATCH")
|
||||
body = {"do": "fast-forward-only", "force_merge": False, "merge_when_checks_succeed": False, "head_commit_id": head_oid}
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/pulls/{pr_number}/merge", body)[1]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Canonical Gitea transport and slash-branch verification."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_gitops_reconciliation_core import RemoteObservation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GiteaPublisherConfig:
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
remote_name: str = "gitea-http"
|
||||
|
||||
|
||||
class GiteaPublisher:
|
||||
def __init__(self, workspace: Path, *, config: GiteaPublisherConfig = GiteaPublisherConfig(), credential: Any = None, api_get: Callable[[str], Any] | None = None, git_ref: Callable[[str], Optional[str]] | None = None) -> None:
|
||||
self.workspace = Path(workspace)
|
||||
self.config = config
|
||||
self.credential = credential
|
||||
self._api_get = api_get
|
||||
self._git_ref = git_ref
|
||||
|
||||
@staticmethod
|
||||
def validate_repository(repository: str) -> None:
|
||||
if repository != "aios/aios":
|
||||
raise ValueError("REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
@staticmethod
|
||||
def validate_branch(branch: str) -> None:
|
||||
if not isinstance(branch, str) or not branch.startswith("agent/") or branch in {"main", "master"}:
|
||||
raise ValueError("BRANCH_SCOPE_DENIED")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
self.validate_repository(self.config.repository)
|
||||
return self.config.repository
|
||||
|
||||
def _environment(self) -> dict[str, str]:
|
||||
if self.credential is not None and hasattr(self.credential, "environment"):
|
||||
return dict(self.credential.environment())
|
||||
return {}
|
||||
|
||||
def read_git_ref(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
if self._git_ref is not None:
|
||||
return self._git_ref(branch)
|
||||
result = subprocess.run(["git", "-C", str(self.workspace), "ls-remote", self.config.remote_name, f"refs/heads/{branch}"], text=True, capture_output=True, check=False, env={**os.environ, **self._environment(), "GIT_TERMINAL_PROMPT": "0"})
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
return result.stdout.split()[0]
|
||||
|
||||
def _api(self, path: str) -> Any:
|
||||
if self._api_get is not None:
|
||||
return self._api_get(path)
|
||||
headers = {"Accept": "application/json"}
|
||||
env = self._environment()
|
||||
if env.get("AIOS_GITEA_TOKEN"):
|
||||
headers["Authorization"] = "token " + env["AIOS_GITEA_TOKEN"]
|
||||
with urlopen(Request(self.config.api_base.rstrip("/") + path, headers=headers), timeout=20) as response:
|
||||
return json.loads(response.read() or b"{}")
|
||||
|
||||
def read_api_branch(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
for page in range(1, 101):
|
||||
value = self._api(f"/repos/aios/aios/branches?page={page}&limit=50")
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
for item in value:
|
||||
if isinstance(item, Mapping) and item.get("name") == branch:
|
||||
commit = item.get("commit")
|
||||
return commit.get("id") if isinstance(commit, Mapping) else None
|
||||
if len(value) < 50:
|
||||
return None
|
||||
raise RuntimeError("GITEA_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
if not isinstance(oid, str) or len(oid) != 40:
|
||||
return False
|
||||
try:
|
||||
value = self._api(f"/repos/aios/aios/git/commits/{quote(oid, safe='')}")
|
||||
return isinstance(value, Mapping) and value.get("sha", oid) == oid
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def observe(self, branch: str, desired_oid: str, authority_oid: Optional[str], *, expected_oid: Optional[str] = None, observed_at: str = "") -> RemoteObservation:
|
||||
return RemoteObservation(self.repository_identity(), branch, desired_oid, authority_oid, self.read_git_ref(branch), self.read_api_branch(branch), self.commit_visible(desired_oid), observed_at, expected_oid)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,518 @@
|
||||
"""Durable Job side-effect executor for the AIOS campaign control plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor, AuthorityGitError
|
||||
from aios_campaign_recovery import RecoveryEvidenceError, load_recovered_authorization
|
||||
from aios_gitea_machine_broker import CampaignCapabilityReceipt, GiteaMachineBroker, MachineBrokerError
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore, ServiceUnavailable
|
||||
|
||||
|
||||
TASK_ID = "AIOS_AUTONOMOUS_RELEASE_CAMPAIGN_V1"
|
||||
CAMPAIGN_ID = "AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807"
|
||||
REPOSITORY = "aios/aios"
|
||||
BRANCH = "agent/autonomous-release-campaign-v1-20260807"
|
||||
EXPECTED_BRANCH_OID = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
AUTHORIZATION_DIGEST = "6b0bf550eb702126e967eeff2d561fa808444b924d8a71f5399accad508ed3b2"
|
||||
LINEAGE_ANCHOR = "69b45e81dcd2c3707c367003b08c55fd361faae8"
|
||||
|
||||
|
||||
class JobExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DurableJobExecutor:
|
||||
controller: GitOpsJobController
|
||||
authority: AuthorityGitExecutor
|
||||
broker: GiteaMachineBroker
|
||||
recovery_path: Path
|
||||
clock: Callable[[], datetime] = field(default=lambda: datetime.now(timezone.utc))
|
||||
lease_seconds: int = 60
|
||||
_persistence_halted: bool = field(default=False, init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
self.controller.assert_effect_admission()
|
||||
except JobError as exc:
|
||||
if str(exc) != "EFFECT_ADMISSION_LATCHED":
|
||||
raise
|
||||
self._persistence_halted = True
|
||||
|
||||
def _request(self, job_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = self.controller.store.read(job_id)
|
||||
try:
|
||||
request = json.loads(row["request_json"])
|
||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise JobExecutionError("JOB_REQUEST_INVALID") from exc
|
||||
binding = dict(request.get("binding") or {})
|
||||
# Payload location is derived from and protected by payload_digest, but
|
||||
# is intentionally outside the idempotency binding. Execution still
|
||||
# needs it after a durable round trip.
|
||||
for field_name in ("payload_ref", "payload_size"):
|
||||
if field_name in request:
|
||||
binding[field_name] = request[field_name]
|
||||
return row, binding
|
||||
|
||||
def _validate_campaign(self, request: Mapping[str, Any]) -> str:
|
||||
operation = str(request.get("operation", ""))
|
||||
expected_branch_oid = request.get("expected_branch_oid")
|
||||
required = {
|
||||
"task_id": TASK_ID,
|
||||
"campaign_id": CAMPAIGN_ID,
|
||||
"repository": REPOSITORY,
|
||||
"branch": BRANCH,
|
||||
"authorization_recovery_digest": AUTHORIZATION_DIGEST,
|
||||
}
|
||||
for key, expected in required.items():
|
||||
if request.get(key) != expected:
|
||||
raise JobExecutionError(f"JOB_{key.upper()}_MISMATCH")
|
||||
new_oid = request.get("new_branch_oid")
|
||||
if not isinstance(new_oid, str) or len(new_oid) != 40 or any(ch not in "0123456789abcdef" for ch in new_oid):
|
||||
raise JobExecutionError("NEW_BRANCH_OID_INVALID")
|
||||
if not isinstance(expected_branch_oid, str) or len(expected_branch_oid) != 40 or any(ch not in "0123456789abcdef" for ch in expected_branch_oid):
|
||||
raise JobExecutionError("EXPECTED_BRANCH_OID_INVALID")
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH" and expected_branch_oid == new_oid:
|
||||
raise JobExecutionError("EXPECTED_BRANCH_OID_INVALID")
|
||||
if operation != "ADVANCE_EXISTING_CAMPAIGN_BRANCH" and expected_branch_oid != new_oid:
|
||||
raise JobExecutionError("POST_ADVANCE_HEAD_BINDING_INVALID")
|
||||
try:
|
||||
recovery = load_recovered_authorization(self.recovery_path)
|
||||
except RecoveryEvidenceError as exc:
|
||||
raise JobExecutionError(str(exc)) from exc
|
||||
if recovery["digest"] != AUTHORIZATION_DIGEST:
|
||||
raise JobExecutionError("RECOVERY_DIGEST_MISMATCH")
|
||||
for key, expected in required.items():
|
||||
if key == "campaign_id":
|
||||
actual = recovery.get(key)
|
||||
elif key == "authorization_recovery_digest":
|
||||
continue
|
||||
else:
|
||||
actual = recovery.get({"expected_branch_oid": "expected_branch_oid"}.get(key, key))
|
||||
if actual is not None and actual != expected:
|
||||
raise JobExecutionError("RECOVERY_BINDING_MISMATCH")
|
||||
if recovery.get("expected_branch_oid") != EXPECTED_BRANCH_OID:
|
||||
raise JobExecutionError("RECOVERY_BRANCH_OID_MISMATCH")
|
||||
authority_head = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
if authority_head not in {expected_branch_oid, new_oid}:
|
||||
raise JobExecutionError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
if not self.authority.ancestry_check(expected_branch_oid, new_oid):
|
||||
raise JobExecutionError("NEW_OID_NOT_DESCENDANT")
|
||||
if not self.authority.ancestry_check(EXPECTED_BRANCH_OID, new_oid):
|
||||
raise JobExecutionError("NEW_OID_OUTSIDE_AUTHORIZED_LINEAGE")
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
old_main = request.get("expected_main_oid")
|
||||
if (
|
||||
not isinstance(old_main, str)
|
||||
or len(old_main) != 40
|
||||
or any(ch not in "0123456789abcdef" for ch in old_main)
|
||||
):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
if not self.authority.ancestry_check(old_main, new_oid):
|
||||
raise JobExecutionError("MAIN_OID_NOT_DESCENDANT")
|
||||
identity = self.broker.verify_identity()
|
||||
if identity.get("repository") != REPOSITORY or identity.get("login") == "aiman":
|
||||
raise JobExecutionError("MACHINE_IDENTITY_SCOPE_INVALID")
|
||||
return new_oid
|
||||
|
||||
def dry_run(self, job_id: str) -> dict[str, Any]:
|
||||
row, request = self._request(job_id)
|
||||
operation = str(request.get("operation") or "")
|
||||
if operation not in {
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH", "COMMIT_FILE",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA", "CREATE_PR", "PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE", "RECONCILE_AUTHORITY_MAIN",
|
||||
}:
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
self._validate_campaign(request)
|
||||
if operation == "COMMIT_FILE":
|
||||
self._commit_file_spec(row, request)
|
||||
observation = self._observe_operation(request)
|
||||
result = {
|
||||
"mode": "DRY_RUN",
|
||||
"operation": operation,
|
||||
"decision": "SAFE_TO_ADVANCE",
|
||||
"effect_performed": False,
|
||||
"readback": observation,
|
||||
}
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
result.update({
|
||||
"authority_action": "CAS_REQUIRED",
|
||||
"gitea_action": "ALREADY_IN_SYNC" if observation["verification"] == "VERIFIED" else "FAST_FORWARD_REQUIRED",
|
||||
"previous_branch_oid": request["expected_branch_oid"],
|
||||
"final_branch_oid": request["new_branch_oid"],
|
||||
"force_push": False,
|
||||
})
|
||||
return result
|
||||
|
||||
def execute(self, job_id: str, *, dry_run: bool = False) -> dict[str, Any]:
|
||||
if dry_run:
|
||||
return self.dry_run(job_id)
|
||||
if self._persistence_halted:
|
||||
raise JobExecutionError("PERSISTENCE_FAILURE_LATCHED")
|
||||
try:
|
||||
return self._execute_unprotected(job_id)
|
||||
except ServiceUnavailable:
|
||||
self._persistence_halted = True
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state in {
|
||||
JobState.EXECUTING, JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING, JobState.REMOTE_VERIFIED,
|
||||
}:
|
||||
self.controller.advance(
|
||||
job_id,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="UNKNOWN",
|
||||
receipt={"error_code": type(exc).__name__, "verification": "UNKNOWN"},
|
||||
)
|
||||
elif state not in {
|
||||
JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED,
|
||||
}:
|
||||
self.controller.advance(
|
||||
job_id, JobState.RECOVERABLE_FAILURE,
|
||||
side_effect_state="NONE",
|
||||
receipt={"error_code": type(exc).__name__, "effect_performed": False},
|
||||
)
|
||||
except ServiceUnavailable:
|
||||
self._persistence_halted = True
|
||||
raise
|
||||
|
||||
def _execute_unprotected(self, job_id: str) -> dict[str, Any]:
|
||||
row, request = self._request(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if state in {
|
||||
JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED,
|
||||
JobState.RECONCILING, JobState.EXECUTING, JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING, JobState.REMOTE_VERIFIED,
|
||||
}:
|
||||
return self.reconcile(job_id)
|
||||
|
||||
operation = str(request.get("operation") or "")
|
||||
if operation not in {
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH", "COMMIT_FILE",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA", "CREATE_PR", "PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE", "RECONCILE_AUTHORITY_MAIN",
|
||||
}:
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
try:
|
||||
self.controller.assert_effect_admission(exclude_job_id=job_id)
|
||||
except JobError as exc:
|
||||
if str(exc) == "EFFECT_ADMISSION_LATCHED":
|
||||
self._persistence_halted = True
|
||||
raise JobExecutionError("PERSISTENCE_FAILURE_LATCHED") from exc
|
||||
raise
|
||||
self._validate_campaign(request)
|
||||
if operation == "COMMIT_FILE":
|
||||
self._commit_file_spec(row, request)
|
||||
row = self._prepare_intent(job_id, row, request)
|
||||
generation = int(request.get("generation", "1"))
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
|
||||
before = self._observe_operation(request)
|
||||
if before["verification"] == "VERIFIED":
|
||||
return self._complete(job_id, request, {"reconciled_before_effect": True}, before)
|
||||
if before["verification"] != "ABSENT":
|
||||
return self._record_unknown(job_id, request, before)
|
||||
|
||||
effect = self._perform_effect(job_id, row, request, generation)
|
||||
after = self._observe_operation(request)
|
||||
if after["verification"] != "VERIFIED":
|
||||
return self._record_unknown(job_id, request, after, effect=effect)
|
||||
return self._complete(job_id, request, effect, after)
|
||||
|
||||
def _prepare_intent(
|
||||
self, job_id: str, row: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED",
|
||||
receipt={"authorization_digest": AUTHORIZATION_DIGEST, "task_id": TASK_ID},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.AUTHORIZED:
|
||||
deadline = self.clock().astimezone(timezone.utc) + timedelta(seconds=self.lease_seconds)
|
||||
self.controller.lease_job(
|
||||
job_id,
|
||||
lease_id=f"lease-{job_id}",
|
||||
lineage_anchor_oid=LINEAGE_ANCHOR,
|
||||
current_head_oid=str(request["expected_branch_oid"]),
|
||||
generation=int(request.get("generation", "1")),
|
||||
lease_expires_at=deadline.isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.LEASED:
|
||||
self.controller.advance(
|
||||
job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY",
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.WORKTREE_READY:
|
||||
self.controller.advance(
|
||||
job_id, JobState.EXECUTING, side_effect_state="INTENT_COMMITTED",
|
||||
receipt={
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"input_digest": row["payload_digest"],
|
||||
"intent_committed": True,
|
||||
},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) != JobState.EXECUTING:
|
||||
raise JobExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||
return dict(row)
|
||||
|
||||
def _perform_effect(
|
||||
self,
|
||||
job_id: str,
|
||||
row: Mapping[str, Any],
|
||||
request: Mapping[str, Any],
|
||||
generation: int,
|
||||
) -> dict[str, Any]:
|
||||
operation = str(request["operation"])
|
||||
new_oid = str(request["new_branch_oid"])
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
expected = str(request["expected_branch_oid"])
|
||||
current = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
authority_oid = new_oid if current == new_oid else self.authority.advance_task_branch_CAS(
|
||||
f"refs/heads/{BRANCH}", expected, new_oid
|
||||
)
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
remote = self.broker.readback()
|
||||
if remote != new_oid:
|
||||
if hasattr(self.broker, "receipt"):
|
||||
self.broker.receipt = replace(self.broker.receipt, expected_oid=expected)
|
||||
self.broker.push_branch(new_oid)
|
||||
return {"authority_oid": authority_oid, "network_oid": new_oid, "force_push": False}
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
return {"final_network_oid": self.broker.push_branch(new_oid), "force_push": False}
|
||||
if operation == "CREATE_PR":
|
||||
return dict(self.broker.create_pr(
|
||||
"AIOS autonomous release control plane",
|
||||
"Durable campaign continuation; exact head and recovery evidence are bound in the Job.",
|
||||
))
|
||||
if operation == "PUBLISH_CHECK":
|
||||
return dict(self.broker.status(
|
||||
str(request.get("check_state", "success")),
|
||||
str(request.get("check_description", "Durable campaign control-plane check passed")),
|
||||
str(request.get("check_context", "aios/durable-release")),
|
||||
))
|
||||
if operation == "PROTECTED_MERGE":
|
||||
number = int(request.get("pr_number", "0"))
|
||||
self.broker.transport.expected_merge_head = request["expected_branch_oid"]
|
||||
if request.get("expected_main_oid") is not None:
|
||||
self.broker.transport.expected_merge_base = request["expected_main_oid"]
|
||||
return dict(self.broker.merge(number))
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
old = request.get("expected_main_oid")
|
||||
if not isinstance(old, str):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
if self.broker.transport.read_branch("main") != new_oid:
|
||||
raise JobExecutionError("GITEA_MAIN_EXPECTATION_MISMATCH")
|
||||
return {"authority_main": self.authority.ff_CAS_main_after_verified_protected_merge(old, new_oid)}
|
||||
if operation == "COMMIT_FILE":
|
||||
path, content = self._commit_file_spec(row, request)
|
||||
return {
|
||||
"new_oid": self.authority.commit_file_in_isolated_worktree(
|
||||
base_oid=row["expected_branch_oid"], files={path: content},
|
||||
message="chore(gitops): durable commit-file", worktree_name=job_id,
|
||||
)
|
||||
}
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
|
||||
def _observe_operation(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
operation = str(request["operation"])
|
||||
new_oid = str(request["new_branch_oid"])
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
authority = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
remote = self.broker.readback()
|
||||
if authority == new_oid and remote == new_oid:
|
||||
return {"verification": "VERIFIED", "authority_oid": authority, "remote_oid": remote}
|
||||
expected = request["expected_branch_oid"]
|
||||
if authority == expected and remote in {None, expected}:
|
||||
return {"verification": "ABSENT", "authority_oid": authority, "remote_oid": remote}
|
||||
return {"verification": "UNKNOWN", "blocker": "PARTIAL_OR_CONFLICTING_BRANCH_EFFECT", "authority_oid": authority, "remote_oid": remote}
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
remote = self.broker.readback()
|
||||
if remote == new_oid:
|
||||
return {"verification": "VERIFIED", "remote_oid": remote}
|
||||
if remote in {None, EXPECTED_BRANCH_OID}:
|
||||
return {"verification": "ABSENT", "remote_oid": remote}
|
||||
return {"verification": "UNKNOWN", "blocker": "REMOTE_BRANCH_CONFLICT", "remote_oid": remote}
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
current = self.authority.read_ref("refs/heads/main")
|
||||
if current == new_oid:
|
||||
return {"verification": "VERIFIED", "authority_main": current}
|
||||
if current == request.get("expected_main_oid"):
|
||||
return {"verification": "ABSENT", "authority_main": current}
|
||||
return {"verification": "UNKNOWN", "blocker": "AUTHORITY_MAIN_CONFLICT", "authority_main": current}
|
||||
if operation == "CREATE_PR" and hasattr(self.broker, "read_pr"):
|
||||
value = self.broker.read_pr(BRANCH, "main", new_oid)
|
||||
return {"verification": "VERIFIED" if value else "ABSENT", "pr": value}
|
||||
if operation == "PUBLISH_CHECK" and hasattr(self.broker, "read_status"):
|
||||
value = self.broker.read_status(
|
||||
new_oid, str(request.get("check_context", "aios/durable-release"))
|
||||
)
|
||||
if value is None:
|
||||
return {"verification": "ABSENT"}
|
||||
expected_state = str(request.get("check_state", "success"))
|
||||
return {"verification": "VERIFIED" if value.get("status") == expected_state else "UNKNOWN", "status": dict(value), **({} if value.get("status") == expected_state else {"blocker": "STATUS_READBACK_MISMATCH"})}
|
||||
if operation == "PROTECTED_MERGE" and hasattr(self.broker, "read_merge"):
|
||||
value = self.broker.read_merge(int(request.get("pr_number", "0")))
|
||||
if value is None:
|
||||
return {"verification": "ABSENT"}
|
||||
if value.get("merged") is True and value.get("head_oid") == request["expected_branch_oid"]:
|
||||
return {"verification": "VERIFIED", "merge": dict(value)}
|
||||
if value.get("merged") is False:
|
||||
return {"verification": "ABSENT", "merge": dict(value)}
|
||||
return {"verification": "UNKNOWN", "blocker": "MERGE_READBACK_MISMATCH", "merge": dict(value)}
|
||||
return {"verification": "UNKNOWN", "blocker": "EXACT_READBACK_UNSUPPORTED"}
|
||||
|
||||
def _complete(
|
||||
self, job_id: str, request: Mapping[str, Any], effect: Mapping[str, Any], observation: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
receipt = {
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"effect": dict(effect),
|
||||
"verification": "VERIFIED",
|
||||
"readback": dict(observation),
|
||||
}
|
||||
completed = self.controller.advance(
|
||||
job_id, JobState.COMPLETED, side_effect_state="VERIFIED",
|
||||
receipt=receipt, expected_revision=int(row["revision"]),
|
||||
)
|
||||
return completed.as_dict()
|
||||
|
||||
def _record_unknown(
|
||||
self,
|
||||
job_id: str,
|
||||
request: Mapping[str, Any],
|
||||
observation: Mapping[str, Any],
|
||||
*,
|
||||
effect: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
receipt = {
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"effect": dict(effect) if effect is not None else None,
|
||||
"verification": "UNKNOWN",
|
||||
"blocker": observation.get("blocker", "READBACK_INCONCLUSIVE"),
|
||||
"readback": dict(observation),
|
||||
}
|
||||
pending = self.controller.advance(
|
||||
job_id, JobState.RECONCILIATION_REQUIRED, side_effect_state="UNKNOWN",
|
||||
receipt=receipt, expected_revision=int(row["revision"]),
|
||||
)
|
||||
return pending.as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, Any]:
|
||||
def observer(_current: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
_row, request = self._request(job_id)
|
||||
self._validate_campaign(request)
|
||||
observation = self._observe_operation(request)
|
||||
if observation["verification"] == "VERIFIED":
|
||||
return {
|
||||
"state": JobState.COMPLETED.value,
|
||||
"side_effect_state": "VERIFIED",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"effect": None, "verification": "VERIFIED",
|
||||
"readback": observation, "reconciled": True,
|
||||
},
|
||||
}
|
||||
if observation["verification"] == "ABSENT":
|
||||
return {
|
||||
"state": JobState.REQUESTED.value,
|
||||
"side_effect_state": "RECONCILED_ABSENT",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"verification": "ABSENT", "readback": observation,
|
||||
"reconciled": True,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"state": JobState.RECONCILIATION_REQUIRED.value,
|
||||
"side_effect_state": "UNKNOWN",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"verification": "UNKNOWN",
|
||||
"blocker": observation.get("blocker", "READBACK_INCONCLUSIVE"),
|
||||
"readback": observation, "reconciled": True,
|
||||
},
|
||||
}
|
||||
|
||||
result = self.controller.reconcile(job_id, observer).as_dict()
|
||||
if result["state"] in {JobState.COMPLETED.value, JobState.REQUESTED.value}:
|
||||
self._persistence_halted = False
|
||||
return result
|
||||
|
||||
def _commit_file_spec(
|
||||
self, row: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> tuple[str, bytes]:
|
||||
payload_ref = request.get("payload_ref")
|
||||
if not payload_ref:
|
||||
raise JobExecutionError("PAYLOAD_REQUIRED")
|
||||
payload = self.controller.store.payloads.read_verified(payload_ref, row["payload_digest"], int(request["payload_size"]))
|
||||
try:
|
||||
spec = json.loads(payload.decode())
|
||||
path = str(spec["path"])
|
||||
content = base64.b64decode(spec["content_base64"], validate=True)
|
||||
except Exception as exc:
|
||||
raise JobExecutionError("COMMIT_FILE_PAYLOAD_INVALID") from exc
|
||||
return path, content
|
||||
|
||||
|
||||
def build_from_environment(job_root: Path) -> DurableJobExecutor:
|
||||
from aios_gitea_machine_transport import GiteaMachineTransport
|
||||
from aios_gitea_machine_broker import MachineCredentialProjection
|
||||
|
||||
account = __import__("os").environ.get("AIOS_GITEA_MACHINE_ACCOUNT", "aios-autonomy-agent")
|
||||
api = __import__("os").environ.get("AIOS_GITEA_API", "https://g.cnnetmkt.top/git/api/v1")
|
||||
remote = __import__("os").environ.get("AIOS_GITEA_REMOTE", "https://g.cnnetmkt.top/git/aios/aios.git")
|
||||
authority_dir = Path(__import__("os").environ.get("AIOS_GITOPS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))
|
||||
worktree_root = Path(__import__("os").environ.get("AIOS_GITOPS_WORKTREE_ROOT", "/var/lib/aios-gitops/worktrees"))
|
||||
recovery = Path(__import__("os").environ.get("AIOS_RELEASE_RECOVERY_RECEIPT", "/var/lib/aios-gitops/release-campaigns/AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807/authorization-recovery.json"))
|
||||
transport = GiteaMachineTransport(api, REPOSITORY, remote, authority_dir)
|
||||
credential = MachineCredentialProjection.from_mapping({"scheme": "systemd-credential", "name": "gitea-machine-token", "account": account})
|
||||
receipt = CampaignCapabilityReceipt(CAMPAIGN_ID, TASK_ID, REPOSITORY, BRANCH, EXPECTED_BRANCH_OID, frozenset({"exact_task_branch_push", "pr_create_update", "status_publish", "protected_merge", "remote_readback"}), frozenset({"direct_main_push", "force_push", "tag_update", "tag_delete", "baseline_write", "production_deploy"}))
|
||||
return DurableJobExecutor(GitOpsJobController(JobStore(job_root)), AuthorityGitExecutor(authority_dir, worktree_root), GiteaMachineBroker(transport, credential, receipt), recovery)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job-root", required=True, type=Path)
|
||||
parser.add_argument("--job-id", required=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
result = build_from_environment(args.job_root).execute(args.job_id, dry_run=args.dry_run)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Collision-resistant storage keys for durable governed Task state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
|
||||
class JobIdentityError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def legacy_task_storage_key(task_id: str) -> str:
|
||||
"""Return the historical lossy key solely to detect migration conflicts."""
|
||||
if not isinstance(task_id, str) or not task_id:
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:120] or "task"
|
||||
|
||||
|
||||
def task_storage_key(task_id: str) -> str:
|
||||
"""Return a canonical collision-resistant key for every Task state sink."""
|
||||
if (
|
||||
not isinstance(task_id, str)
|
||||
or not task_id
|
||||
or task_id != task_id.strip()
|
||||
or len(task_id) > 512
|
||||
):
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
readable = re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:48] or "task"
|
||||
digest = hashlib.sha256(
|
||||
("aios-task-storage-v1\0" + task_id).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"{readable}-{digest}"
|
||||
@@ -5,6 +5,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
||||
@@ -218,7 +220,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
a = self.require_write(['owner']);
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
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
|
||||
try:
|
||||
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]);
|
||||
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
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pure, observation-first decisions for governed GitOps reconciliation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ReconciliationAction(str, Enum):
|
||||
NOOP = "NOOP"
|
||||
PUSH_EXACT = "PUSH_EXACT"
|
||||
VERIFY_REMOTE = "VERIFY_REMOTE"
|
||||
MARK_REMOTE_VERIFIED = "MARK_REMOTE_VERIFIED"
|
||||
MARK_RECONCILIATION_REQUIRED = "MARK_RECONCILIATION_REQUIRED"
|
||||
FAIL_CONFLICT = "FAIL_CONFLICT"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteObservation:
|
||||
repository_identity: str
|
||||
branch: str
|
||||
desired_oid: str
|
||||
authority_oid: Optional[str]
|
||||
remote_git_oid: Optional[str]
|
||||
remote_api_oid: Optional[str]
|
||||
commit_visible: bool
|
||||
observed_at: str
|
||||
expected_oid: Optional[str] = None
|
||||
|
||||
@property
|
||||
def all_equal(self) -> bool:
|
||||
values = (self.authority_oid, self.remote_git_oid, self.remote_api_oid, self.desired_oid)
|
||||
return all(isinstance(value, str) for value in values) and len(set(values)) == 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationDecision:
|
||||
action: ReconciliationAction
|
||||
state: str
|
||||
reason: str
|
||||
retry_safe: bool = False
|
||||
|
||||
|
||||
def _valid_oid(value: Optional[str]) -> bool:
|
||||
return value is None or bool(OID_RE.fullmatch(value))
|
||||
|
||||
|
||||
def decide_reconciliation(observation: RemoteObservation) -> ReconciliationDecision:
|
||||
"""Decide from a fresh observation; never complete on a missing remote."""
|
||||
if observation.repository_identity != "aios/aios":
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not observation.branch.startswith("agent/"):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "BRANCH_SCOPE_DENIED")
|
||||
if not _valid_oid(observation.desired_oid) or not _valid_oid(observation.expected_oid):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "OID_INVALID")
|
||||
if observation.authority_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_UNAVAILABLE")
|
||||
if observation.authority_oid != observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_CONFLICT")
|
||||
present = [oid for oid in (observation.remote_git_oid, observation.remote_api_oid) if oid is not None]
|
||||
if any(oid not in {observation.desired_oid, observation.expected_oid} for oid in present):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REMOTE_OID_CONFLICT")
|
||||
if observation.all_equal and observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_REMOTE_VERIFIED, "REMOTE_VERIFIED", "ALL_OIDS_EQUAL_AND_VISIBLE")
|
||||
if observation.remote_git_oid is None and observation.remote_api_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "REMOTE_PUSH_PENDING", "AUTHORITY_DESIRED_REMOTE_MISSING")
|
||||
if observation.remote_git_oid == observation.desired_oid or observation.remote_api_oid == observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "REMOTE_VERIFY_PENDING", "REMOTE_PROOF_PARTIAL")
|
||||
if observation.expected_oid is not None and observation.remote_git_oid == observation.expected_oid and observation.remote_api_oid == observation.expected_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.PUSH_EXACT, "REMOTE_PUSH_PENDING", "SAFE_EXACT_FAST_FORWARD_RETRY", retry_safe=True)
|
||||
if not observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "COMMIT_VISIBILITY_PENDING", "COMMIT_VISIBILITY_LAG")
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "PARTIAL_REMOTE_OBSERVATION")
|
||||
|
||||
|
||||
def reconcile_observation(**kwargs: object) -> ReconciliationDecision:
|
||||
return decide_reconciliation(RemoteObservation(**kwargs))
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""Single supervised socket-activated worker for durable GitOps Jobs.
|
||||
|
||||
The worker consumes durable requests and reconciliation queries. Repository
|
||||
mutation is a separate injected executor and remains impossible while the
|
||||
legacy global write gate is false.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore, ServiceUnavailable
|
||||
|
||||
|
||||
def runtime_policy(environment: Mapping[str, str] | None = None) -> dict[str, bool]:
|
||||
env = environment or os.environ
|
||||
legacy = env.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
durable = env.get("AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
# Legacy direct Git mutation remains disabled. Authorized durable Jobs use
|
||||
# their own campaign/task capability and may mutate only through the
|
||||
# worker's lease/CAS path while the legacy gate stays false.
|
||||
return {"legacy_global_write": legacy, "durable_job_execution": durable, "repository_mutation_allowed": durable}
|
||||
|
||||
|
||||
def handle_request(controller: GitOpsJobController, request: Mapping[str, Any], executor: Any | None = None) -> dict[str, Any]:
|
||||
job_id = str(request.get("job_id") or "")
|
||||
if not job_id:
|
||||
raise JobError("JOB_ID_REQUIRED")
|
||||
if request.get("action") == "execute":
|
||||
if executor is None:
|
||||
raise JobError("JOB_EXECUTOR_UNAVAILABLE")
|
||||
result = executor.execute(job_id, dry_run=bool(request.get("dry_run", False)))
|
||||
receipt = controller.store._receipt(controller.store.read(job_id), duplicate=True)
|
||||
return {"job": result, **receipt.as_dict()}
|
||||
if request.get("action") == "resume":
|
||||
receipt = controller.resume(job_id)
|
||||
else:
|
||||
row = controller.store.read(job_id)
|
||||
receipt = controller.store._receipt(row, duplicate=True)
|
||||
return receipt.as_dict()
|
||||
|
||||
|
||||
def serve_socket(sock: socket.socket, controller: GitOpsJobController, stopping: threading.Event, executor: Any | None = None) -> None:
|
||||
sock.listen(32)
|
||||
sock.settimeout(1.0)
|
||||
while not stopping.is_set():
|
||||
try:
|
||||
connection, _ = sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with connection:
|
||||
try:
|
||||
raw = connection.makefile("rb").readline(1_048_576)
|
||||
request = json.loads(raw.decode("utf-8"))
|
||||
response = {"status": "ok", **handle_request(controller, request, executor)}
|
||||
except ServiceUnavailable as exc:
|
||||
response = {"status": "error", "error": str(exc), "retryable": True, "side_effect_state": "UNKNOWN"}
|
||||
except Exception as exc:
|
||||
response = {"status": "error", "error": type(exc).__name__, "retryable": False}
|
||||
connection.sendall((json.dumps(response, sort_keys=True) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def socket_from_systemd() -> socket.socket:
|
||||
if os.environ.get("LISTEN_FDS") != "1":
|
||||
raise RuntimeError("SOCKET_ACTIVATION_REQUIRED")
|
||||
return socket.fromfd(3, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job-root", required=True, type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
policy = runtime_policy()
|
||||
if policy["legacy_global_write"]:
|
||||
raise RuntimeError("LEGACY_GLOBAL_WRITES_MUST_REMAIN_FALSE")
|
||||
if not policy["durable_job_execution"]:
|
||||
raise RuntimeError("DURABLE_JOB_EXECUTION_GATE_CLOSED")
|
||||
controller = GitOpsJobController(JobStore(args.job_root))
|
||||
from aios_gitops_job_executor import build_from_environment
|
||||
executor = build_from_environment(args.job_root)
|
||||
stopping = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stopping.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: stopping.set())
|
||||
sock = socket_from_systemd()
|
||||
try:
|
||||
serve_socket(sock, controller, stopping, executor)
|
||||
finally:
|
||||
sock.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Central fail-closed policy seam for governed task and continuation requests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedPolicyInput:
|
||||
identity: str
|
||||
task_id: str
|
||||
repository: str
|
||||
operation: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
risk: str = "engineering"
|
||||
requested_capabilities: tuple[str, ...] = ()
|
||||
previous_generation: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyDecision:
|
||||
decision: str
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"decision": self.decision, "reason": self.reason}
|
||||
|
||||
|
||||
def evaluate_policy(value: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
if isinstance(value, Mapping):
|
||||
value = GovernedPolicyInput(**value)
|
||||
if value.repository != "aios/aios":
|
||||
return PolicyDecision("DENY", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not OID_RE.fullmatch(value.base_oid) or not OID_RE.fullmatch(value.expected_branch_oid):
|
||||
return PolicyDecision("DENY", "OID_INVALID")
|
||||
if not value.target_branch.startswith("agent/") or value.target_branch in {"main", "master"}:
|
||||
return PolicyDecision("DENY", "BRANCH_SCOPE_DENIED")
|
||||
if "force_push" in value.requested_capabilities or "force" in value.operation.lower():
|
||||
return PolicyDecision("DENY", "FORCE_PUSH_DENIED")
|
||||
if {"main_write", "tag_write", "baseline_write", "production_write", "production_deploy", "tag_mutation", "baseline_mutation"}.intersection(value.requested_capabilities):
|
||||
return PolicyDecision("DENY", "MUTATION_SCOPE_DENIED")
|
||||
if not isinstance(value.generation, int) or isinstance(value.generation, bool) or value.generation < 1:
|
||||
return PolicyDecision("DENY", "GENERATION_INVALID")
|
||||
if value.previous_generation is not None and value.generation <= value.previous_generation:
|
||||
return PolicyDecision("MANUAL_OWNER_REVIEW_REQUIRED", "GENERATION_NOT_MONOTONIC")
|
||||
if not value.task_id:
|
||||
return PolicyDecision("DENY", "TASK_ID_INVALID")
|
||||
return PolicyDecision("ALLOW", "GOVERNED_ENGINEERING_SCOPE")
|
||||
|
||||
|
||||
class GovernedPolicy:
|
||||
def decide(self, request: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
return evaluate_policy(request)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Durable governed task binding for CREATE_NEW and CONTINUE_EXISTING."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
from aios_gitops_job_identity import legacy_task_storage_key, task_storage_key
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||
|
||||
try: # pragma: no cover - exercised on the Linux activation host
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX execution stays fail closed
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
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}$")
|
||||
|
||||
|
||||
class BootstrapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _lexists(path: Path) -> bool:
|
||||
try:
|
||||
path.lstat()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _require_runtime_directory(path: Path) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID") from exc
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_mode & 0o022
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedTaskBinding:
|
||||
task_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
idempotency_key: str
|
||||
expected_branch_oid: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
job_id: str | None = None
|
||||
|
||||
def response(self, *, idempotent: bool = False) -> dict[str, Any]:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
|
||||
# ===== AIOS Formal Lineage producer-native reservation v12 =====
|
||||
import re as _fl_re
|
||||
|
||||
_FL_PRODUCER_KEY = _fl_re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
def _formal_lineage_reservation_receipt(
|
||||
value,
|
||||
*,
|
||||
request_idempotency_key: str,
|
||||
expected_side_effect_state: str,
|
||||
):
|
||||
if callable(getattr(value, "as_dict", None)):
|
||||
raw=value.as_dict()
|
||||
elif isinstance(value, dict):
|
||||
raw=dict(value)
|
||||
else:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_TYPE_INVALID")
|
||||
|
||||
producer_key=raw.get("idempotency_key")
|
||||
job_id=raw.get("job_id")
|
||||
if not isinstance(job_id,str) or not job_id:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_ID_INVALID")
|
||||
if raw.get("state")!="REQUESTED":
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_STATE_INVALID")
|
||||
if raw.get("side_effect_state")!=expected_side_effect_state:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_SIDE_EFFECT_STATE_INVALID")
|
||||
if not isinstance(producer_key,str) or not _FL_PRODUCER_KEY.fullmatch(producer_key):
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_PRODUCER_IDEMPOTENCY_INVALID")
|
||||
if job_id!="job-"+producer_key[:32]:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_PRODUCER_KEY_CONFLICT")
|
||||
if not isinstance(request_idempotency_key,str) or not request_idempotency_key:
|
||||
raise RuntimeError("GOVERNED_TASK_REQUEST_IDEMPOTENCY_INVALID")
|
||||
|
||||
return {
|
||||
"job_id":job_id,
|
||||
"state":"REQUESTED",
|
||||
"side_effect_state":raw["side_effect_state"],
|
||||
"producer_idempotency_key":producer_key,
|
||||
"request_idempotency_key":request_idempotency_key,
|
||||
}
|
||||
|
||||
def _formal_lineage_attach_receipt(response, receipt):
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RESPONSE_INVALID")
|
||||
if response.get("state")!="BOUND":
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_STATE_INVALID")
|
||||
if response.get("job_id")!=receipt.get("job_id"):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RECEIPT_JOB_CONFLICT")
|
||||
result=dict(response)
|
||||
result["reservation_receipt"]=dict(receipt)
|
||||
return result
|
||||
|
||||
class GovernedTaskBootstrap:
|
||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.lock_root = self.root / "task-binding-locks"; self.task_meta_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.job_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.lock_root.mkdir(parents=True, exist_ok=True, mode=0o700); _require_runtime_directory(self.task_meta_root); _require_runtime_directory(self.job_root); _require_runtime_directory(self.lock_root)
|
||||
|
||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||
root = self.job_root / task_storage_key(task_id)
|
||||
legacy = self.job_root / legacy_task_storage_key(task_id)
|
||||
if legacy != root and _lexists(legacy):
|
||||
if _lexists(root):
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_MIGRATION_REQUIRED")
|
||||
store = JobStore(root, payload_root=root / "payloads")
|
||||
return store, GitOpsJobController(store)
|
||||
|
||||
def _metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (task_storage_key(task_id) + ".json")
|
||||
|
||||
def _legacy_metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (legacy_task_storage_key(task_id) + ".json")
|
||||
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
legacy = self._legacy_metadata(task_id)
|
||||
if legacy != path and _lexists(legacy):
|
||||
if _lexists(path):
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_MIGRATION_REQUIRED")
|
||||
if not _lexists(path):
|
||||
return None
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise BootstrapError("TASK_BINDING_PATH_INVALID")
|
||||
|
||||
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
if binding.task_id != task_id:
|
||||
raise BootstrapError("TASK_BINDING_IDENTITY_CONFLICT")
|
||||
return binding
|
||||
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:
|
||||
path = self._metadata(binding.task_id)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".task-binding-", dir=self.task_meta_root,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(json.dumps(asdict(binding), sort_keys=True) + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
parent = os.open(self.task_meta_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.close(parent)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def _task_lock(self, task_id: str) -> int:
|
||||
if fcntl is None:
|
||||
raise BootstrapError("TASK_BINDING_LOCK_UNAVAILABLE")
|
||||
path = self.lock_root / (task_storage_key(task_id) + ".lock")
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
metadata = os.fstat(descriptor)
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_nlink != 1
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_LOCK_INVALID")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_BUSY") from exc
|
||||
return descriptor
|
||||
|
||||
@staticmethod
|
||||
def _unlock_task(descriptor: int) -> None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||
descriptor = self._task_lock(values["task_id"])
|
||||
try:
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
expected = str(request.get('expected_branch_oid') or values['base_oid'])
|
||||
payload = json.dumps(values, sort_keys=True, separators=(',', ':')).encode()
|
||||
_, controller = self._store(values['task_id'])
|
||||
_fl_submit_receipt = controller.submit({'idempotency_key': values['idempotency_key'], 'campaign_id': values['task_id'], 'task_id': values['task_id'], 'repository': values['repository'], 'operation': 'GOVERNED_TASK_CREATE', 'branch': values['target_branch'], 'expected_branch_oid': expected, 'payload_digest': hashlib.sha256(payload).hexdigest(), 'lineage_anchor_oid': values['base_oid']}, payload=payload)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
_fl_submit_receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state='SAME_JOB'
|
||||
)
|
||||
_fl_response = existing.response(idempotent=True)
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
expected = str(request.get("expected_branch_oid") or values["base_oid"])
|
||||
if current is None:
|
||||
mode, generation = "CREATE_NEW", 1
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
expected_job_id = controller.internal_job_id_for_idempotency_key(values["idempotency_key"])
|
||||
with controller.store._connect() as connection:
|
||||
bootstrap_jobs = connection.execute(
|
||||
"SELECT job_id FROM jobs WHERE operation='GOVERNED_TASK_CREATE' ORDER BY job_id"
|
||||
).fetchall()
|
||||
if bootstrap_jobs and [str(row[0]) for row in bootstrap_jobs] != [expected_job_id]:
|
||||
raise BootstrapError("TASK_BINDING_RECOVERY_CONFLICT")
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
binding = GovernedTaskBinding(**values, expected_branch_oid=expected, branch_mode=mode, generation=generation, job_id=receipt.job_id)
|
||||
self._write(binding)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state="QUEUED"
|
||||
)
|
||||
_fl_response = binding.response()
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
finally:
|
||||
self._unlock_task(descriptor)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,527 @@
|
||||
"""Owner-final, structured authorization for one governed pull-request merge.
|
||||
|
||||
The Owner approval is the policy decision. Signing and public verification
|
||||
prove that decision but cannot change its repository, pull request, CAS base,
|
||||
technical-gate contract, or lifetime. This module deliberately exposes no
|
||||
generic sign-bytes API and does not publish or alter technical check results.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
SignedArtifact,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
POLICY_SCHEMA = "aios.owner-merge-approval-policy.v2"
|
||||
APPROVAL_SCHEMA = "aios.owner-merge-approval.v2"
|
||||
RECEIPT_SCHEMA = "aios.owner-merge-admission-receipt.v2"
|
||||
OPERATION = "merge.pull-request"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,255}$")
|
||||
CHECK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{11,255}$")
|
||||
|
||||
|
||||
class OwnerMergeApprovalError(RuntimeError):
|
||||
"""Stable fail-closed error for the Owner merge approval contract."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _require_oid(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or OID_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _require_digest(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or DIGEST_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeApprovalPolicy:
|
||||
"""Immutable policy input for one exact protected merge approval."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
expected_main_oid: str
|
||||
operation: str
|
||||
required_check_contract_digest: str
|
||||
required_checks: tuple[str, ...]
|
||||
owner_identity: str
|
||||
audit_lineage_digest: str
|
||||
maximum_ttl_seconds: int
|
||||
policy_version: str = "v2"
|
||||
schema: str = POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if (
|
||||
self.schema != POLICY_SCHEMA
|
||||
or self.policy_version != "v2"
|
||||
or not isinstance(self.repository, str)
|
||||
or REPOSITORY_RE.fullmatch(self.repository) is None
|
||||
or type(self.pr_number) is not int
|
||||
or not 1 <= self.pr_number < 2**31
|
||||
or self.operation != OPERATION
|
||||
or not isinstance(self.owner_identity, str)
|
||||
or IDENTITY_RE.fullmatch(self.owner_identity) is None
|
||||
or type(self.maximum_ttl_seconds) is not int
|
||||
or not 60 <= self.maximum_ttl_seconds <= 900
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_INVALID")
|
||||
_require_oid(self.pr_head_oid, "OWNER_MERGE_POLICY_HEAD_INVALID")
|
||||
_require_oid(self.expected_main_oid, "OWNER_MERGE_POLICY_BASE_INVALID")
|
||||
_require_digest(
|
||||
self.required_check_contract_digest,
|
||||
"OWNER_MERGE_POLICY_CHECK_CONTRACT_INVALID",
|
||||
)
|
||||
_require_digest(self.audit_lineage_digest, "OWNER_MERGE_POLICY_LINEAGE_INVALID")
|
||||
if (
|
||||
not isinstance(self.required_checks, tuple)
|
||||
or not self.required_checks
|
||||
or len(set(self.required_checks)) != len(self.required_checks)
|
||||
or any(not isinstance(item, str) or CHECK_RE.fullmatch(item) is None for item in self.required_checks)
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_REQUIRED_CHECKS_INVALID")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
value = asdict(self)
|
||||
value["required_checks"] = list(self.required_checks)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return sha256_json(self.as_dict())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeLiveState:
|
||||
"""Read-only live evidence; this object has no status publication method."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
main_oid: str
|
||||
required_check_contract_digest: str
|
||||
check_statuses: Mapping[str, bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeOidReadback:
|
||||
authority_main_oid: str
|
||||
gitea_main_oid: str
|
||||
api_main_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeAdmissionReceipt:
|
||||
approval_id: str
|
||||
nonce: str
|
||||
owner_identity: str
|
||||
policy_digest: str
|
||||
proof_path: str
|
||||
merge_oid: str
|
||||
schema: str = RECEIPT_SCHEMA
|
||||
|
||||
|
||||
class ApprovalProofVerifier(Protocol):
|
||||
"""Public proof verifier; it is intentionally unable to issue approvals."""
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
key_id: str,
|
||||
) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class GovernedMergeExecutor(Protocol):
|
||||
def execute(self, approval: Mapping[str, object]) -> str:
|
||||
...
|
||||
|
||||
|
||||
class MergeOidReconciler(Protocol):
|
||||
def readback(self, merge_oid: str) -> OwnerMergeOidReadback:
|
||||
...
|
||||
|
||||
|
||||
APPROVAL_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"approval_id",
|
||||
"nonce",
|
||||
"repository",
|
||||
"pr_number",
|
||||
"pr_head_oid",
|
||||
"expected_main_oid",
|
||||
"operation",
|
||||
"required_check_contract_digest",
|
||||
"required_checks",
|
||||
"owner_identity",
|
||||
"audit_lineage_digest",
|
||||
"policy_version",
|
||||
"policy_digest",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OwnerMergeApprovalAuthority:
|
||||
"""Structured Owner signer for an immutable merge policy.
|
||||
|
||||
``issue`` has no caller-controlled payload parameter. The immutable policy
|
||||
selects the complete authorization scope; only approval IDs and nonces are
|
||||
generated inside the authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] = secrets.token_urlsafe,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id="local-owner-authority",
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
|
||||
def _token(self, prefix: str) -> str:
|
||||
value = prefix + self.token_urlsafe(24)
|
||||
if TOKEN_RE.fullmatch(value) is None:
|
||||
_fail("OWNER_MERGE_AUTHORITY_GENERATED_TOKEN_INVALID")
|
||||
return value
|
||||
|
||||
def issue(self) -> SignedArtifact:
|
||||
now = int(self.clock())
|
||||
approval_id = self._token("owner-merge-approval-")
|
||||
nonce = self._token("owner-merge-nonce-")
|
||||
policy = self.policy
|
||||
payload: dict[str, Any] = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"approval_id": approval_id,
|
||||
"nonce": nonce,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + policy.maximum_ttl_seconds,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.signer.fingerprint,
|
||||
}
|
||||
payload["canonical_payload_digest"] = sha256_json(payload)
|
||||
return self.signer._sign_canonical(payload)
|
||||
|
||||
|
||||
class OwnerMergeApprovalVerifier:
|
||||
"""Verify Owner proof, preferring normal verification then recovery proof.
|
||||
|
||||
The recovery verifier validates a pre-existing approval with the registered
|
||||
public Owner root. It cannot issue an approval and is therefore not a
|
||||
second policy principal.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
primary: ApprovalProofVerifier,
|
||||
recovery: ApprovalProofVerifier | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.primary = primary
|
||||
self.recovery = recovery
|
||||
self.clock = clock
|
||||
|
||||
@staticmethod
|
||||
def _artifact(artifact: SignedArtifact | Mapping[str, Any]) -> tuple[dict[str, Any], str, str, str]:
|
||||
if isinstance(artifact, SignedArtifact):
|
||||
return dict(artifact.payload), artifact.signature, artifact.key_id, artifact.namespace
|
||||
if not isinstance(artifact, Mapping) or set(artifact) != {"payload", "signature", "key_id", "namespace"}:
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
signature = artifact.get("signature")
|
||||
key_id = artifact.get("key_id")
|
||||
namespace = artifact.get("namespace")
|
||||
if not isinstance(payload, Mapping) or not all(isinstance(item, str) for item in (signature, key_id, namespace)):
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
return dict(payload), signature, key_id, namespace
|
||||
|
||||
@staticmethod
|
||||
def _validate_scope(payload: Mapping[str, Any], policy: OwnerMergeApprovalPolicy, *, now: int, key_id: str, namespace: str) -> None:
|
||||
if set(payload) != APPROVAL_FIELDS:
|
||||
_fail("OWNER_MERGE_APPROVAL_FIELDS_INVALID")
|
||||
expected = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": key_id,
|
||||
}
|
||||
if payload.get("pr_head_oid") != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_HEAD_BINDING_INVALID")
|
||||
if payload.get("expected_main_oid") != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_BASE_BINDING_INVALID")
|
||||
if payload.get("policy_digest") != policy.digest:
|
||||
_fail("OWNER_MERGE_POLICY_DIGEST_DRIFT")
|
||||
if namespace != OWNER_NAMESPACE or any(payload.get(key) != value for key, value in expected.items()):
|
||||
_fail("OWNER_MERGE_APPROVAL_SCOPE_INVALID")
|
||||
for field in ("approval_id", "nonce"):
|
||||
if not isinstance(payload.get(field), str) or TOKEN_RE.fullmatch(payload[field]) is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_TOKEN_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(payload.get(field)) is not int:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
if not payload["not_before"] <= now < payload["expires_at"]:
|
||||
_fail("OWNER_MERGE_APPROVAL_EXPIRED")
|
||||
if payload["issued_at"] > now + 5 or payload["expires_at"] - payload["issued_at"] != policy.maximum_ttl_seconds:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
supplied = payload.get("canonical_payload_digest")
|
||||
body = dict(payload)
|
||||
body.pop("canonical_payload_digest", None)
|
||||
if supplied != sha256_json(body):
|
||||
_fail("OWNER_MERGE_APPROVAL_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact | Mapping[str, Any],
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
policy.validate()
|
||||
payload, signature, key_id, namespace = self._artifact(artifact)
|
||||
canonical = canonical_json_bytes(payload)
|
||||
proof_path: str | None = None
|
||||
for path, verifier in (("primary", self.primary), ("recovery", self.recovery)):
|
||||
if verifier is None:
|
||||
continue
|
||||
try:
|
||||
verified = verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=canonical,
|
||||
signature=signature,
|
||||
key_id=key_id,
|
||||
)
|
||||
except Exception:
|
||||
verified = False
|
||||
if verified is True:
|
||||
proof_path = path
|
||||
break
|
||||
if proof_path is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_SIGNATURE_INVALID")
|
||||
self._validate_scope(payload, policy, now=int(self.clock()), key_id=key_id, namespace=namespace)
|
||||
return payload, proof_path
|
||||
|
||||
|
||||
class SQLiteOwnerMergeNonceLedger:
|
||||
"""Durable one-shot fence. Unknown effects are intentionally not retried."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connection(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_NONCE_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_nonce ("
|
||||
"nonce TEXT PRIMARY KEY, approval_digest TEXT NOT NULL, state TEXT NOT NULL, claimed_at INTEGER NOT NULL)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def claim(self, *, nonce: str, approval_digest: str, now: int) -> None:
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT state FROM owner_merge_nonce WHERE nonce=?", (nonce,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_REPLAY")
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_nonce(nonce,approval_digest,state,claimed_at) VALUES(?,?,?,?)",
|
||||
(nonce, approval_digest, "PENDING", now),
|
||||
)
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def mark(self, *, nonce: str, state: str) -> None:
|
||||
if state not in {"COMPLETED", "UNKNOWN"}:
|
||||
_fail("OWNER_MERGE_NONCE_STATE_INVALID")
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
updated = connection.execute(
|
||||
"UPDATE owner_merge_nonce SET state=? WHERE nonce=? AND state='PENDING'",
|
||||
(state, nonce),
|
||||
).rowcount
|
||||
if updated != 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_STATE_CONFLICT")
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class GovernedOwnerMergeAdmission:
|
||||
"""Admit one protected merge without owning any policy or technical gate."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
verifier: OwnerMergeApprovalVerifier,
|
||||
live_state_reader: Callable[[], OwnerMergeLiveState],
|
||||
executor: GovernedMergeExecutor,
|
||||
reconciler: MergeOidReconciler,
|
||||
nonce_ledger: SQLiteOwnerMergeNonceLedger,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.verifier = verifier
|
||||
self.live_state_reader = live_state_reader
|
||||
self.executor = executor
|
||||
self.reconciler = reconciler
|
||||
self.nonce_ledger = nonce_ledger
|
||||
self.clock = clock
|
||||
|
||||
def _validate_live_state(self, live: OwnerMergeLiveState) -> None:
|
||||
if not isinstance(live, OwnerMergeLiveState):
|
||||
_fail("OWNER_MERGE_LIVE_STATE_INVALID")
|
||||
policy = self.policy
|
||||
if live.repository != policy.repository:
|
||||
_fail("OWNER_MERGE_REPOSITORY_DRIFT")
|
||||
if live.pr_number != policy.pr_number:
|
||||
_fail("OWNER_MERGE_PR_BINDING_DRIFT")
|
||||
if live.pr_head_oid != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_HEAD_DRIFT")
|
||||
if live.main_oid != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_BASE_DRIFT")
|
||||
if live.required_check_contract_digest != policy.required_check_contract_digest:
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if not isinstance(live.check_statuses, Mapping) or set(live.check_statuses) != set(policy.required_checks):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if any(value is not True for value in live.check_statuses.values()):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECKS_FAILED")
|
||||
|
||||
def admit(self, artifact: SignedArtifact | Mapping[str, Any]) -> OwnerMergeAdmissionReceipt:
|
||||
approval, proof_path = self.verifier.verify(artifact, self.policy)
|
||||
self._validate_live_state(self.live_state_reader())
|
||||
nonce = str(approval["nonce"])
|
||||
approval_digest = hashlib.sha256(canonical_json_bytes(approval)).hexdigest()
|
||||
self.nonce_ledger.claim(nonce=nonce, approval_digest=approval_digest, now=int(self.clock()))
|
||||
try:
|
||||
merge_oid = self.executor.execute(dict(approval))
|
||||
_require_oid(merge_oid, "OWNER_MERGE_EXECUTOR_RESULT_INVALID")
|
||||
readback = self.reconciler.readback(merge_oid)
|
||||
if (
|
||||
not isinstance(readback, OwnerMergeOidReadback)
|
||||
or readback.authority_main_oid != merge_oid
|
||||
or readback.gitea_main_oid != merge_oid
|
||||
or readback.api_main_oid != merge_oid
|
||||
):
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
_fail("OWNER_MERGE_OID_RECONCILIATION_REQUIRED")
|
||||
except OwnerMergeApprovalError:
|
||||
# Every failure after a durable claim represents an operation that
|
||||
# is either known failed or needs reconciliation. Preserve the
|
||||
# one-shot fence rather than exposing a retryable PENDING state.
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_EXECUTION_UNKNOWN") from exc
|
||||
self.nonce_ledger.mark(nonce=nonce, state="COMPLETED")
|
||||
return OwnerMergeAdmissionReceipt(
|
||||
approval_id=str(approval["approval_id"]),
|
||||
nonce=nonce,
|
||||
owner_identity=str(approval["owner_identity"]),
|
||||
policy_digest=str(approval["policy_digest"]),
|
||||
proof_path=proof_path,
|
||||
merge_oid=merge_oid,
|
||||
)
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Restricted issuance service for ``aios.owner-merge-approval.v2``.
|
||||
|
||||
The service has one Owner-only operation. It accepts an empty request payload,
|
||||
loads one immutable policy, generates approval identifiers and nonces itself,
|
||||
and stores one exact issuance response. It deliberately does not expose a
|
||||
generic signing, payload, or policy-selection API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
OwnerMergeApprovalAuthority,
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
)
|
||||
from aios_single_host_authority import RegistryOwnerApprovalVerifier, SignedArtifact
|
||||
|
||||
|
||||
ISSUE_OPERATION = "issue_owner_merge_approval"
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _secure_regular(path: Path, *, require_root_owner: bool) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_FILE_MISSING") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_INVALID")
|
||||
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_WRITABLE")
|
||||
if require_root_owner and metadata.st_uid != 0:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_NOT_ROOT_OWNED")
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool) -> OwnerMergeApprovalPolicy:
|
||||
_secure_regular(path, require_root_owner=require_root_owner)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
expected = set(OwnerMergeApprovalPolicy.__dataclass_fields__)
|
||||
if set(value) != expected or not isinstance(value.get("required_checks"), list):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
try:
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository=value["repository"],
|
||||
pr_number=value["pr_number"],
|
||||
pr_head_oid=value["pr_head_oid"],
|
||||
expected_main_oid=value["expected_main_oid"],
|
||||
operation=value["operation"],
|
||||
required_check_contract_digest=value["required_check_contract_digest"],
|
||||
required_checks=tuple(value["required_checks"]),
|
||||
owner_identity=value["owner_identity"],
|
||||
audit_lineage_digest=value["audit_lineage_digest"],
|
||||
maximum_ttl_seconds=value["maximum_ttl_seconds"],
|
||||
policy_version=value["policy_version"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
policy.validate()
|
||||
except (KeyError, TypeError, OwnerMergeApprovalError) as exc:
|
||||
if isinstance(exc, OwnerMergeApprovalError):
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
return policy
|
||||
|
||||
|
||||
def _artifact_dict(artifact: SignedArtifact) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(artifact.payload),
|
||||
"signature": artifact.signature,
|
||||
"key_id": artifact.key_id,
|
||||
"namespace": artifact.namespace,
|
||||
}
|
||||
|
||||
|
||||
class SQLiteOwnerMergeIssuanceLedger:
|
||||
"""Durably replay a current approval and retain expired issuance history."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_issuance ("
|
||||
"nonce TEXT PRIMARY KEY, policy_digest TEXT NOT NULL, response_json TEXT NOT NULL, "
|
||||
"issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS owner_merge_issuance_policy_expiry "
|
||||
"ON owner_merge_issuance(policy_digest, expires_at)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
policy_digest: str,
|
||||
now: int,
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
validate_current: Callable[[dict[str, Any]], None],
|
||||
expiry_of: Callable[[dict[str, Any]], int],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT response_json, expires_at FROM owner_merge_issuance "
|
||||
"WHERE policy_digest=? AND expires_at>? ORDER BY issued_at DESC",
|
||||
(policy_digest, now),
|
||||
).fetchall()
|
||||
if len(rows) > 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if rows:
|
||||
try:
|
||||
response = json.loads(rows[0][0])
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
connection.rollback()
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
if not isinstance(response, dict):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if type(rows[0][1]) is not int or expiry_of(response) != rows[0][1]:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
validate_current(response)
|
||||
connection.rollback()
|
||||
return response, True
|
||||
response = issuer()
|
||||
expires_at = expiry_of(response)
|
||||
if expires_at <= now:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_RESPONSE_EXPIRED")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("nonce"), str):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
encoded = json.dumps(
|
||||
response,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_issuance(nonce,policy_digest,response_json,issued_at,expires_at) "
|
||||
"VALUES(?,?,?,?,?)",
|
||||
(payload["nonce"], policy_digest, encoded, now, expires_at),
|
||||
)
|
||||
connection.commit()
|
||||
return response, False
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.IntegrityError as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_NONCE_CONFLICT") from exc
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OwnerMergeApprovalService:
|
||||
"""A formal structured adapter, not an arbitrary signing oracle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy_path: Path,
|
||||
registry_path: Path,
|
||||
owner_key_path: Path,
|
||||
issuance_ledger_path: Path,
|
||||
allowed_uids: tuple[int, ...],
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] | None = None,
|
||||
) -> None:
|
||||
if not allowed_uids or any(type(uid) is not int or uid < 0 for uid in allowed_uids):
|
||||
_fail("OWNER_MERGE_ISSUER_ALLOWED_UIDS_INVALID")
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.require_root_owner = require_root_owner
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
self.ledger = SQLiteOwnerMergeIssuanceLedger(issuance_ledger_path)
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
_fail("OWNER_MERGE_ISSUER_PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_INVALID")
|
||||
if request.get("operation") != ISSUE_OPERATION:
|
||||
_fail("OWNER_MERGE_ISSUER_OPERATION_FORBIDDEN")
|
||||
if request.get("payload") != {}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_FIELDS_INVALID")
|
||||
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
_secure_regular(
|
||||
self.owner_key_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
primary = RegistryOwnerApprovalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OwnerMergeApprovalVerifier(primary=primary, clock=self.clock)
|
||||
|
||||
def expiry_of(response: dict[str, Any]) -> int:
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or type(payload.get("expires_at")) is not int:
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return payload["expires_at"]
|
||||
|
||||
def validate_current(response: dict[str, Any]) -> None:
|
||||
expiry_of(response)
|
||||
artifact = response["artifact"]
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
try:
|
||||
verifier.verify(artifact, policy)
|
||||
except OwnerMergeApprovalError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.token_urlsafe is not None:
|
||||
kwargs["token_urlsafe"] = self.token_urlsafe
|
||||
artifact = OwnerMergeApprovalAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
).issue()
|
||||
value = _artifact_dict(artifact)
|
||||
verifier.verify(value, policy)
|
||||
return {"status": "PASS", "artifact": value}
|
||||
|
||||
response, idempotent = self.ledger.issue_once(
|
||||
policy_digest=policy.digest,
|
||||
now=int(self.clock()),
|
||||
issuer=issue,
|
||||
validate_current=validate_current,
|
||||
expiry_of=expiry_of,
|
||||
)
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return {**response, "idempotent": idempotent}
|
||||
@@ -0,0 +1,967 @@
|
||||
"""Bounded, read-only discovery over a verified immutable Git repository.
|
||||
|
||||
The module is deliberately independent from Task, lease, credential, worktree,
|
||||
and write code. Every public operation verifies the exact repository identity,
|
||||
resolves a floating ref once to a full commit OID, and then reads only that
|
||||
immutable snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from authoritative_git import AuthoritativeGitError, verify_repository
|
||||
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
||||
_IDENTITY_RE = re.compile(
|
||||
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
)
|
||||
_READ_ONLY_COMMANDS = frozenset({"cat-file", "diff", "ls-tree", "rev-parse"})
|
||||
_MAX_COMMAND_OUTPUT = 1_048_576
|
||||
_MAX_RECORD_BYTES = 1_048_576
|
||||
_MAX_LIMIT = 1_000
|
||||
_MAX_DEPTH = 32
|
||||
_MAX_BYTES = 1_048_576
|
||||
_MAX_GLOBS = 16
|
||||
_MAX_QUERY_LENGTH = 512
|
||||
_MAX_CURSOR_LENGTH = 4_096
|
||||
_GIT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
class RepositoryDiscoveryError(RuntimeError):
|
||||
"""A fail-closed repository discovery request error."""
|
||||
|
||||
def __init__(self, code: str, *, status_code: int | None = None) -> None:
|
||||
self.code = str(code)
|
||||
self.status_code = (
|
||||
status_code if status_code is not None else self._status_for(self.code)
|
||||
)
|
||||
super().__init__(self.code)
|
||||
|
||||
@staticmethod
|
||||
def _status_for(code: str) -> int:
|
||||
if "OUTPUT_LIMIT" in code:
|
||||
return 413
|
||||
if code.endswith("_NOT_FOUND"):
|
||||
return 404
|
||||
if code in {"REPOSITORY_NOT_ALLOWED", "REPOSITORY_IDENTITY_MISMATCH"}:
|
||||
return 403
|
||||
if code in {
|
||||
"REPOSITORY_DISCOVERY_UNAVAILABLE",
|
||||
"REPOSITORY_DISCOVERY_GIT_FAILED",
|
||||
"REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID",
|
||||
"REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED",
|
||||
}:
|
||||
return 503
|
||||
return 400
|
||||
|
||||
|
||||
class RepositoryDiscoveryNotFound(RepositoryDiscoveryError):
|
||||
"""A validated immutable object or path was not found."""
|
||||
|
||||
|
||||
class RepositoryDiscoveryOutputTooLarge(RepositoryDiscoveryError):
|
||||
"""A fixed read-only Git query exceeded its collection boundary."""
|
||||
|
||||
|
||||
def _error(code: str) -> RepositoryDiscoveryError:
|
||||
return RepositoryDiscoveryError(code)
|
||||
|
||||
|
||||
def _request_mapping(request: Mapping[str, object]) -> Mapping[str, object]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return request
|
||||
|
||||
|
||||
def _required_request_value(request: Mapping[str, object], field: str) -> object:
|
||||
value = request.get(field)
|
||||
if value is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def _positive_int(value: object, field: str, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
||||
raise _error(f"REPOSITORY_DISCOVERY_INVALID_{field.upper()}")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_relative_path(
|
||||
value: object,
|
||||
field: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
allow_glob: bool = False,
|
||||
) -> str:
|
||||
if not isinstance(value, str) or value != value.strip() or "\x00" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if "\\" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value in ("", "."):
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value.startswith(("/", "-")) or "//" in value or ":" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if not allow_glob and any(character in value for character in "*?["):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
parts = value.split("/")
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if len(value) > 512:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_glob(value: object) -> str:
|
||||
return _safe_relative_path(value, "path_glob", allow_empty=False, allow_glob=True)
|
||||
|
||||
|
||||
def _safe_ref(value: str) -> bool:
|
||||
return bool(
|
||||
_REF_RE.fullmatch(value)
|
||||
and ".." not in value
|
||||
and "//" not in value
|
||||
and "@{" not in value
|
||||
and not value.endswith(("/", ".", ".lock"))
|
||||
and not any(part in ("", ".", "..") for part in value.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def _parse_tree_record(record: bytes) -> dict[str, str] | None:
|
||||
try:
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, oid = header.decode("ascii").split(" ", 2)
|
||||
path = raw_path.decode("utf-8")
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if kind not in {"blob", "tree", "commit"} or not _OID_RE.fullmatch(oid.lower()):
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
_safe_relative_path(path, "tree_path", allow_empty=False)
|
||||
except RepositoryDiscoveryError:
|
||||
# Git permits filenames outside the public POSIX-relative contract.
|
||||
# They are not exposed as usable discovery paths.
|
||||
return None
|
||||
return {"mode": mode, "type": kind, "object_oid": oid.lower(), "path": path}
|
||||
|
||||
|
||||
def _decode_output_path(raw: bytes) -> str:
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
return _safe_relative_path(value, "git_output_path", allow_empty=False)
|
||||
|
||||
|
||||
class RepositoryDiscovery:
|
||||
"""Repository-scoped immutable Git discovery with signed cursors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> None:
|
||||
configured = allowed_repositories
|
||||
if configured is None:
|
||||
configured = (os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", ""),)
|
||||
identities = tuple(configured)
|
||||
if not identities or any(
|
||||
not isinstance(item, str) or not _IDENTITY_RE.fullmatch(item)
|
||||
for item in identities
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_ALLOWLIST_INVALID")
|
||||
self._allowed_repositories = frozenset(identities)
|
||||
self._git_dir = git_dir
|
||||
secret = (
|
||||
cursor_secret
|
||||
if cursor_secret is not None
|
||||
else os.getenv("AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET", "")
|
||||
)
|
||||
if isinstance(secret, str):
|
||||
secret = secret.encode("utf-8")
|
||||
if not isinstance(secret, bytes) or len(secret) < 16:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED")
|
||||
self._cursor_secret = secret
|
||||
|
||||
def _repository_path(self, repository: object) -> Path:
|
||||
if not isinstance(repository, str) or repository not in self._allowed_repositories:
|
||||
raise _error("REPOSITORY_NOT_ALLOWED")
|
||||
try:
|
||||
path, actual_identity = verify_repository(
|
||||
git_dir=self._git_dir,
|
||||
repository_identity=repository,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
code = (
|
||||
"REPOSITORY_IDENTITY_MISMATCH"
|
||||
if "REPOSITORY_IDENTITY_MISMATCH" in str(exc)
|
||||
else "REPOSITORY_DISCOVERY_UNAVAILABLE"
|
||||
)
|
||||
raise _error(code) from exc
|
||||
if actual_identity != repository:
|
||||
raise _error("REPOSITORY_IDENTITY_MISMATCH")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _git_env() -> dict[str, str]:
|
||||
return {
|
||||
**os.environ,
|
||||
"GIT_OPTIONAL_LOCKS": "0",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run(
|
||||
path: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
max_output: int = _MAX_COMMAND_OUTPUT,
|
||||
) -> bytes:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
output_limit = min(max_output, _MAX_COMMAND_OUTPUT)
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
output = process.stdout.read(output_limit + 1)
|
||||
if len(output) > output_limit:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
raise RepositoryDiscoveryOutputTooLarge(
|
||||
"REPOSITORY_DISCOVERY_OUTPUT_LIMIT"
|
||||
)
|
||||
returncode = process.wait(timeout=_GIT_TIMEOUT_SECONDS)
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
if returncode != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _read_blob_prefix(path: Path, oid: str, maximum: int) -> tuple[bytes, bool]:
|
||||
"""Read at most maximum bytes plus one sentinel byte."""
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), "cat-file", "blob", oid],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
content = process.stdout.read(maximum + 1)
|
||||
truncated = len(content) > maximum
|
||||
if truncated:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
return content[:maximum], True
|
||||
if process.wait(timeout=_GIT_TIMEOUT_SECONDS) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return content, False
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
|
||||
def _iter_nul_records(self, path: Path, args: list[str]) -> Iterator[bytes]:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
buffer = b""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=self._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
while True:
|
||||
if time.monotonic() - started > _GIT_TIMEOUT_SECONDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
chunk = process.stdout.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
if len(buffer) > _MAX_RECORD_BYTES and b"\0" not in buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
while b"\0" in buffer:
|
||||
record, buffer = buffer.split(b"\0", 1)
|
||||
if len(record) > _MAX_RECORD_BYTES:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
yield record
|
||||
if buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
if process.wait(
|
||||
timeout=max(0.1, _GIT_TIMEOUT_SECONDS - (time.monotonic() - started))
|
||||
) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
def _resolve_at(self, path: Path, revision: object, field: str) -> str:
|
||||
if (
|
||||
not isinstance(revision, str)
|
||||
or revision != revision.strip()
|
||||
or not revision
|
||||
or revision.startswith("-")
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
if _OID_RE.fullmatch(revision.lower()):
|
||||
candidates = [revision.lower()]
|
||||
elif revision == "HEAD":
|
||||
candidates = ["HEAD"]
|
||||
elif _safe_ref(revision) and not any(
|
||||
token in revision for token in ("^", "~", ":", "\\")
|
||||
):
|
||||
if revision.startswith("refs/"):
|
||||
if not revision.startswith(("refs/heads/", "refs/tags/")):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
candidates = [revision]
|
||||
else:
|
||||
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
|
||||
else:
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
resolved: list[str] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
output = self._run(
|
||||
path,
|
||||
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
|
||||
max_output=256,
|
||||
)
|
||||
except RepositoryDiscoveryError:
|
||||
continue
|
||||
oid = output.decode("ascii", errors="ignore").strip().lower()
|
||||
if _OID_RE.fullmatch(oid):
|
||||
resolved.append(oid)
|
||||
if not resolved:
|
||||
raise RepositoryDiscoveryNotFound(
|
||||
f"REPOSITORY_DISCOVERY_{field.upper()}_NOT_FOUND"
|
||||
)
|
||||
if len(set(resolved)) != 1:
|
||||
raise _error("REPOSITORY_DISCOVERY_AMBIGUOUS_REVISION")
|
||||
return resolved[0]
|
||||
|
||||
def resolve_revision(self, repository: str, revision: str) -> str:
|
||||
return self._resolve_at(self._repository_path(repository), revision, "revision")
|
||||
|
||||
def _cursor_offset(
|
||||
self,
|
||||
cursor: str | None,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
) -> int:
|
||||
if cursor is None:
|
||||
return 0
|
||||
if (
|
||||
not isinstance(cursor, str)
|
||||
or not cursor
|
||||
or len(cursor) > _MAX_CURSOR_LENGTH
|
||||
or "." not in cursor
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
encoded, supplied_signature = cursor.split(".", 1)
|
||||
try:
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(padded.encode("ascii")).decode("ascii")
|
||||
)
|
||||
except (UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID") from exc
|
||||
expected_signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
expected = {
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
}
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or payload.get("v") != 1
|
||||
or any(payload.get(key) != value for key, value in expected.items())
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
offset = payload.get("offset")
|
||||
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
return offset
|
||||
|
||||
def _next_cursor(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
offset: int,
|
||||
) -> str:
|
||||
payload = {
|
||||
"v": 1,
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
"offset": offset,
|
||||
}
|
||||
encoded = base64.urlsafe_b64encode(_canonical(payload)).decode("ascii").rstrip("=")
|
||||
signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def _iter_tree_entries(
|
||||
self,
|
||||
path: Path,
|
||||
oid: str,
|
||||
prefix: str,
|
||||
*,
|
||||
include_trees: bool,
|
||||
) -> Iterator[dict[str, str]]:
|
||||
args = ["ls-tree", "-z", "-r", "--full-tree"]
|
||||
if include_trees:
|
||||
args.append("-t")
|
||||
args.append(oid)
|
||||
if prefix:
|
||||
args.extend(["--", prefix])
|
||||
for record in self._iter_nul_records(path, args):
|
||||
entry = _parse_tree_record(record)
|
||||
if entry is None:
|
||||
continue
|
||||
if not prefix or entry["path"] == prefix or entry["path"].startswith(prefix + "/"):
|
||||
yield entry
|
||||
|
||||
def list_tree(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
prefix: str = "",
|
||||
depth: int = 8,
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
depth = _positive_int(depth, "depth", _MAX_DEPTH)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query = {"prefix": prefix, "depth": depth, "limit": limit}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, str]] = []
|
||||
matched = 0
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, prefix, include_trees=True):
|
||||
relative = entry["path"][len(prefix):].lstrip("/") if prefix else entry["path"]
|
||||
if relative.count("/") + 1 > depth:
|
||||
continue
|
||||
if matched >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
matched += 1
|
||||
has_more = offset + len(page) < matched
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def list_repository_tree(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
return self.list_tree(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
prefix=request.get("prefix", ""), # type: ignore[arg-type]
|
||||
depth=request.get("depth", 8), # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _blob_size(self, path: Path, oid: str) -> int:
|
||||
output = self._run(path, ["cat-file", "-s", oid], max_output=128)
|
||||
try:
|
||||
size = int(output.decode("ascii").strip())
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if size < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return size
|
||||
|
||||
def search_source(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
query: str,
|
||||
path_globs: Iterable[str] | None = None,
|
||||
max_matches: int = 100,
|
||||
max_bytes: int = 262_144,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(query, str)
|
||||
or not query
|
||||
or "\x00" in query
|
||||
or len(query) > _MAX_QUERY_LENGTH
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_QUERY_INVALID")
|
||||
max_matches = _positive_int(max_matches, "max_matches", _MAX_LIMIT)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
if path_globs is None:
|
||||
globs = ("*",)
|
||||
else:
|
||||
if isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
globs = tuple(_safe_glob(item) for item in path_globs)
|
||||
if not globs or len(globs) > _MAX_GLOBS:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query_binding = {
|
||||
"query": query,
|
||||
"path_globs": list(globs),
|
||||
"max_matches": max_matches,
|
||||
"max_bytes": max_bytes,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
)
|
||||
scanned_bytes = 0
|
||||
skipped_for_bound = False
|
||||
binary_blobs = 0
|
||||
matched = 0
|
||||
matches: list[dict[str, object]] = []
|
||||
has_more = False
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, "", include_trees=False):
|
||||
if entry["type"] != "blob" or not any(
|
||||
fnmatch.fnmatchcase(entry["path"], pattern) for pattern in globs
|
||||
):
|
||||
continue
|
||||
blob_size = self._blob_size(path, entry["object_oid"])
|
||||
remaining = max_bytes - scanned_bytes
|
||||
if blob_size > remaining:
|
||||
skipped_for_bound = True
|
||||
continue
|
||||
raw, clipped = self._read_blob_prefix(path, entry["object_oid"], blob_size)
|
||||
if clipped:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
scanned_bytes += len(raw)
|
||||
if b"\0" in raw:
|
||||
binary_blobs += 1
|
||||
continue
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if query not in line:
|
||||
continue
|
||||
if matched < offset:
|
||||
matched += 1
|
||||
continue
|
||||
if len(matches) >= max_matches:
|
||||
has_more = True
|
||||
break
|
||||
matches.append(
|
||||
{
|
||||
"path": entry["path"],
|
||||
"line_start": number,
|
||||
"line_end": number,
|
||||
"blob_oid": entry["object_oid"],
|
||||
"snippet": line[:512],
|
||||
}
|
||||
)
|
||||
matched += 1
|
||||
if has_more:
|
||||
break
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"matches": matches,
|
||||
"scanned_bytes": scanned_bytes,
|
||||
"max_bytes": max_bytes,
|
||||
"binary_blobs_skipped": binary_blobs,
|
||||
"truncated": bool(has_more or skipped_for_bound),
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
offset=offset + len(matches),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def search_repository_source(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
path_globs = request.get("path_globs")
|
||||
if path_globs is not None and (
|
||||
isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable)
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return self.search_source(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
query=_required_request_value(request, "query"), # type: ignore[arg-type]
|
||||
path_globs=path_globs, # type: ignore[arg-type]
|
||||
max_matches=request.get("max_matches", 100), # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _iter_diff_name_entries(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
) -> Iterator[dict[str, object]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--name-status",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
while True:
|
||||
try:
|
||||
status_raw = next(iterator)
|
||||
except StopIteration:
|
||||
return
|
||||
try:
|
||||
status = status_raw.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if not status or status[0] not in {"A", "C", "D", "M", "R", "T", "U", "X"}:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
if status[0] in {"R", "C"}:
|
||||
old_path = _decode_output_path(next(iterator))
|
||||
new_path = _decode_output_path(next(iterator))
|
||||
yield {
|
||||
"status": status,
|
||||
"path": new_path,
|
||||
"old_path": old_path,
|
||||
}
|
||||
else:
|
||||
yield {
|
||||
"status": status,
|
||||
"path": _decode_output_path(next(iterator)),
|
||||
"old_path": None,
|
||||
}
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_numstat_count(value: bytes) -> int | None:
|
||||
if value == b"-":
|
||||
return None
|
||||
try:
|
||||
parsed = int(value.decode("ascii"), 10)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if parsed < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return parsed
|
||||
|
||||
def _diff_stats(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
wanted_paths: set[str],
|
||||
) -> dict[str, tuple[int | None, int | None]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--numstat",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
stats: dict[str, tuple[int | None, int | None]] = {}
|
||||
for row in iterator:
|
||||
if not row:
|
||||
continue
|
||||
fields = row.split(b"\t", 2)
|
||||
if len(fields) != 3:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
additions = self._parse_numstat_count(fields[0])
|
||||
deletions = self._parse_numstat_count(fields[1])
|
||||
if fields[2]:
|
||||
stat_path = _decode_output_path(fields[2])
|
||||
else:
|
||||
try:
|
||||
next(iterator) # old path
|
||||
stat_path = _decode_output_path(next(iterator))
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if stat_path in wanted_paths:
|
||||
stats[stat_path] = (additions, deletions)
|
||||
if wanted_paths <= set(stats):
|
||||
break
|
||||
return stats
|
||||
|
||||
def diff_index(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
from_revision: str,
|
||||
to_revision: str,
|
||||
prefix: str = "",
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
from_oid = self._resolve_at(path, from_revision, "from")
|
||||
to_oid = self._resolve_at(path, to_revision, "to")
|
||||
query = {
|
||||
"from_oid": from_oid,
|
||||
"to_oid": to_oid,
|
||||
"prefix": prefix,
|
||||
"limit": limit,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, object]] = []
|
||||
total = 0
|
||||
for entry in self._iter_diff_name_entries(path, from_oid, to_oid, prefix):
|
||||
if total >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
total += 1
|
||||
wanted_paths = {str(entry["path"]) for entry in page}
|
||||
stat_by_path = (
|
||||
self._diff_stats(path, from_oid, to_oid, prefix, wanted_paths)
|
||||
if page
|
||||
else {}
|
||||
)
|
||||
for entry in page:
|
||||
additions, deletions = stat_by_path.get(
|
||||
str(entry["path"]), (None, None)
|
||||
)
|
||||
entry["additions"] = additions
|
||||
entry["deletions"] = deletions
|
||||
has_more = offset + len(page) < total
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_from_oid": from_oid,
|
||||
"resolved_to_oid": to_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def get_diff_index(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
from_revision = request.get("from_revision", request.get("from"))
|
||||
to_revision = request.get("to_revision", request.get("to"))
|
||||
prefix = request.get("prefix", request.get("path_prefix", ""))
|
||||
if from_revision is None or to_revision is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.diff_index(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
from_revision=from_revision, # type: ignore[arg-type]
|
||||
to_revision=to_revision, # type: ignore[arg-type]
|
||||
prefix=prefix, # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
file_path: str,
|
||||
max_bytes: int = 262_144,
|
||||
) -> dict[str, object]:
|
||||
file_path = _safe_relative_path(file_path, "file_path", allow_empty=False)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
exact: list[dict[str, str]] = []
|
||||
for entry in self._iter_tree_entries(
|
||||
path, resolved_oid, file_path, include_trees=False
|
||||
):
|
||||
if entry["path"] == file_path and entry["type"] == "blob":
|
||||
exact.append(entry)
|
||||
if len(exact) != 1:
|
||||
raise RepositoryDiscoveryNotFound("REPOSITORY_DISCOVERY_FILE_NOT_FOUND")
|
||||
blob_oid = exact[0]["object_oid"]
|
||||
content, truncated = self._read_blob_prefix(path, blob_oid, max_bytes)
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"blob_oid": blob_oid,
|
||||
"path": file_path,
|
||||
"content": content.decode("utf-8", errors="replace"),
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"content_bytes": len(content),
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
def read_repository_file(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
file_path = request.get("file_path", request.get("path"))
|
||||
if file_path is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.read_file(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
file_path=file_path, # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def create_repository_discovery(
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> RepositoryDiscovery:
|
||||
return RepositoryDiscovery(
|
||||
allowed_repositories=allowed_repositories,
|
||||
git_dir=git_dir,
|
||||
cursor_secret=cursor_secret,
|
||||
)
|
||||
|
||||
|
||||
def list_repository_tree(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).list_repository_tree(request)
|
||||
|
||||
|
||||
def search_repository_source(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).search_repository_source(request)
|
||||
|
||||
|
||||
def get_diff_index(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).get_diff_index(request)
|
||||
|
||||
|
||||
def read_repository_file(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).read_repository_file(request)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
"""Single-host operational delegation for AIOS migration.
|
||||
|
||||
This module is deliberately narrow:
|
||||
* Owner signs only OperationalDelegation and ExpiredTaskRecoveryDecision.
|
||||
* Machine signer signs only WorkerRuntimeBinding and WorkerRuntimeProof.
|
||||
* Every object is bound to one immutable Task identity and a narrow capability set.
|
||||
* There is no arbitrary sign-bytes API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_MACHINE_SIGNER_ID,
|
||||
DEFAULT_OWNER_SIGNER_ID,
|
||||
MACHINE_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
RegistryOwnerApprovalVerifier,
|
||||
SignerRegistry,
|
||||
SingleHostAuthorityError,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
verify_sshsig,
|
||||
)
|
||||
|
||||
OPERATIONAL_POLICY_SCHEMA = "aios.single-host-operational-policy.v1"
|
||||
OPERATIONAL_DELEGATION_SCHEMA = "aios.owner-operational-delegation.v1"
|
||||
RECOVERY_DECISION_SCHEMA = "aios.expired-task-recovery-decision.v1"
|
||||
WORKER_BINDING_SCHEMA = "aios.worker-runtime-binding.v1"
|
||||
WORKER_PROOF_SCHEMA = "aios.worker-runtime-proof.v1"
|
||||
REVOCATION_SCHEMA = "aios.operational-revocations.v1"
|
||||
|
||||
ALLOWED_OPERATIONAL_CAPABILITIES = frozenset({
|
||||
"task.recovery",
|
||||
"worker.runtime.bind",
|
||||
"worker.runtime.proof",
|
||||
"task.claim.guard",
|
||||
})
|
||||
ALLOWED_EXPECTED_STATE = "EXPIRED"
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
|
||||
|
||||
class OperationalAuthorityError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _exact_fields(value: Mapping[str, Any], fields: set[str], code: str) -> None:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
fail(code)
|
||||
|
||||
|
||||
def _safe_id(value: str, code: str) -> str:
|
||||
if not isinstance(value, str) or not SAFE_ID.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _hex(value: str, n: int, code: str) -> str:
|
||||
regex = HEX40 if n == 40 else HEX64
|
||||
if not isinstance(value, str) or not regex.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _caps(value: Sequence[str], code: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)) or not value:
|
||||
fail(code)
|
||||
out = tuple(sorted(set(value)))
|
||||
if len(out) != len(value) or any(not isinstance(x, str) or not CAP.fullmatch(x) for x in out):
|
||||
fail(code)
|
||||
return out
|
||||
|
||||
|
||||
def task_identity_sha256(task: Mapping[str, Any]) -> str:
|
||||
"""Digest only immutable recovery/claim identity fields.
|
||||
|
||||
Revision, state, attempt and deadline are intentionally excluded.
|
||||
"""
|
||||
fields = (
|
||||
"task_id", "repository", "target_branch", "base_oid", "generation",
|
||||
"execution_plan_sha256", "external_job_identity",
|
||||
)
|
||||
try:
|
||||
value = {name: task[name] for name in fields}
|
||||
except KeyError as exc:
|
||||
raise OperationalAuthorityError("TASK_IDENTITY_FIELD_MISSING") from exc
|
||||
_safe_id(str(value["task_id"]), "TASK_ID_INVALID")
|
||||
_safe_id(str(value["repository"]), "REPOSITORY_INVALID")
|
||||
_safe_id(str(value["target_branch"]), "TARGET_BRANCH_INVALID")
|
||||
_hex(str(value["base_oid"]), 40, "BASE_OID_INVALID")
|
||||
if type(value["generation"]) is not int or value["generation"] < 1:
|
||||
fail("GENERATION_INVALID")
|
||||
_hex(str(value["execution_plan_sha256"]), 64, "EXECUTION_PLAN_DIGEST_INVALID")
|
||||
_safe_id(str(value["external_job_identity"]), "EXTERNAL_JOB_IDENTITY_INVALID")
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationalPolicy:
|
||||
repository: str
|
||||
task_id: str
|
||||
task_identity_sha256: str
|
||||
target_branch: str
|
||||
base_oid: str
|
||||
generation: int
|
||||
execution_plan_sha256: str
|
||||
external_job_identity: str
|
||||
allowed_capabilities: tuple[str, ...]
|
||||
allowed_worker_ids: tuple[str, ...]
|
||||
maximum_delegation_ttl_seconds: int
|
||||
maximum_recovery_decision_ttl_seconds: int
|
||||
maximum_binding_ttl_seconds: int
|
||||
maximum_proof_ttl_seconds: int
|
||||
maximum_heartbeat_age_seconds: int
|
||||
maximum_task_deadline_extension_seconds: int
|
||||
schema: str = OPERATIONAL_POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.schema != OPERATIONAL_POLICY_SCHEMA:
|
||||
fail("OPERATIONAL_POLICY_SCHEMA_INVALID")
|
||||
_safe_id(self.repository, "OPERATIONAL_POLICY_REPOSITORY_INVALID")
|
||||
_safe_id(self.task_id, "OPERATIONAL_POLICY_TASK_ID_INVALID")
|
||||
_hex(self.task_identity_sha256, 64, "OPERATIONAL_POLICY_TASK_IDENTITY_INVALID")
|
||||
_safe_id(self.target_branch, "OPERATIONAL_POLICY_BRANCH_INVALID")
|
||||
_hex(self.base_oid, 40, "OPERATIONAL_POLICY_BASE_OID_INVALID")
|
||||
if type(self.generation) is not int or self.generation < 1:
|
||||
fail("OPERATIONAL_POLICY_GENERATION_INVALID")
|
||||
_hex(self.execution_plan_sha256, 64, "OPERATIONAL_POLICY_PLAN_INVALID")
|
||||
_safe_id(self.external_job_identity, "OPERATIONAL_POLICY_JOB_INVALID")
|
||||
caps = _caps(self.allowed_capabilities, "OPERATIONAL_POLICY_CAPABILITIES_INVALID")
|
||||
if not set(caps).issubset(ALLOWED_OPERATIONAL_CAPABILITIES):
|
||||
fail("OPERATIONAL_POLICY_CAPABILITY_WIDENING")
|
||||
if not self.allowed_worker_ids or any(not SAFE_ID.fullmatch(x) for x in self.allowed_worker_ids):
|
||||
fail("OPERATIONAL_POLICY_WORKERS_INVALID")
|
||||
bounds = (
|
||||
(self.maximum_delegation_ttl_seconds, 300, 7 * 24 * 3600),
|
||||
(self.maximum_recovery_decision_ttl_seconds, 30, 900),
|
||||
(self.maximum_binding_ttl_seconds, 60, 24 * 3600),
|
||||
(self.maximum_proof_ttl_seconds, 15, 900),
|
||||
(self.maximum_heartbeat_age_seconds, 5, 300),
|
||||
(self.maximum_task_deadline_extension_seconds, 300, 24 * 3600),
|
||||
)
|
||||
if any(type(v) is not int or not lo <= v <= hi for v, lo, hi in bounds):
|
||||
fail("OPERATIONAL_POLICY_TTL_INVALID")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
self.validate()
|
||||
return sha256_json(asdict(self))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "OperationalPolicy":
|
||||
fields = set(cls.__dataclass_fields__)
|
||||
_exact_fields(value, fields, "OPERATIONAL_POLICY_FIELDS_INVALID")
|
||||
try:
|
||||
policy = cls(
|
||||
repository=value["repository"],
|
||||
task_id=value["task_id"],
|
||||
task_identity_sha256=value["task_identity_sha256"],
|
||||
target_branch=value["target_branch"],
|
||||
base_oid=value["base_oid"],
|
||||
generation=value["generation"],
|
||||
execution_plan_sha256=value["execution_plan_sha256"],
|
||||
external_job_identity=value["external_job_identity"],
|
||||
allowed_capabilities=tuple(value["allowed_capabilities"]),
|
||||
allowed_worker_ids=tuple(value["allowed_worker_ids"]),
|
||||
maximum_delegation_ttl_seconds=value["maximum_delegation_ttl_seconds"],
|
||||
maximum_recovery_decision_ttl_seconds=value["maximum_recovery_decision_ttl_seconds"],
|
||||
maximum_binding_ttl_seconds=value["maximum_binding_ttl_seconds"],
|
||||
maximum_proof_ttl_seconds=value["maximum_proof_ttl_seconds"],
|
||||
maximum_heartbeat_age_seconds=value["maximum_heartbeat_age_seconds"],
|
||||
maximum_task_deadline_extension_seconds=value["maximum_task_deadline_extension_seconds"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
except Exception as exc:
|
||||
raise OperationalAuthorityError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def build_exact_policy(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
worker_id: str,
|
||||
allowed_capabilities: Sequence[str] = tuple(sorted(ALLOWED_OPERATIONAL_CAPABILITIES)),
|
||||
) -> OperationalPolicy:
|
||||
identity = task_identity_sha256(task)
|
||||
policy = OperationalPolicy(
|
||||
repository=str(task["repository"]),
|
||||
task_id=str(task["task_id"]),
|
||||
task_identity_sha256=identity,
|
||||
target_branch=str(task["target_branch"]),
|
||||
base_oid=str(task["base_oid"]),
|
||||
generation=int(task["generation"]),
|
||||
execution_plan_sha256=str(task["execution_plan_sha256"]),
|
||||
external_job_identity=str(task["external_job_identity"]),
|
||||
allowed_capabilities=tuple(sorted(allowed_capabilities)),
|
||||
allowed_worker_ids=(worker_id,),
|
||||
maximum_delegation_ttl_seconds=24 * 3600,
|
||||
maximum_recovery_decision_ttl_seconds=300,
|
||||
maximum_binding_ttl_seconds=4 * 3600,
|
||||
maximum_proof_ttl_seconds=300,
|
||||
maximum_heartbeat_age_seconds=30,
|
||||
maximum_task_deadline_extension_seconds=4 * 3600,
|
||||
)
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def _signed_payload_dict(payload: Mapping[str, Any], *, key_id: str, namespace: str) -> dict[str, Any]:
|
||||
value = dict(payload)
|
||||
value["signature_namespace"] = namespace
|
||||
value["signature_key_id"] = key_id
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return value
|
||||
|
||||
|
||||
def _verify_digest_field(payload: Mapping[str, Any]) -> bool:
|
||||
if not isinstance(payload, Mapping) or "canonical_payload_digest" not in payload:
|
||||
return False
|
||||
value = dict(payload)
|
||||
supplied = value.pop("canonical_payload_digest", None)
|
||||
return isinstance(supplied, str) and supplied == sha256_json(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SignedOperationalArtifact:
|
||||
payload: dict[str, Any]
|
||||
signature: str
|
||||
key_id: str
|
||||
namespace: str
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> bytes:
|
||||
return canonical_json_bytes(self.payload)
|
||||
|
||||
@property
|
||||
def payload_sha256(self) -> str:
|
||||
return hashlib.sha256(self.canonical_payload).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(self.payload),
|
||||
"signature": self.signature,
|
||||
"key_id": self.key_id,
|
||||
"namespace": self.namespace,
|
||||
}
|
||||
|
||||
|
||||
class OperationalOwnerAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=OWNER_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_operational_delegation(self, *, delegation_id: str, nonce: str, ttl_seconds: int) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
if not SAFE_ID.fullmatch(delegation_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("OPERATIONAL_DELEGATION_ID_INVALID")
|
||||
if type(ttl_seconds) is not int or not 60 <= ttl_seconds <= p.maximum_delegation_ttl_seconds:
|
||||
fail("OPERATIONAL_DELEGATION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": OPERATIONAL_DELEGATION_SCHEMA,
|
||||
"delegation_id": delegation_id,
|
||||
"nonce": nonce,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"allowed_capabilities": list(p.allowed_capabilities),
|
||||
"allowed_worker_ids": list(p.allowed_worker_ids),
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
"revocation_epoch": 0,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_recovery_decision(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
decision_id: str,
|
||||
nonce: str,
|
||||
expected_revision: int,
|
||||
expected_attempt: int,
|
||||
original_deadline: int,
|
||||
new_deadline: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
verify_delegation_scope(delegation, self.policy, now=now)
|
||||
if "task.recovery" not in delegation["allowed_capabilities"]:
|
||||
fail("RECOVERY_CAPABILITY_NOT_DELEGATED")
|
||||
if not SAFE_ID.fullmatch(decision_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("RECOVERY_DECISION_ID_INVALID")
|
||||
if type(expected_revision) is not int or expected_revision < 0 or type(expected_attempt) is not int or expected_attempt < 0:
|
||||
fail("RECOVERY_CAS_INVALID")
|
||||
if type(original_deadline) is not int or type(new_deadline) is not int:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline <= now or new_deadline <= original_deadline:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline - now > self.policy.maximum_task_deadline_extension_seconds:
|
||||
fail("RECOVERY_DEADLINE_WIDENING")
|
||||
if type(ttl_seconds) is not int or not 15 <= ttl_seconds <= self.policy.maximum_recovery_decision_ttl_seconds:
|
||||
fail("RECOVERY_DECISION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": RECOVERY_DECISION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"task_id": self.policy.task_id,
|
||||
"task_identity_sha256": self.policy.task_identity_sha256,
|
||||
"expected_state": ALLOWED_EXPECTED_STATE,
|
||||
"expected_revision": expected_revision,
|
||||
"expected_attempt": expected_attempt,
|
||||
"original_deadline": original_deadline,
|
||||
"new_deadline": new_deadline,
|
||||
"repository": self.policy.repository,
|
||||
"target_branch": self.policy.target_branch,
|
||||
"base_oid": self.policy.base_oid,
|
||||
"generation": self.policy.generation,
|
||||
"execution_plan_sha256": self.policy.execution_plan_sha256,
|
||||
"external_job_identity": self.policy.external_job_identity,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
class OperationalMachineAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_MACHINE_SIGNER_ID,
|
||||
namespace=MACHINE_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=MACHINE_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_runtime_binding(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
binding_id: str,
|
||||
nonce: str,
|
||||
worker_id: str,
|
||||
runtime_id: str,
|
||||
runtime_sha256: str,
|
||||
task_contract_version: str,
|
||||
supported_contract_versions: Sequence[str],
|
||||
required_capabilities: Sequence[str],
|
||||
worker_capabilities: Sequence[str],
|
||||
max_concurrency: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
if "worker.runtime.bind" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_BIND_CAPABILITY_NOT_DELEGATED")
|
||||
if worker_id not in p.allowed_worker_ids:
|
||||
fail("WORKER_ID_NOT_DELEGATED")
|
||||
for val, code in [(binding_id, "BINDING_ID_INVALID"), (nonce, "BINDING_NONCE_INVALID"), (worker_id, "WORKER_ID_INVALID"), (runtime_id, "RUNTIME_ID_INVALID"), (task_contract_version, "CONTRACT_VERSION_INVALID")]:
|
||||
_safe_id(val, code)
|
||||
_hex(runtime_sha256, 64, "RUNTIME_SHA256_INVALID")
|
||||
supported = tuple(sorted(set(supported_contract_versions)))
|
||||
if task_contract_version not in supported or not supported or any(not SAFE_ID.fullmatch(x) for x in supported):
|
||||
fail("CONTRACT_INCOMPATIBLE")
|
||||
required = _caps(required_capabilities, "REQUIRED_CAPABILITIES_INVALID")
|
||||
worker_caps = _caps(worker_capabilities, "WORKER_CAPABILITIES_INVALID")
|
||||
if not set(required).issubset(set(worker_caps)):
|
||||
fail("CAPABILITY_MISMATCH")
|
||||
if type(max_concurrency) is not int or not 1 <= max_concurrency <= 64:
|
||||
fail("MAX_CONCURRENCY_INVALID")
|
||||
if type(ttl_seconds) is not int or not 30 <= ttl_seconds <= p.maximum_binding_ttl_seconds:
|
||||
fail("BINDING_TTL_INVALID")
|
||||
if now + ttl_seconds > int(delegation["expires_at"]):
|
||||
fail("BINDING_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_BINDING_SCHEMA,
|
||||
"binding_id": binding_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"worker_id": worker_id,
|
||||
"runtime_id": runtime_id,
|
||||
"runtime_sha256": runtime_sha256,
|
||||
"task_contract_version": task_contract_version,
|
||||
"supported_contract_versions": list(supported),
|
||||
"required_capabilities": list(required),
|
||||
"worker_capabilities": list(worker_caps),
|
||||
"max_concurrency": max_concurrency,
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_runtime_proof(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
binding: Mapping[str, Any],
|
||||
*,
|
||||
proof_id: str,
|
||||
nonce: str,
|
||||
heartbeat_at: int,
|
||||
adapter_readiness: bool,
|
||||
resource_readiness: bool,
|
||||
active_claim_count: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
verify_binding_scope(binding, delegation, p, now=now)
|
||||
if "worker.runtime.proof" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_PROOF_CAPABILITY_NOT_DELEGATED")
|
||||
_safe_id(proof_id, "PROOF_ID_INVALID")
|
||||
_safe_id(nonce, "PROOF_NONCE_INVALID")
|
||||
if type(heartbeat_at) is not int or heartbeat_at > now + 5 or now - heartbeat_at > p.maximum_heartbeat_age_seconds:
|
||||
fail("HEARTBEAT_STALE")
|
||||
if adapter_readiness is not True or resource_readiness is not True:
|
||||
fail("RUNTIME_NOT_READY")
|
||||
if type(active_claim_count) is not int or active_claim_count < 0 or active_claim_count >= int(binding["max_concurrency"]):
|
||||
fail("MAX_CONCURRENCY_REACHED")
|
||||
if type(ttl_seconds) is not int or not 10 <= ttl_seconds <= p.maximum_proof_ttl_seconds:
|
||||
fail("PROOF_TTL_INVALID")
|
||||
if now + ttl_seconds > min(int(binding["expires_at"]), int(delegation["expires_at"])):
|
||||
fail("PROOF_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_PROOF_SCHEMA,
|
||||
"proof_id": proof_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"binding_id": binding["binding_id"],
|
||||
"binding_digest": hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"worker_id": binding["worker_id"],
|
||||
"runtime_id": binding["runtime_id"],
|
||||
"runtime_sha256": binding["runtime_sha256"],
|
||||
"heartbeat_at": heartbeat_at,
|
||||
"adapter_readiness": True,
|
||||
"resource_readiness": True,
|
||||
"active_claim_count": active_claim_count,
|
||||
"max_concurrency": binding["max_concurrency"],
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
DELEGATION_FIELDS = {
|
||||
"schema","delegation_id","nonce","owner_identity","repository","task_id",
|
||||
"task_identity_sha256","target_branch","base_oid","generation","execution_plan_sha256",
|
||||
"external_job_identity","allowed_capabilities","allowed_worker_ids","policy_digest",
|
||||
"issued_at","not_before","expires_at","revocation_epoch","status",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
BINDING_FIELDS = {
|
||||
"schema","binding_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"repository","task_id","task_identity_sha256","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities","worker_capabilities",
|
||||
"max_concurrency","policy_digest","issued_at","not_before","expires_at",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
PROOF_FIELDS = {
|
||||
"schema","proof_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"binding_id","binding_digest","repository","task_id","task_identity_sha256","generation",
|
||||
"execution_plan_sha256","worker_id","runtime_id","runtime_sha256","heartbeat_at",
|
||||
"adapter_readiness","resource_readiness","active_claim_count","max_concurrency",
|
||||
"policy_digest","issued_at","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
RECOVERY_FIELDS = {
|
||||
"schema","decision_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"task_id","task_identity_sha256","expected_state","expected_revision","expected_attempt",
|
||||
"original_deadline","new_deadline","repository","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","policy_digest","one_time_use",
|
||||
"issued_at","not_before","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_delegation_scope(value: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
policy.validate()
|
||||
_exact_fields(value, DELEGATION_FIELDS, "OPERATIONAL_DELEGATION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
value["schema"] != OPERATIONAL_DELEGATION_SCHEMA
|
||||
or value["status"] != "ACTIVE"
|
||||
or value["revocation_epoch"] != 0
|
||||
or value["owner_identity"] != OWNER_IDENTITY
|
||||
or value["repository"] != policy.repository
|
||||
or value["task_id"] != policy.task_id
|
||||
or value["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or value["target_branch"] != policy.target_branch
|
||||
or value["base_oid"] != policy.base_oid
|
||||
or value["generation"] != policy.generation
|
||||
or value["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or value["external_job_identity"] != policy.external_job_identity
|
||||
or value["policy_digest"] != policy.digest
|
||||
or not set(value["allowed_capabilities"]).issubset(set(policy.allowed_capabilities))
|
||||
or not set(value["allowed_worker_ids"]).issubset(set(policy.allowed_worker_ids))
|
||||
or value["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(value)
|
||||
or not int(value["not_before"]) <= now < int(value["expires_at"])
|
||||
):
|
||||
fail("OPERATIONAL_DELEGATION_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_binding_scope(binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(binding, BINDING_FIELDS, "WORKER_BINDING_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
expected_parent = hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
if (
|
||||
binding["schema"] != WORKER_BINDING_SCHEMA
|
||||
or binding["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or binding["parent_delegation_digest"] != expected_parent
|
||||
or binding["repository"] != policy.repository
|
||||
or binding["task_id"] != policy.task_id
|
||||
or binding["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or binding["target_branch"] != policy.target_branch
|
||||
or binding["base_oid"] != policy.base_oid
|
||||
or binding["generation"] != policy.generation
|
||||
or binding["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or binding["external_job_identity"] != policy.external_job_identity
|
||||
or binding["worker_id"] not in policy.allowed_worker_ids
|
||||
or binding["policy_digest"] != policy.digest
|
||||
or binding["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or not _verify_digest_field(binding)
|
||||
or not int(binding["not_before"]) <= now < int(binding["expires_at"])
|
||||
):
|
||||
fail("WORKER_BINDING_SCOPE_INVALID")
|
||||
required = set(binding["required_capabilities"])
|
||||
worker = set(binding["worker_capabilities"])
|
||||
if not required.issubset(worker) or binding["task_contract_version"] not in binding["supported_contract_versions"]:
|
||||
fail("WORKER_BINDING_COMPATIBILITY_INVALID")
|
||||
|
||||
|
||||
def verify_proof_scope(proof: Mapping[str, Any], binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(proof, PROOF_FIELDS, "WORKER_PROOF_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
proof["schema"] != WORKER_PROOF_SCHEMA
|
||||
or proof["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or proof["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or proof["binding_id"] != binding["binding_id"]
|
||||
or proof["binding_digest"] != hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest()
|
||||
or proof["repository"] != policy.repository
|
||||
or proof["task_id"] != policy.task_id
|
||||
or proof["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or proof["generation"] != policy.generation
|
||||
or proof["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or proof["worker_id"] != binding["worker_id"]
|
||||
or proof["runtime_id"] != binding["runtime_id"]
|
||||
or proof["runtime_sha256"] != binding["runtime_sha256"]
|
||||
or proof["max_concurrency"] != binding["max_concurrency"]
|
||||
or proof["policy_digest"] != policy.digest
|
||||
or proof["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or proof["adapter_readiness"] is not True
|
||||
or proof["resource_readiness"] is not True
|
||||
or not _verify_digest_field(proof)
|
||||
or not now < int(proof["expires_at"])
|
||||
or now - int(proof["heartbeat_at"]) > policy.maximum_heartbeat_age_seconds
|
||||
or int(proof["active_claim_count"]) >= int(proof["max_concurrency"])
|
||||
):
|
||||
fail("WORKER_PROOF_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_recovery_scope(decision: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(decision, RECOVERY_FIELDS, "RECOVERY_DECISION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
decision["schema"] != RECOVERY_DECISION_SCHEMA
|
||||
or decision["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or decision["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or decision["task_id"] != policy.task_id
|
||||
or decision["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or decision["expected_state"] != "EXPIRED"
|
||||
or decision["repository"] != policy.repository
|
||||
or decision["target_branch"] != policy.target_branch
|
||||
or decision["base_oid"] != policy.base_oid
|
||||
or decision["generation"] != policy.generation
|
||||
or decision["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or decision["external_job_identity"] != policy.external_job_identity
|
||||
or decision["policy_digest"] != policy.digest
|
||||
or decision["one_time_use"] is not True
|
||||
or decision["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(decision)
|
||||
or not int(decision["not_before"]) <= now < int(decision["expires_at"])
|
||||
or int(decision["new_deadline"]) <= now
|
||||
):
|
||||
fail("RECOVERY_DECISION_SCOPE_INVALID")
|
||||
|
||||
|
||||
class OperationalVerifier:
|
||||
"""Verify signatures through the public root-owned SignerRegistry."""
|
||||
|
||||
def __init__(self, registry_path: Path, *, require_root_owner: bool = True):
|
||||
self.registry_path = Path(registry_path)
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _verify(self, artifact: SignedOperationalArtifact | Mapping[str, Any], *, role: str, signer_id: str, namespace: str) -> dict[str, Any]:
|
||||
if isinstance(artifact, SignedOperationalArtifact):
|
||||
payload, signature, key_id, artifact_namespace = artifact.payload, artifact.signature, artifact.key_id, artifact.namespace
|
||||
else:
|
||||
_exact_fields(artifact, {"payload","signature","key_id","namespace"}, "SIGNED_ARTIFACT_FIELDS_INVALID")
|
||||
payload, signature, key_id, artifact_namespace = artifact["payload"], artifact["signature"], artifact["key_id"], artifact["namespace"]
|
||||
if artifact_namespace != namespace:
|
||||
fail("SIGNED_ARTIFACT_NAMESPACE_INVALID")
|
||||
registry = SignerRegistry(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
rec = registry.require(signer_id, role=role)
|
||||
if rec.fingerprint != key_id:
|
||||
fail("SIGNED_ARTIFACT_KEY_ID_INVALID")
|
||||
if role == "MACHINE_OPERATIONAL":
|
||||
owner_rec = registry.require(DEFAULT_OWNER_SIGNER_ID, role="OWNER_AUTHORITY")
|
||||
if rec.parent_signer_id != owner_rec.signer_id or rec.parent_fingerprint != owner_rec.fingerprint:
|
||||
fail("MACHINE_PARENT_BINDING_INVALID")
|
||||
# RegistryOwnerApprovalVerifier handles Owner namespace; generic machine uses registry public key.
|
||||
if role == "OWNER_AUTHORITY":
|
||||
verifier = RegistryOwnerApprovalVerifier(self.registry_path, require_root_owner=self.require_root_owner, signer_id=signer_id)
|
||||
ok = verifier.verify(namespace=namespace, payload=canonical_json_bytes(payload), signature=signature, key_id=key_id)
|
||||
else:
|
||||
from aios_single_host_authority import _load_public_key_line
|
||||
pub = _load_public_key_line(rec.public_key)
|
||||
ok = verify_sshsig(pub, canonical_json_bytes(payload), signature, namespace=namespace, expected_key_id=key_id)
|
||||
if not ok:
|
||||
fail("SIGNED_ARTIFACT_SIGNATURE_INVALID")
|
||||
return dict(payload)
|
||||
|
||||
def owner(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="OWNER_AUTHORITY", signer_id=DEFAULT_OWNER_SIGNER_ID, namespace=OWNER_NAMESPACE)
|
||||
|
||||
def machine(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="MACHINE_OPERATIONAL", signer_id=DEFAULT_MACHINE_SIGNER_ID, namespace=MACHINE_NAMESPACE)
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Unix-domain fixed-schema service for operational migration authority."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_REGISTRY_PATH,
|
||||
OWNER_NAMESPACE,
|
||||
MACHINE_NAMESPACE,
|
||||
SingleHostAuthorityError,
|
||||
)
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalMachineAuthority,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
OperationalVerifier,
|
||||
SignedOperationalArtifact,
|
||||
verify_binding_scope,
|
||||
verify_delegation_scope,
|
||||
)
|
||||
|
||||
DEFAULT_POLICY = Path("/etc/aios/single-host-operational-policy.json")
|
||||
DEFAULT_OWNER_KEY = Path("/var/lib/aios-owner-authority/owner-authority.key")
|
||||
DEFAULT_MACHINE_KEY = Path("/var/lib/aios-machine-signer/machine-operational.key")
|
||||
DEFAULT_OWNER_SOCKET = Path("/run/aios/owner-authority.sock")
|
||||
DEFAULT_MACHINE_SOCKET = Path("/run/aios/machine-signer.sock")
|
||||
DEFAULT_LEDGER = Path("/var/lib/aios-operational-authority/issuance-ledger.sqlite3")
|
||||
MAX_REQUEST = 128 * 1024
|
||||
READINESS_SCHEMA = "aios.signer-readiness-challenge.v1"
|
||||
|
||||
|
||||
class OperationalServiceError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalServiceError(code)
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool = True) -> OperationalPolicy:
|
||||
path=Path(path)
|
||||
if not path.is_absolute():
|
||||
fail("OPERATIONAL_POLICY_PATH_INVALID")
|
||||
flags=os.O_RDONLY
|
||||
if hasattr(os,"O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd=os.open(path,flags)
|
||||
except OSError as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_UNAVAILABLE") from exc
|
||||
try:
|
||||
st=os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
fail("OPERATIONAL_POLICY_NOT_REGULAR")
|
||||
mode=stat.S_IMODE(st.st_mode)
|
||||
if mode & 0o022:
|
||||
fail("OPERATIONAL_POLICY_WRITABLE_BY_GROUP_OR_OTHER")
|
||||
if require_root_owner and st.st_uid != 0:
|
||||
fail("OPERATIONAL_POLICY_NOT_ROOT_OWNED")
|
||||
raw=bytearray()
|
||||
while len(raw) <= MAX_REQUEST:
|
||||
chunk=os.read(fd,min(65536,MAX_REQUEST+1-len(raw)))
|
||||
if not chunk: break
|
||||
raw.extend(chunk)
|
||||
if not raw or len(raw)>MAX_REQUEST:
|
||||
fail("OPERATIONAL_POLICY_UNAVAILABLE")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
obj=json.loads(bytes(raw).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_INVALID") from exc
|
||||
return OperationalPolicy.from_mapping(obj)
|
||||
|
||||
|
||||
def _artifact(value: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
if not isinstance(value, Mapping) or set(value) != {"payload","signature","key_id","namespace"}:
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
if not isinstance(value["payload"], Mapping):
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
return SignedOperationalArtifact(
|
||||
payload=dict(value["payload"]),
|
||||
signature=str(value["signature"]),
|
||||
key_id=str(value["key_id"]),
|
||||
namespace=str(value["namespace"]),
|
||||
)
|
||||
|
||||
|
||||
def _peer_uid(conn: socket.socket) -> int:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
fail("PEER_CREDENTIALS_UNAVAILABLE")
|
||||
pid, uid, gid = struct.unpack("3i", conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
class DurableIssuanceLedger:
|
||||
"""Root-owned exact-idempotent structured signing ledger."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if os.name == "posix": os.chmod(self.path.parent, 0o700)
|
||||
if self.path.exists() and self.path.is_symlink():
|
||||
fail("ISSUANCE_LEDGER_SYMLINK_FORBIDDEN")
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=DELETE")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS issuance (
|
||||
role TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(role, operation, nonce)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def issue(self, *, role: str, operation: str, nonce: str, request: Mapping[str, Any], issuer: Any) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(request, sort_keys=True, separators=(",",":"), ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest()
|
||||
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT request_sha256,response_json FROM issuance WHERE role=? AND operation=? AND nonce=?",
|
||||
(role, operation, nonce),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != request_sha:
|
||||
conn.rollback()
|
||||
fail("NONCE_REUSE_WITH_DIFFERENT_REQUEST")
|
||||
result = json.loads(row[1])
|
||||
conn.commit()
|
||||
return result
|
||||
result = issuer()
|
||||
encoded = json.dumps(result, sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
||||
conn.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
class OperationalAuthorityService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
policy_path: Path = DEFAULT_POLICY,
|
||||
registry_path: Path = DEFAULT_REGISTRY_PATH,
|
||||
owner_key_path: Path = DEFAULT_OWNER_KEY,
|
||||
machine_key_path: Path = DEFAULT_MACHINE_KEY,
|
||||
socket_path: Path | None = None,
|
||||
ledger_path: Path = DEFAULT_LEDGER,
|
||||
allowed_uids: tuple[int, ...] = (0,),
|
||||
clock: Any = time.time,
|
||||
require_root_owner: bool = True,
|
||||
):
|
||||
if role not in {"owner","machine"}:
|
||||
fail("ROLE_INVALID")
|
||||
self.role = role
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.machine_key_path = Path(machine_key_path)
|
||||
self.socket_path = Path(socket_path or (DEFAULT_OWNER_SOCKET if role == "owner" else DEFAULT_MACHINE_SOCKET))
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.ledger = DurableIssuanceLedger(ledger_path)
|
||||
self.clock = clock
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(self.policy_path, require_root_owner=self.require_root_owner)
|
||||
verifier = OperationalVerifier(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
if self.role == "owner":
|
||||
signer = OperationalOwnerAuthority(self.owner_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(self.machine_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation","payload"} or not isinstance(request["payload"], Mapping):
|
||||
fail("REQUEST_INVALID")
|
||||
op = request["operation"]
|
||||
p = request["payload"]
|
||||
policy, verifier, signer = self._components()
|
||||
|
||||
if op == "identity":
|
||||
return {
|
||||
"status":"PASS",
|
||||
"role":self.role,
|
||||
"policy_digest":policy.digest,
|
||||
"key_id":signer.fingerprint,
|
||||
"socket":str(self.socket_path),
|
||||
"arbitrary_signing":False,
|
||||
}
|
||||
|
||||
if op == "readiness_challenge":
|
||||
if set(p) != {"challenge_id","challenge_sha256"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
challenge_id = str(p["challenge_id"])
|
||||
challenge_sha256 = str(p["challenge_sha256"])
|
||||
if not challenge_id or len(challenge_id) > 160:
|
||||
fail("READINESS_CHALLENGE_ID_INVALID")
|
||||
if not isinstance(challenge_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", challenge_sha256):
|
||||
fail("READINESS_CHALLENGE_SHA256_INVALID")
|
||||
namespace = OWNER_NAMESPACE if self.role == "owner" else MACHINE_NAMESPACE
|
||||
def issue():
|
||||
art = signer._sign({
|
||||
"schema": READINESS_SCHEMA,
|
||||
"role": self.role,
|
||||
"challenge_id": challenge_id,
|
||||
"challenge_sha256": challenge_sha256,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": int(self.clock()),
|
||||
})
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(
|
||||
role=self.role,
|
||||
operation=op,
|
||||
nonce=challenge_id,
|
||||
request=p,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
if self.role == "owner":
|
||||
if op == "issue_operational_delegation":
|
||||
if set(p) != {"delegation_id","nonce","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
def issue():
|
||||
art = signer.issue_operational_delegation(
|
||||
delegation_id=str(p["delegation_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
if op == "issue_expired_task_recovery":
|
||||
if set(p) != {"delegation","decision_id","nonce","expected_revision","expected_attempt","original_deadline","new_deadline","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_recovery_decision(
|
||||
parent,
|
||||
decision_id=str(p["decision_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
expected_revision=p["expected_revision"],
|
||||
expected_attempt=p["expected_attempt"],
|
||||
original_deadline=p["original_deadline"],
|
||||
new_deadline=p["new_deadline"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
fail("OWNER_OPERATION_FORBIDDEN")
|
||||
|
||||
if op == "issue_worker_runtime_binding":
|
||||
if set(p) != {
|
||||
"delegation","binding_id","nonce","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities",
|
||||
"worker_capabilities","max_concurrency","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_binding(
|
||||
parent,
|
||||
binding_id=str(p["binding_id"]), nonce=str(p["nonce"]),
|
||||
worker_id=str(p["worker_id"]), runtime_id=str(p["runtime_id"]),
|
||||
runtime_sha256=str(p["runtime_sha256"]),
|
||||
task_contract_version=str(p["task_contract_version"]),
|
||||
supported_contract_versions=tuple(p["supported_contract_versions"]),
|
||||
required_capabilities=tuple(p["required_capabilities"]),
|
||||
worker_capabilities=tuple(p["worker_capabilities"]),
|
||||
max_concurrency=p["max_concurrency"], ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
if op == "issue_worker_runtime_proof":
|
||||
if set(p) != {
|
||||
"delegation","binding","proof_id","nonce","heartbeat_at","adapter_readiness",
|
||||
"resource_readiness","active_claim_count","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
binding_art = _artifact(p["binding"])
|
||||
parent = verifier.owner(parent_art)
|
||||
binding = verifier.machine(binding_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
verify_binding_scope(binding, parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_proof(
|
||||
parent, binding,
|
||||
proof_id=str(p["proof_id"]), nonce=str(p["nonce"]),
|
||||
heartbeat_at=p["heartbeat_at"],
|
||||
adapter_readiness=p["adapter_readiness"],
|
||||
resource_readiness=p["resource_readiness"],
|
||||
active_claim_count=p["active_claim_count"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
path = self.socket_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists() or path.is_symlink():
|
||||
if path.is_socket():
|
||||
path.unlink()
|
||||
else:
|
||||
fail("SOCKET_PATH_OCCUPIED")
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(str(path))
|
||||
os.chmod(path, 0o600)
|
||||
sock.listen(16)
|
||||
while True:
|
||||
conn, _ = sock.accept()
|
||||
with conn:
|
||||
try:
|
||||
uid = _peer_uid(conn)
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
data = conn.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
total += len(data)
|
||||
if total > MAX_REQUEST:
|
||||
fail("REQUEST_TOO_LARGE")
|
||||
chunks.append(data)
|
||||
if b"\n" in data:
|
||||
break
|
||||
raw = b"".join(chunks)
|
||||
line = raw.split(b"\n",1)[0]
|
||||
req = json.loads(line.decode("utf-8"))
|
||||
result = self.handle(req, peer_uid=uid)
|
||||
out = {"ok":True,"result":result}
|
||||
except (OperationalServiceError, OperationalAuthorityError, SingleHostAuthorityError) as exc:
|
||||
out = {"ok":False,"error":getattr(exc,"code",str(exc))}
|
||||
except Exception:
|
||||
out = {"ok":False,"error":"INTERNAL_FAIL_CLOSED"}
|
||||
conn.sendall(json.dumps(out, sort_keys=True, separators=(",",":")).encode("utf-8")+b"\n")
|
||||
finally:
|
||||
sock.close()
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--role", choices=("owner","machine"), required=True)
|
||||
ap.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
ap.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
ap.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
ap.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
ap.add_argument("--socket")
|
||||
ns = ap.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=ns.role,
|
||||
policy_path=Path(ns.policy),
|
||||
registry_path=Path(ns.registry),
|
||||
owner_key_path=Path(ns.owner_key),
|
||||
machine_key_path=Path(ns.machine_key),
|
||||
socket_path=Path(ns.socket) if ns.socket else None,
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
"""Bridge contract surface for Task Admission Bootstrap.
|
||||
|
||||
This module is intentionally transport-agnostic. The live Bridge may merge
|
||||
these routes/operations into its existing authenticated/HTTPS surface, but the
|
||||
authorization proof for the authorize operation is the verified delegation
|
||||
epoch or Owner signature carried by the request itself.
|
||||
|
||||
No handler here can issue TaskPrincipal assertions or WriterLeases. Those remain
|
||||
strictly downstream of a real admitted/claimed Agent Task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_task_admission_bootstrap import TaskAdmissionBootstrapService
|
||||
|
||||
|
||||
TASK_ADMISSION_ROUTES = {
|
||||
"/v2/task-admission/envelope": "getTaskAdmissionEnvelope",
|
||||
"/v2/task-admission/authorize": "submitTaskAdmissionAuthorization",
|
||||
"/v2/task-admission/status": "getTaskAdmissionStatus",
|
||||
}
|
||||
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS = {
|
||||
"getTaskAdmissionEnvelope": (
|
||||
"idempotency_key",
|
||||
"task_id",
|
||||
"repository",
|
||||
"authority_base_oid",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"execution_digest",
|
||||
"requested_capabilities",
|
||||
"file_allowlist",
|
||||
"risk",
|
||||
),
|
||||
"submitTaskAdmissionAuthorization": (
|
||||
"envelope_id",
|
||||
"authorization_kind",
|
||||
),
|
||||
"getTaskAdmissionStatus": ("envelope_id",),
|
||||
}
|
||||
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS = {
|
||||
"getTaskAdmissionEnvelope": ("expires_in_seconds",),
|
||||
"submitTaskAdmissionAuthorization": (
|
||||
"delegation_epoch_id",
|
||||
"owner_key_id",
|
||||
"signature_base64",
|
||||
),
|
||||
"getTaskAdmissionStatus": (),
|
||||
}
|
||||
|
||||
|
||||
class TaskAdmissionBridgeContract:
|
||||
def __init__(self, service: TaskAdmissionBootstrapService) -> None:
|
||||
self.service = service
|
||||
|
||||
@staticmethod
|
||||
def _validate_fields(operation: str, request: Mapping[str, Any]) -> None:
|
||||
required = set(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation])
|
||||
allowed = required | set(TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation])
|
||||
if not required.issubset(request):
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_REQUIRED_FIELDS_MISSING")
|
||||
if set(request) - allowed:
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_UNKNOWN_FIELDS")
|
||||
|
||||
def dispatch(self, operation: str, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if operation not in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS:
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND")
|
||||
self._validate_fields(operation, request)
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return self.service.create_envelope(request)
|
||||
if operation == "submitTaskAdmissionAuthorization":
|
||||
return self.service.authorize_and_admit(request)
|
||||
if operation == "getTaskAdmissionStatus":
|
||||
return self.service.status(str(request["envelope_id"]))
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND")
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
operation: (
|
||||
lambda request, operation=operation: self.dispatch(operation, request)
|
||||
)
|
||||
for operation in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
||||
"""Append-only ClaimGuard binding for the superseded Single-Host child Task."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_task_lineage_supersession_initial_projection import (
|
||||
PROJECTION_RECEIPT_SCHEMA,
|
||||
PROJECTION_TABLE,
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
)
|
||||
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
WORKER_ID = "p0-task-principal-v15-20260830"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
BINDING_SCHEMA = "aios.task-claim-guard-binding.v2"
|
||||
RECEIPT_SCHEMA = "aios.task-claim-guard-binding-receipt.v2"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ClaimGuardBindingError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise ClaimGuardBindingError(code, status)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _row_dict(row: Any) -> dict[str, Any]:
|
||||
if row is None:
|
||||
return {}
|
||||
if hasattr(row, "keys"):
|
||||
return {str(key): row[key] for key in row.keys()}
|
||||
_fail("TASK_ROW_MAPPING_UNAVAILABLE", 500)
|
||||
|
||||
|
||||
def _json(raw: Any, code: str) -> Any:
|
||||
if not isinstance(raw, str):
|
||||
_fail(code)
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ClaimGuardBindingError(code) from exc
|
||||
|
||||
|
||||
def _default_policy_factory(value: Mapping[str, Any]):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
return OperationalPolicy.from_mapping(value)
|
||||
|
||||
|
||||
def _policy_value(policy: Any, name: str) -> Any:
|
||||
try:
|
||||
return getattr(policy, name)
|
||||
except AttributeError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
|
||||
|
||||
def _load_projection_evidence(connection, relation) -> dict[str, Any]:
|
||||
try:
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=? AND source_task_id=? "
|
||||
"AND supersession_ordinal=?",
|
||||
(CHILD_TASK_ID, SOURCE_TASK_ID, 1),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
raise ClaimGuardBindingError(
|
||||
"FORMAL_CHILD_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
) from exc
|
||||
if row is None:
|
||||
_fail("FORMAL_CHILD_INITIAL_PROJECTION_REQUIRED")
|
||||
value = _row_dict(row)
|
||||
receipt = _json(value.get("receipt_json"), "CHILD_PROJECTION_RECEIPT_INVALID")
|
||||
canonical_digest = value.get("canonical_child_execution_digest")
|
||||
source_digest = value.get("source_business_execution_digest")
|
||||
exact = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": 1,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"owner_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
if any(value.get(key) != expected for key, expected in exact.items()):
|
||||
_fail("CHILD_PROJECTION_LINEAGE_MISMATCH")
|
||||
if (
|
||||
not isinstance(canonical_digest, str)
|
||||
or HEX64.fullmatch(canonical_digest) is None
|
||||
or canonical_digest == SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or source_digest != SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or _sha_text(value["receipt_json"]) != value.get("receipt_sha256")
|
||||
or not isinstance(receipt, Mapping)
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest") != canonical_digest
|
||||
or receipt.get("source_business_execution_digest") != source_digest
|
||||
or receipt.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_RECEIPT_MISMATCH")
|
||||
return {
|
||||
**value,
|
||||
"canonical_child_execution_digest": canonical_digest,
|
||||
"source_business_execution_digest": source_digest,
|
||||
}
|
||||
|
||||
|
||||
def install_child_binding(
|
||||
connection,
|
||||
*,
|
||||
policy: Mapping[str, Any],
|
||||
runtime_id: str,
|
||||
runtime_observation_digest: str,
|
||||
task_contract_version: str,
|
||||
policy_factory: Callable[[Mapping[str, Any]], Any] = _default_policy_factory,
|
||||
now: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate and persist the only permitted child binding from current DB state."""
|
||||
if not isinstance(policy, Mapping):
|
||||
_fail("OPERATIONAL_POLICY_INVALID")
|
||||
if not isinstance(runtime_id, str) or not runtime_id or len(runtime_id) > 512:
|
||||
_fail("RUNTIME_ID_INVALID")
|
||||
if not isinstance(runtime_observation_digest, str) or HEX64.fullmatch(runtime_observation_digest) is None:
|
||||
_fail("RUNTIME_OBSERVATION_DIGEST_INVALID")
|
||||
if not isinstance(task_contract_version, str) or not task_contract_version or len(task_contract_version) > 128:
|
||||
_fail("TASK_CONTRACT_VERSION_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
task_row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
if task_row is None:
|
||||
_fail("CHILD_TASK_NOT_FOUND", 404)
|
||||
task = _row_dict(task_row)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) < 2
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_PROJECTED_CLAIMABLE_STATE_REQUIRED")
|
||||
event_rows = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
if [row[0] for row in event_rows] != [
|
||||
"TASK_CREATED",
|
||||
"TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
||||
]:
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_REQUIRED")
|
||||
projection_event = _json(
|
||||
event_rows[-1][1], "CANONICAL_INITIAL_PROJECTION_EVENT_INVALID"
|
||||
)
|
||||
if (
|
||||
projection_event.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or projection_event.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or projection_event.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or _sha_json(projection_event) != projection["projection_event_sha256"]
|
||||
):
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_EVENT_MISMATCH")
|
||||
metadata_raw = task.get("metadata_json")
|
||||
capabilities_raw = task.get("required_capabilities_json")
|
||||
metadata = _json(metadata_raw, "CHILD_METADATA_INVALID")
|
||||
capabilities = _json(capabilities_raw, "CHILD_CAPABILITIES_INVALID")
|
||||
if (
|
||||
_sha_text(metadata_raw) != projection["metadata_sha256"]
|
||||
or _sha_text(capabilities_raw)
|
||||
!= projection["required_capabilities_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_TASK_DIGEST_MISMATCH")
|
||||
if not isinstance(metadata, Mapping) or not isinstance(capabilities, list) or not capabilities:
|
||||
_fail("CHILD_PROJECTION_INVALID")
|
||||
if any(not isinstance(value, str) or not value for value in capabilities) or len(set(capabilities)) != len(capabilities):
|
||||
_fail("CHILD_CAPABILITIES_INVALID")
|
||||
execution = metadata.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("CHILD_EXECUTION_PROJECTION_MISSING")
|
||||
exact_execution = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_digest": canonical_execution_digest, "job_id": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in exact_execution.items()):
|
||||
_fail("CHILD_EXECUTION_BINDING_MISMATCH")
|
||||
parsed_policy = policy_factory(dict(policy))
|
||||
if hasattr(parsed_policy, "validate"):
|
||||
parsed_policy.validate()
|
||||
policy_exact = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_plan_sha256": canonical_execution_digest,
|
||||
"external_job_identity": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(_policy_value(parsed_policy, key) != value for key, value in policy_exact.items()):
|
||||
_fail("OPERATIONAL_POLICY_SCOPE_MISMATCH")
|
||||
if tuple(_policy_value(parsed_policy, "allowed_worker_ids")) != (WORKER_ID,):
|
||||
_fail("OPERATIONAL_POLICY_WORKER_MISMATCH")
|
||||
expected_oid = execution.get("expected_branch_oid", execution.get("base_oid"))
|
||||
if expected_oid is not None and _policy_value(parsed_policy, "base_oid") != expected_oid:
|
||||
_fail("OPERATIONAL_POLICY_BASE_OID_MISMATCH")
|
||||
policy_json = canonical_json(dict(policy))
|
||||
policy_sha = _sha_text(policy_json)
|
||||
policy_digest = str(_policy_value(parsed_policy, "digest"))
|
||||
if policy_digest != policy_sha:
|
||||
_fail("OPERATIONAL_POLICY_DIGEST_MISMATCH")
|
||||
binding = {
|
||||
"schema": BINDING_SCHEMA, "task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID, "supersession_ordinal": 1,
|
||||
"expected_revision": int(task["revision"]),
|
||||
"metadata_sha256": _sha_text(metadata_raw),
|
||||
"required_capabilities_sha256": _sha_json(sorted(capabilities)),
|
||||
"worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY, "target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"external_job_id": EXTERNAL_JOB_ID, "policy_sha256": policy_sha,
|
||||
"policy_digest": policy_digest, "runtime_id": runtime_id,
|
||||
"runtime_observation_digest": runtime_observation_digest,
|
||||
"task_contract_version": task_contract_version,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
binding_json = canonical_json(binding)
|
||||
binding_sha = _sha_text(binding_json)
|
||||
binding_id = "sha256:" + binding_sha
|
||||
existing = connection.execute(
|
||||
"SELECT binding_sha256 FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing[0] != binding_sha:
|
||||
_fail("CLAIM_GUARD_BINDING_ALREADY_EXISTS")
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM task_claim_guard_binding_receipts_v2 WHERE binding_id=?", (binding_id,)
|
||||
).fetchone()
|
||||
if receipt is None:
|
||||
_fail("CLAIM_GUARD_BINDING_RECEIPT_MISSING", 500)
|
||||
return {**json.loads(receipt[0]), "idempotent": True}
|
||||
created_at = now or _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO task_claim_guard_bindings_v2(
|
||||
binding_id,task_id,source_task_id,supersession_ordinal,expected_revision,
|
||||
metadata_sha256,required_capabilities_sha256,worker_id,execution_digest,
|
||||
repository,target_branch,generation,policy_json,policy_sha256,binding_json,
|
||||
binding_sha256,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(binding_id, CHILD_TASK_ID, SOURCE_TASK_ID, 1, binding["expected_revision"],
|
||||
binding["metadata_sha256"], binding["required_capabilities_sha256"], WORKER_ID,
|
||||
canonical_execution_digest, REPOSITORY, TARGET_BRANCH, GENERATION, policy_json, policy_sha,
|
||||
binding_json, binding_sha, created_at),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "BOUND", "binding_id": binding_id,
|
||||
"binding_sha256": binding_sha, "task_id": CHILD_TASK_ID,
|
||||
"expected_revision": binding["expected_revision"],
|
||||
"metadata_sha256": binding["metadata_sha256"], "worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
receipt_sha = _sha_json(receipt)
|
||||
receipt_id = "sha256:" + receipt_sha
|
||||
connection.execute(
|
||||
"INSERT INTO task_claim_guard_binding_receipts_v2 VALUES(?,?,?,?,?,?)",
|
||||
(receipt_id, binding_id, CHILD_TASK_ID, canonical_json(receipt), receipt_sha, created_at),
|
||||
)
|
||||
return {**receipt, "receipt_id": receipt_id, "receipt_sha256": receipt_sha, "idempotent": False}
|
||||
|
||||
|
||||
def _load_child_binding(connection) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
_fail("CHILD_CLAIM_GUARD_BINDING_MISSING")
|
||||
value = _row_dict(row)
|
||||
binding = _json(value["binding_json"], "CLAIM_GUARD_BINDING_INVALID")
|
||||
policy = _json(value["policy_json"], "OPERATIONAL_POLICY_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? "
|
||||
"AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
if (
|
||||
_sha_text(value["binding_json"]) != value["binding_sha256"]
|
||||
or binding.get("schema") != BINDING_SCHEMA
|
||||
or value.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or binding.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or binding.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or policy.get("execution_plan_sha256") != canonical_execution_digest
|
||||
):
|
||||
_fail("CLAIM_GUARD_BINDING_DIGEST_MISMATCH")
|
||||
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
task = _row_dict(task)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) != int(binding["expected_revision"])
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or _sha_text(task.get("metadata_json", "")) != binding["metadata_sha256"]
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_CLAIM_GUARD_TASK_CAS_MISMATCH")
|
||||
return binding, policy
|
||||
|
||||
|
||||
def _run_child_guard(connection, binding, policy_value, agent_id):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy, OperationalVerifier
|
||||
from aios_singlehost_claim_guard_stage1 import _LiveReader, REGISTRY_PATH
|
||||
from aios_task_claim_guard import ClaimGuard, ClaimGuardError as CoreError
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("CLAIM_GUARD_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
policy = OperationalPolicy.from_mapping(policy_value)
|
||||
cfg = {
|
||||
"task_id": CHILD_TASK_ID, "expected_claim_revision": binding["expected_revision"],
|
||||
"task_metadata_sha256": binding["metadata_sha256"],
|
||||
"required_capabilities_sha256": binding["required_capabilities_sha256"],
|
||||
"task_contract_version": binding["task_contract_version"], "worker_id": WORKER_ID,
|
||||
"policy_digest": binding["policy_digest"], "runtime_id": binding["runtime_id"],
|
||||
"runtime_observation_digest": binding["runtime_observation_digest"],
|
||||
}
|
||||
reader = _LiveReader(connection, policy, cfg, now=int(datetime.now(timezone.utc).timestamp()))
|
||||
try:
|
||||
ClaimGuard(policy, OperationalVerifier(REGISTRY_PATH, require_root_owner=True)).validate_in_transaction(
|
||||
reader, task_id=CHILD_TASK_ID,
|
||||
expected_revision=int(binding["expected_revision"]), worker_id=agent_id,
|
||||
)
|
||||
except CoreError as exc:
|
||||
raise ClaimGuardBindingError(exc.code) from exc
|
||||
|
||||
|
||||
def enforce_claim_guard(connection, *, task_id: str, agent_id: str) -> None:
|
||||
"""Exact two-lineage dispatcher; unrelated Tasks retain their prior behavior."""
|
||||
if task_id == SOURCE_TASK_ID:
|
||||
try:
|
||||
from aios_singlehost_claim_guard_stage1 import enforce_claim_guard as frozen_guard
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("FROZEN_SOURCE_CLAIM_GUARD_UNAVAILABLE", 503) from exc
|
||||
frozen_guard(connection, task_id=task_id, agent_id=agent_id)
|
||||
return
|
||||
if task_id != CHILD_TASK_ID:
|
||||
return
|
||||
if agent_id != WORKER_ID:
|
||||
_fail("WORKER_ID_MISMATCH", 403)
|
||||
binding, policy = _load_child_binding(connection)
|
||||
_run_child_guard(connection, binding, policy, agent_id)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""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"]}
|
||||
@@ -0,0 +1,339 @@
|
||||
"""One-time, Owner-signed, append-only supersession for one exact Task lineage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
DECISION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-lineage-supersession-receipt.v1"
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_PRINCIPAL = "aios-task-grant-signer"
|
||||
OWNER_KEY_ID = "SHA256:rPqpxfPHtaZPM3zSWojX8Q7VxzCknsYqOokyo/aAM3A"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXECUTION_DIGEST = "e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
CANONICAL_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROFILE_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
class TaskLineageSupersessionError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
def verify(self, *, namespace: str, payload: bytes, signature: str, key_id: str) -> bool: ...
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise TaskLineageSupersessionError(code, status)
|
||||
|
||||
|
||||
def _utc(value: Any, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value or len(value) > 80:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
try:
|
||||
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise TaskLineageSupersessionError(field.upper() + "_INVALID", 400) from exc
|
||||
if result.tzinfo is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return result.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, maximum: int = 1024) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return value
|
||||
|
||||
|
||||
def decision_payload(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: raw[key] for key in raw if key != "signature"}
|
||||
|
||||
|
||||
DECISION_FIELDS = {
|
||||
"schema", "decision_id", "nonce", "source_task_id",
|
||||
"source_task_identity_sha256", "source_metadata_sha256",
|
||||
"source_expected_status", "source_expected_revision", "source_expected_attempt",
|
||||
"source_expected_claimed_by", "source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at", "repository", "target_branch", "generation",
|
||||
"execution_digest", "external_job_id", "child_task_id", "supersession_ordinal",
|
||||
"canonical_projection_profile", "canonical_projection_version", "supersession_reason",
|
||||
"owner_identity", "signature_namespace", "signature_principal", "key_id",
|
||||
"issued_at", "not_before", "expires_at", "one_time_use", "signature",
|
||||
}
|
||||
|
||||
|
||||
def validate_decision(raw: Mapping[str, Any], *, now: datetime) -> dict[str, Any]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != DECISION_FIELDS:
|
||||
_fail("DECISION_FIELDS_INVALID", 400)
|
||||
exact = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
for field, expected in exact.items():
|
||||
if raw[field] != expected:
|
||||
_fail(field.upper() + "_MISMATCH", 403 if field.startswith(("owner", "signature", "key")) else 409)
|
||||
for field in ("decision_id", "nonce"):
|
||||
value = _text(raw[field], field, maximum=256)
|
||||
if SAFE_ID.fullmatch(value) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(raw[field], str) or HEX64.fullmatch(raw[field]) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
if type(raw["source_expected_revision"]) is not int or raw["source_expected_revision"] < 1:
|
||||
_fail("SOURCE_EXPECTED_REVISION_INVALID", 400)
|
||||
_text(raw["signature"], "signature", maximum=32768)
|
||||
issued_at = _utc(raw["issued_at"], "issued_at")
|
||||
not_before = _utc(raw["not_before"], "not_before")
|
||||
expires_at = _utc(raw["expires_at"], "expires_at")
|
||||
if issued_at > expires_at or not_before > expires_at:
|
||||
_fail("DECISION_TIME_ORDER_INVALID", 403)
|
||||
if expires_at - issued_at > MAX_TTL or expires_at <= issued_at:
|
||||
_fail("DECISION_TTL_INVALID", 403)
|
||||
now = now.astimezone(timezone.utc)
|
||||
if now < not_before or now >= expires_at:
|
||||
_fail("DECISION_EXPIRED_OR_NOT_YET_VALID", 403)
|
||||
if issued_at > now + timedelta(seconds=5) or now - issued_at > MAX_TTL:
|
||||
_fail("DECISION_ISSUED_AT_INVALID", 403)
|
||||
return {**dict(raw), "issued_at_dt": issued_at, "not_before_dt": not_before, "expires_at_dt": expires_at}
|
||||
|
||||
|
||||
def _default_identity_digest(row: sqlite3.Row) -> str:
|
||||
try:
|
||||
from aios_expired_task_recovery import task_identity_digest
|
||||
except ImportError as exc:
|
||||
raise TaskLineageSupersessionError("FORMAL_TASK_IDENTITY_HELPER_UNAVAILABLE", 503) from exc
|
||||
return str(task_identity_digest(row))
|
||||
|
||||
|
||||
def _json_object(row: sqlite3.Row, field: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(row[field])
|
||||
except (TypeError, ValueError, KeyError) as exc:
|
||||
raise TaskLineageSupersessionError("SOURCE_TASK_JSON_INVALID", 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("SOURCE_TASK_JSON_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _verify_source(row: sqlite3.Row, d: Mapping[str, Any], identity_digest: Callable[[sqlite3.Row], str]) -> None:
|
||||
if row["task_id"] != SOURCE_TASK_ID:
|
||||
_fail("SOURCE_TASK_ID_MISMATCH")
|
||||
cas = (
|
||||
row["status"] == "EXPIRED"
|
||||
and int(row["revision"]) == d["source_expected_revision"]
|
||||
and int(row["attempt"]) == 0
|
||||
and row["claimed_by"] is None
|
||||
and row["lease_token_hash"] is None
|
||||
and row["lease_expires_at"] is None
|
||||
)
|
||||
if not cas:
|
||||
_fail("SOURCE_TASK_EXACT_CAS_MISMATCH")
|
||||
metadata_raw = row["metadata_json"]
|
||||
if not isinstance(metadata_raw, str) or sha256_text(metadata_raw) != d["source_metadata_sha256"]:
|
||||
_fail("SOURCE_METADATA_DIGEST_MISMATCH")
|
||||
if identity_digest(row) != d["source_task_identity_sha256"]:
|
||||
_fail("SOURCE_TASK_IDENTITY_DIGEST_MISMATCH")
|
||||
metadata = _json_object(row, "metadata_json")
|
||||
execution = metadata.get("execution")
|
||||
constraints = _json_object(row, "constraints_json")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("SOURCE_EXECUTION_BINDING_INVALID")
|
||||
expected = {
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in expected.items()):
|
||||
_fail("SOURCE_EXECUTION_BINDING_MISMATCH")
|
||||
if constraints.get("generation") != GENERATION:
|
||||
_fail("SOURCE_GENERATION_CONSTRAINT_MISMATCH")
|
||||
|
||||
|
||||
class TaskLineageSupersessionController:
|
||||
def __init__(self, db_path: str | Path, verifier: SignatureVerifier, *, clock=None, identity_digest=None):
|
||||
self.db_path = Path(db_path)
|
||||
self.verifier = verifier
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.identity_digest = identity_digest or _default_identity_digest
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _now(self) -> datetime:
|
||||
value = self.clock()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema(connection: sqlite3.Connection) -> None:
|
||||
required = {"tasks", "events", "task_lineage_supersessions", "task_lineage_supersession_receipts"}
|
||||
actual = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not required.issubset(actual):
|
||||
_fail("SUPERSESSION_SCHEMA_UNAVAILABLE", 503)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")}
|
||||
task_columns = {
|
||||
"task_id", "goal", "instructions", "success_criteria_json",
|
||||
"required_capabilities_json", "constraints_json", "metadata_json", "deadline",
|
||||
"priority", "max_attempts", "attempt", "status", "claimed_by",
|
||||
"lease_token_hash", "lease_expires_at", "result_json", "result_digest",
|
||||
"result_idempotency_key", "verification_json", "revision", "created_at", "updated_at",
|
||||
}
|
||||
if not task_columns.issubset(columns):
|
||||
_fail("TASK_SCHEMA_UNSUPPORTED", 503)
|
||||
|
||||
def supersede(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
now = self._now()
|
||||
decision = validate_decision(raw, now=now)
|
||||
payload_bytes = canonical_json(decision_payload(raw)).encode("utf-8")
|
||||
if not self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE, payload=payload_bytes,
|
||||
signature=decision["signature"], key_id=decision["key_id"],
|
||||
):
|
||||
_fail("DECISION_SIGNATURE_INVALID", 403)
|
||||
decision_sha = hashlib.sha256(payload_bytes).hexdigest()
|
||||
now_text = now.isoformat()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT owner_decision_sha256,receipt_json FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["owner_decision_sha256"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
result = json.loads(replay["receipt_json"])
|
||||
connection.commit()
|
||||
return {**result, "idempotent": True}
|
||||
source = connection.execute("SELECT * FROM tasks WHERE task_id=?", (SOURCE_TASK_ID,)).fetchone()
|
||||
if source is None:
|
||||
_fail("SOURCE_TASK_NOT_FOUND", 404)
|
||||
_verify_source(source, decision, self.identity_digest)
|
||||
if connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL),
|
||||
).fetchone():
|
||||
_fail("SUPERSESSION_ALREADY_EXISTS")
|
||||
if connection.execute("SELECT 1 FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone():
|
||||
_fail("CHILD_TASK_ALREADY_EXISTS")
|
||||
connection.execute(
|
||||
"""INSERT INTO tasks(
|
||||
task_id,goal,instructions,success_criteria_json,required_capabilities_json,
|
||||
constraints_json,metadata_json,deadline,priority,max_attempts,attempt,status,
|
||||
claimed_by,lease_token_hash,lease_expires_at,result_json,result_digest,
|
||||
result_idempotency_key,verification_json,revision,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
CHILD_TASK_ID, source["goal"], source["instructions"], source["success_criteria_json"],
|
||||
"[]", source["constraints_json"], "{}", None, source["priority"], source["max_attempts"],
|
||||
0, "QUEUED", None, None, None, None, None, None, None, 1, now_text, now_text,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersessions(
|
||||
source_task_id,supersession_ordinal,child_task_id,source_task_identity_sha256,
|
||||
source_metadata_sha256,owner_decision_sha256,decision_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)""",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID,
|
||||
decision["source_task_identity_sha256"], decision["source_metadata_sha256"],
|
||||
decision_sha, decision["decision_id"], now_text),
|
||||
)
|
||||
event_payload = {
|
||||
"task_id": CHILD_TASK_ID, "supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(CHILD_TASK_ID, None, "TASK_CREATED", canonical_json(event_payload), now_text),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "SUPERSEDED",
|
||||
"decision_id": decision["decision_id"], "owner_decision_sha256": decision_sha,
|
||||
"source_task_id": SOURCE_TASK_ID, "source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"source_status": "EXPIRED", "source_revision": decision["source_expected_revision"],
|
||||
"source_attempt": 0, "source_history_preserved": True,
|
||||
"child_task_id": CHILD_TASK_ID, "supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"child_status": "QUEUED", "child_revision": 1, "child_attempt": 0,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"exact_identity_cas": True, "one_time_use": True,
|
||||
"owner_signature_namespace": OWNER_NAMESPACE,
|
||||
"owner_signature_principal": OWNER_PRINCIPAL,
|
||||
"owner_signature_key_id": OWNER_KEY_ID,
|
||||
"owner_signature_sha256": sha256_text(decision["signature"]),
|
||||
}
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersession_receipts(
|
||||
decision_id,nonce,source_task_id,supersession_ordinal,child_task_id,
|
||||
owner_decision_sha256,decision_json,receipt_json,consumed_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
(decision["decision_id"], decision["nonce"], SOURCE_TASK_ID, SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID, decision_sha, canonical_json(dict(raw)), canonical_json(receipt), now_text),
|
||||
)
|
||||
connection.commit()
|
||||
return {**receipt, "idempotent": False}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Canonical initial projection for the exact Single-Host supersession child.
|
||||
|
||||
The source business execution digest is lineage evidence. It is deliberately
|
||||
not used as the child execution-envelope digest: the canonical envelope binds
|
||||
the child Task id and therefore has its own server-derived digest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionRequest,
|
||||
InitialExecutionProjectionStore,
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
_now,
|
||||
_require_string,
|
||||
_required_authorization_id,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
ExecutionProjectionError,
|
||||
GovernedExecutionBinding,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
from aios_task_lineage_supersession import (
|
||||
CANONICAL_PROFILE,
|
||||
CANONICAL_PROFILE_VERSION,
|
||||
CHILD_TASK_ID,
|
||||
DECISION_SCHEMA,
|
||||
EXECUTION_DIGEST as SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
EXTERNAL_JOB_ID,
|
||||
GENERATION,
|
||||
OWNER_KEY_ID,
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_PRINCIPAL,
|
||||
RECEIPT_SCHEMA as SUPERSESSION_RECEIPT_SCHEMA,
|
||||
REPOSITORY,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
TARGET_BRANCH,
|
||||
canonical_json,
|
||||
decision_payload,
|
||||
)
|
||||
|
||||
|
||||
PROJECTION_INTENT_SCHEMA = "aios.task-lineage-supersession-initial-projection-intent.v1"
|
||||
PROJECTION_RECEIPT_SCHEMA = "aios.task-lineage-supersession-initial-projection-receipt.v1"
|
||||
PROJECTION_IDEMPOTENCY_KEY = "task-lineage-supersession-s1-initial-projection-v1"
|
||||
PROJECTION_REASON = "canonical-initial-projection-for-formal-supersession-child"
|
||||
PROJECTION_TABLE = "task_lineage_supersession_initial_projections"
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _json_object(raw: Any, code: str) -> dict[str, Any]:
|
||||
if not isinstance(raw, str):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise InitialExecutionProjectionError(code, 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
return dict(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupersessionChildProjectionIntent:
|
||||
"""Caller-selectable portion of the exact child projection.
|
||||
|
||||
Task identity, CAS values, Job identity, projection profile, reason and
|
||||
idempotency are intentionally absent from the request and server-owned.
|
||||
"""
|
||||
|
||||
adapter: str
|
||||
required_capability: str
|
||||
authorization_id: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, Any]
|
||||
) -> "SupersessionChildProjectionIntent":
|
||||
if not isinstance(value, Mapping) or set(value) != {
|
||||
"adapter",
|
||||
"required_capability",
|
||||
"authorization_id",
|
||||
}:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_FIELDS_INVALID", 400
|
||||
)
|
||||
adapter = _require_string(value["adapter"], "RUNTIME_ADAPTER_INVALID")
|
||||
capability = _require_string(
|
||||
value["required_capability"],
|
||||
"RUNTIME_CAPABILITY_INVALID",
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
)
|
||||
if adapter not in {"hermes", "openclaw"} or capability != f"agent.runtime.{adapter}":
|
||||
raise InitialExecutionProjectionError("RUNTIME_CAPABILITY_MISMATCH", 400)
|
||||
authorization_id = _require_string(
|
||||
value["authorization_id"], "AUTHORIZATION_ID_INVALID"
|
||||
)
|
||||
return cls(adapter, capability, authorization_id)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": PROJECTION_INTENT_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"adapter": self.adapter,
|
||||
"required_capability": self.required_capability,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
|
||||
def initial_request(self, canonical_execution_digest: str) -> InitialExecutionProjectionRequest:
|
||||
return InitialExecutionProjectionRequest.from_mapping(
|
||||
{
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"expected_execution_digest": canonical_execution_digest,
|
||||
"required_capability": self.required_capability,
|
||||
"adapter": self.adapter,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _load_lineage_context(
|
||||
connection: sqlite3.Connection, *, expect_projected: bool
|
||||
) -> dict[str, Any]:
|
||||
required_tables = {
|
||||
"tasks",
|
||||
"events",
|
||||
"task_lineage_supersessions",
|
||||
"task_lineage_supersession_receipts",
|
||||
PROJECTION_TABLE,
|
||||
}
|
||||
if not required_tables.issubset(_table_names(connection)):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions "
|
||||
"WHERE source_task_id=? AND supersession_ordinal=? AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
raise InitialExecutionProjectionError("FORMAL_SUPERSESSION_REQUIRED", 409)
|
||||
receipt_row = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersession_receipts "
|
||||
"WHERE decision_id=? AND source_task_id=? AND supersession_ordinal=? "
|
||||
"AND child_task_id=?",
|
||||
(
|
||||
relation["decision_id"],
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID,
|
||||
),
|
||||
).fetchone()
|
||||
if receipt_row is None:
|
||||
raise InitialExecutionProjectionError("SUPERSESSION_RECEIPT_REQUIRED", 409)
|
||||
decision = _json_object(receipt_row["decision_json"], "SUPERSESSION_DECISION_INVALID")
|
||||
receipt = _json_object(receipt_row["receipt_json"], "SUPERSESSION_RECEIPT_INVALID")
|
||||
decision_sha256 = _sha_text(canonical_json(decision_payload(decision)))
|
||||
exact_decision = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
if any(decision.get(key) != expected for key, expected in exact_decision.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_DECISION_SCOPE_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
decision_sha256 != relation["owner_decision_sha256"]
|
||||
or decision_sha256 != receipt_row["owner_decision_sha256"]
|
||||
or decision.get("source_task_identity_sha256")
|
||||
!= relation["source_task_identity_sha256"]
|
||||
or decision.get("source_metadata_sha256") != relation["source_metadata_sha256"]
|
||||
or receipt.get("schema") != SUPERSESSION_RECEIPT_SCHEMA
|
||||
or receipt.get("owner_decision_sha256") != decision_sha256
|
||||
or receipt.get("source_task_id") != SOURCE_TASK_ID
|
||||
or receipt.get("child_task_id") != CHILD_TASK_ID
|
||||
or receipt.get("supersession_ordinal") != SUPERSESSION_ORDINAL
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_LINEAGE_EVIDENCE_MISMATCH", 409
|
||||
)
|
||||
events = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
expected_events = ["TASK_CREATED"]
|
||||
if expect_projected:
|
||||
expected_events.append("TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED")
|
||||
if [str(row["event_type"]) for row in events] != expected_events:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_EVENT_CHAIN_INVALID", 409
|
||||
)
|
||||
created = _json_object(events[0]["payload_json"], "SUPERSESSION_CREATED_EVENT_INVALID")
|
||||
exact_created = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha256,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
if any(created.get(key) != expected for key, expected in exact_created.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CREATED_EVENT_SCOPE_MISMATCH", 409
|
||||
)
|
||||
return {
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_decision_sha256": decision_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _validate_canonical_child_execution(
|
||||
resolved: Mapping[str, Any],
|
||||
*,
|
||||
intent: SupersessionChildProjectionIntent,
|
||||
runtime_input: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
binding = GovernedExecutionBinding.from_mapping(resolved)
|
||||
execution = validate_execution_envelope(resolved, binding=binding)
|
||||
except ExecutionProjectionError as exc:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_INVALID", 409
|
||||
) from exc
|
||||
exact_binding = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
}
|
||||
if any(execution.get(key) != expected for key, expected in exact_binding.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_BINDING_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
execution.get("action") != "agent_runtime"
|
||||
or execution.get("adapter") != intent.adapter
|
||||
or execution.get("capability_scope") != [intent.required_capability]
|
||||
or execution.get("runtime_input") != dict(runtime_input)
|
||||
or execution.get("change_set") != []
|
||||
or execution.get("verification") != []
|
||||
or execution.get("governance", {}).get("writer_lease") is not False
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_AGENT_RUNTIME_ENVELOPE_INVALID", 409
|
||||
)
|
||||
if execution["execution_digest"] == SOURCE_BUSINESS_EXECUTION_DIGEST:
|
||||
raise InitialExecutionProjectionError(
|
||||
"CHILD_CANONICAL_DIGEST_COLLIDES_WITH_SOURCE_BUSINESS_DIGEST", 409
|
||||
)
|
||||
return execution
|
||||
|
||||
|
||||
class _SupersessionProjectionTransactionStore(InitialExecutionProjectionStore):
|
||||
"""Per-invocation store that adds immutable lineage evidence atomically."""
|
||||
|
||||
allows_supersession_child = True
|
||||
|
||||
def __init__(self, db_path, resolved: Mapping[str, Any], intent):
|
||||
self._resolved = dict(resolved)
|
||||
self.intent = intent
|
||||
super().__init__(db_path, self._resolve_once)
|
||||
|
||||
def _resolve_once(self, *_args):
|
||||
return dict(self._resolved)
|
||||
|
||||
def _projection_event_extension(
|
||||
self, connection, task_id, request, execution, applied_revision
|
||||
):
|
||||
if task_id != CHILD_TASK_ID:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_TASK_ID_MISMATCH", 409
|
||||
)
|
||||
context = _load_lineage_context(connection, expect_projected=False)
|
||||
return {
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
|
||||
def _insert_projection_extension(
|
||||
self,
|
||||
connection,
|
||||
task_id,
|
||||
request,
|
||||
execution,
|
||||
event_payload,
|
||||
applied_revision,
|
||||
):
|
||||
context = _load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT metadata_json,required_capabilities_json FROM tasks WHERE task_id=?",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise InitialExecutionProjectionError("TASK_NOT_FOUND", 404)
|
||||
intent_json = canonical_json(self.intent.public())
|
||||
intent_sha256 = _sha_text(intent_json)
|
||||
receipt = {
|
||||
"schema": PROJECTION_RECEIPT_SCHEMA,
|
||||
"projection_id": "sha256:" + intent_sha256,
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"adapter": self.intent.adapter,
|
||||
"required_capability": self.intent.required_capability,
|
||||
"expected_revision": 1,
|
||||
"applied_revision": applied_revision,
|
||||
"attempt": 0,
|
||||
"metadata_sha256": _sha_text(task["metadata_json"]),
|
||||
"required_capabilities_sha256": _sha_text(
|
||||
task["required_capabilities_json"]
|
||||
),
|
||||
"projection_event_sha256": _sha_json(dict(event_payload)),
|
||||
}
|
||||
receipt_json = canonical_json(receipt)
|
||||
receipt_sha256 = _sha_text(receipt_json)
|
||||
connection.execute(
|
||||
f"""INSERT INTO {PROJECTION_TABLE}(
|
||||
projection_id,task_id,source_task_id,supersession_ordinal,
|
||||
intent_sha256,intent_json,owner_decision_sha256,
|
||||
source_task_identity_sha256,source_metadata_sha256,
|
||||
source_business_execution_digest,canonical_child_execution_digest,
|
||||
external_job_id,repository,target_branch,generation,adapter,
|
||||
required_capability,expected_revision,applied_revision,metadata_sha256,
|
||||
required_capabilities_sha256,projection_event_sha256,receipt_json,
|
||||
receipt_sha256,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
receipt["projection_id"],
|
||||
CHILD_TASK_ID,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
intent_sha256,
|
||||
intent_json,
|
||||
context["owner_decision_sha256"],
|
||||
context["source_task_identity_sha256"],
|
||||
context["source_metadata_sha256"],
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
execution["execution_digest"],
|
||||
EXTERNAL_JOB_ID,
|
||||
REPOSITORY,
|
||||
TARGET_BRANCH,
|
||||
GENERATION,
|
||||
self.intent.adapter,
|
||||
self.intent.required_capability,
|
||||
1,
|
||||
applied_revision,
|
||||
receipt["metadata_sha256"],
|
||||
receipt["required_capabilities_sha256"],
|
||||
receipt["projection_event_sha256"],
|
||||
receipt_json,
|
||||
receipt_sha256,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SupersessionChildInitialProjectionStore:
|
||||
"""Server-owned adapter from exact supersession lineage to current ABI."""
|
||||
|
||||
def __init__(self, db_path, resolver):
|
||||
self.db_path = db_path
|
||||
self.resolver = resolver
|
||||
|
||||
def _connect(self):
|
||||
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 _replay(connection, intent: SupersessionChildProjectionIntent):
|
||||
if PROJECTION_TABLE not in _table_names(connection):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
tables = _table_names(connection)
|
||||
if "task_initial_execution_projections" in tables:
|
||||
generic = connection.execute(
|
||||
"SELECT 1 FROM task_initial_execution_projections "
|
||||
"WHERE task_id=? AND idempotency_key=?",
|
||||
(CHILD_TASK_ID, PROJECTION_IDEMPOTENCY_KEY),
|
||||
).fetchone()
|
||||
if generic is not None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return None
|
||||
intent_sha256 = _sha_json(intent.public())
|
||||
if row["intent_sha256"] != intent_sha256:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_INTENT_CONFLICT", 409
|
||||
)
|
||||
receipt = _json_object(
|
||||
row["receipt_json"], "SUPERSESSION_PROJECTION_RECEIPT_INVALID"
|
||||
)
|
||||
if (
|
||||
_sha_text(row["receipt_json"]) != row["receipt_sha256"]
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest")
|
||||
!= row["canonical_child_execution_digest"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_DIGEST_MISMATCH", 500
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if (
|
||||
task is None
|
||||
or task["status"] != "QUEUED"
|
||||
or int(task["revision"]) != int(row["applied_revision"])
|
||||
or int(task["attempt"]) != 0
|
||||
or task["claimed_by"] is not None
|
||||
or task["lease_token_hash"] is not None
|
||||
or task["lease_expires_at"] is not None
|
||||
or _sha_text(task["metadata_json"]) != row["metadata_sha256"]
|
||||
or _sha_text(task["required_capabilities_json"])
|
||||
!= row["required_capabilities_sha256"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTED_TASK_CAS_MISMATCH", 409
|
||||
)
|
||||
return {**receipt, "idempotent": True}
|
||||
|
||||
def project(self, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
intent = SupersessionChildProjectionIntent.from_mapping(value)
|
||||
if intent.authorization_id != _required_authorization_id():
|
||||
raise InitialExecutionProjectionError("AUTHORIZATION_ID_MISMATCH", 403)
|
||||
with self._connect() as connection:
|
||||
replay = self._replay(connection, intent)
|
||||
if replay is not None:
|
||||
return replay
|
||||
probe_request = intent.initial_request("0" * 64)
|
||||
probe_store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, {}, intent
|
||||
)
|
||||
task, _, _, _ = probe_store._load_and_validate(
|
||||
connection, CHILD_TASK_ID, probe_request
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=False)
|
||||
runtime_input = probe_store._runtime_input(task)
|
||||
resolved = dict(
|
||||
self.resolver(
|
||||
CHILD_TASK_ID,
|
||||
EXTERNAL_JOB_ID,
|
||||
intent.required_capability,
|
||||
intent.adapter,
|
||||
runtime_input,
|
||||
)
|
||||
)
|
||||
execution = _validate_canonical_child_execution(
|
||||
resolved, intent=intent, runtime_input=runtime_input
|
||||
)
|
||||
store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, resolved, intent
|
||||
)
|
||||
result = store.project(
|
||||
CHILD_TASK_ID, intent.initial_request(execution["execution_digest"])
|
||||
)
|
||||
with self._connect() as connection:
|
||||
receipt = self._replay(connection, intent)
|
||||
if receipt is None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return {**receipt, "idempotent": bool(result["idempotent"])}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Fixed Owner operation for one exact Task-lineage supersession.
|
||||
|
||||
This staging overlay deliberately extends the installed single-host operational
|
||||
authority instead of exposing a generic signing primitive. The caller can
|
||||
only provide the three live CAS values which cannot be known at build time.
|
||||
Every authority, lineage and execution field is constructed server-side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import OWNER_IDENTITY, OWNER_NAMESPACE, sha256_json
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
SignedOperationalArtifact,
|
||||
)
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION = "issue_task_lineage_supersession"
|
||||
TASK_LINEAGE_SUPERSESSION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST = (
|
||||
"e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
)
|
||||
EXTERNAL_JOB_IDENTITY = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
CANONICAL_PROJECTION_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROJECTION_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
DECISION_TTL_SECONDS = 300
|
||||
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _assert_fixed_policy(policy: OperationalPolicy) -> None:
|
||||
"""Refuse issuance if the live policy is not the approved source scope."""
|
||||
policy.validate()
|
||||
exact = {
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_plan_sha256": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
}
|
||||
if any(getattr(policy, field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_MISMATCH")
|
||||
if policy.maximum_recovery_decision_ttl_seconds < DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_TTL_TOO_NARROW")
|
||||
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_FIELDS = {
|
||||
"schema",
|
||||
"decision_id",
|
||||
"nonce",
|
||||
"source_task_id",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
"source_expected_status",
|
||||
"source_expected_revision",
|
||||
"source_expected_attempt",
|
||||
"source_expected_claimed_by",
|
||||
"source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at",
|
||||
"repository",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"source_business_execution_digest",
|
||||
"external_job_identity",
|
||||
"child_task_id",
|
||||
"supersession_ordinal",
|
||||
"canonical_projection_profile",
|
||||
"canonical_projection_version",
|
||||
"supersession_reason",
|
||||
"owner_identity",
|
||||
"policy_digest",
|
||||
"one_time_use",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_task_lineage_supersession_scope(
|
||||
decision: Mapping[str, Any],
|
||||
policy: OperationalPolicy,
|
||||
*,
|
||||
now: int,
|
||||
) -> None:
|
||||
"""Validate a signature-verified decision against the fixed source scope."""
|
||||
_assert_fixed_policy(policy)
|
||||
if not isinstance(decision, Mapping) or set(decision) != TASK_LINEAGE_SUPERSESSION_FIELDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_FIELDS_INVALID")
|
||||
exact = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": policy.digest,
|
||||
"one_time_use": True,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
}
|
||||
if any(decision.get(field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SCOPE_INVALID")
|
||||
if decision.get("source_task_identity_sha256") != policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(decision.get(field), str) or HEX64.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_DIGEST_INVALID")
|
||||
if type(decision.get("source_expected_revision")) is not int or not 1 <= decision["source_expected_revision"] < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
for field in ("decision_id", "nonce"):
|
||||
if not isinstance(decision.get(field), str) or SAFE_TOKEN.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(decision.get(field)) is not int:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if not decision["not_before"] <= now < decision["expires_at"]:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["issued_at"] > now + 5 or now - decision["issued_at"] > DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["expires_at"] - decision["issued_at"] != DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
digest_value = dict(decision)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if supplied_digest != sha256_json(digest_value):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
|
||||
class TaskLineageSupersessionOwnerAuthority(OperationalOwnerAuthority):
|
||||
"""Owner signer with one bounded constructor and no caller-defined message."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
token_hex: Callable[[int], str] = secrets.token_hex,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def issue_task_lineage_supersession(
|
||||
self,
|
||||
*,
|
||||
source_expected_revision: int,
|
||||
source_task_identity_sha256: str,
|
||||
source_metadata_sha256: str,
|
||||
) -> SignedOperationalArtifact:
|
||||
_assert_fixed_policy(self.policy)
|
||||
if type(source_expected_revision) is not int or not 1 <= source_expected_revision < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
if source_task_identity_sha256 != self.policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
if not isinstance(source_metadata_sha256, str) or HEX64.fullmatch(source_metadata_sha256) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_METADATA_DIGEST_INVALID")
|
||||
|
||||
decision_id = "task-lineage-supersession-" + self._token_hex(16)
|
||||
nonce = "task-lineage-supersession-nonce-" + self._token_hex(16)
|
||||
if SAFE_TOKEN.fullmatch(decision_id) is None or SAFE_TOKEN.fullmatch(nonce) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
now = int(self.clock())
|
||||
payload = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_task_identity_sha256": source_task_identity_sha256,
|
||||
"source_metadata_sha256": source_metadata_sha256,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_revision": source_expected_revision,
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + DECISION_TTL_SECONDS,
|
||||
}
|
||||
return self._sign(payload)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Staging-only server overlay for the fixed lineage Owner operation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import DEFAULT_REGISTRY_PATH
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalMachineAuthority,
|
||||
OperationalVerifier,
|
||||
)
|
||||
from aios_single_host_operational_authority_server import (
|
||||
DEFAULT_LEDGER,
|
||||
DEFAULT_MACHINE_KEY,
|
||||
DEFAULT_OWNER_KEY,
|
||||
DEFAULT_POLICY,
|
||||
DurableIssuanceLedger,
|
||||
OperationalAuthorityService as BaseOperationalAuthorityService,
|
||||
_read_policy,
|
||||
fail,
|
||||
)
|
||||
from aios_task_lineage_supersession_owner_authority import (
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
TaskLineageSupersessionOwnerAuthority,
|
||||
)
|
||||
|
||||
REQUEST_FIELDS = {
|
||||
"source_expected_revision",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
}
|
||||
|
||||
|
||||
class TaskLineageSupersessionIssuanceLedger(DurableIssuanceLedger):
|
||||
"""Operation-scoped one-time fence layered on the existing durable ledger."""
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
operation: str,
|
||||
request: Mapping[str, Any],
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(
|
||||
request,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT 1 FROM issuance WHERE role=? AND operation=? LIMIT 1",
|
||||
(role, operation),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ALREADY_ISSUED")
|
||||
result = issuer()
|
||||
artifact = result.get("artifact")
|
||||
nonce = artifact.get("payload", {}).get("nonce") if isinstance(artifact, Mapping) else None
|
||||
if not isinstance(nonce, str) or not nonce:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ISSUER_RESULT_INVALID")
|
||||
encoded = json.dumps(
|
||||
result,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
connection.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OperationalAuthorityService(BaseOperationalAuthorityService):
|
||||
"""Preserve all existing operations and add one exact Owner-only operation."""
|
||||
|
||||
def __init__(self, *args: Any, token_hex=None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ledger = TaskLineageSupersessionIssuanceLedger(self.ledger.path)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OperationalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if self.role == "owner":
|
||||
kwargs = {}
|
||||
if self._token_hex is not None:
|
||||
kwargs["token_hex"] = self._token_hex
|
||||
signer = TaskLineageSupersessionOwnerAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(
|
||||
self.machine_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
fail("REQUEST_INVALID")
|
||||
if request.get("operation") != TASK_LINEAGE_SUPERSESSION_OPERATION:
|
||||
return super().handle(request, peer_uid=peer_uid)
|
||||
if self.role != "owner":
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
payload = request.get("payload")
|
||||
if not isinstance(payload, Mapping) or set(payload) != REQUEST_FIELDS:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
_policy, _verifier, signer = self._components()
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
artifact = signer.issue_task_lineage_supersession(
|
||||
source_expected_revision=payload["source_expected_revision"],
|
||||
source_task_identity_sha256=payload["source_task_identity_sha256"],
|
||||
source_metadata_sha256=payload["source_metadata_sha256"],
|
||||
)
|
||||
return {"status": "PASS", "artifact": artifact.to_dict()}
|
||||
|
||||
return self.ledger.issue_once(
|
||||
role=self.role,
|
||||
operation=TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
request=payload,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--role", choices=("owner", "machine"), required=True)
|
||||
parser.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
parser.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
parser.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
parser.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
parser.add_argument("--socket")
|
||||
parser.add_argument("--ledger", default=str(DEFAULT_LEDGER))
|
||||
arguments = parser.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=arguments.role,
|
||||
policy_path=Path(arguments.policy),
|
||||
registry_path=Path(arguments.registry),
|
||||
owner_key_path=Path(arguments.owner_key),
|
||||
machine_key_path=Path(arguments.machine_key),
|
||||
socket_path=Path(arguments.socket) if arguments.socket else None,
|
||||
ledger_path=Path(arguments.ledger),
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,958 @@
|
||||
#!/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, replace
|
||||
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
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
||||
|
||||
@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.recipes.get(version)
|
||||
if recipe is not None:
|
||||
resolved = self.fixed_recipe(version)
|
||||
# Fixed/governed-derived policy recipes remain immutable. A
|
||||
# source admission can never override one of these identities.
|
||||
effective_candidate_id = str(candidate_id or resolved.get("candidate_id") or "")
|
||||
if {
|
||||
"version": resolved.get("version"),
|
||||
"candidate_id": resolved.get("candidate_id"),
|
||||
"source_commit": resolved.get("source_commit"),
|
||||
} != {
|
||||
"version": version,
|
||||
"candidate_id": effective_candidate_id,
|
||||
"source_commit": source_commit,
|
||||
}:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return resolved
|
||||
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
try:
|
||||
admitted = dict(resolver(version, candidate_id, source_commit))
|
||||
except Exception as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
||||
required = {
|
||||
"mode",
|
||||
"task_id",
|
||||
"candidate_id",
|
||||
"base_oid",
|
||||
"source_commit",
|
||||
"version",
|
||||
"purpose",
|
||||
}
|
||||
if (
|
||||
set(admitted) != required
|
||||
or admitted.get("mode") != "governed-source-admission"
|
||||
or admitted.get("version") != version
|
||||
or admitted.get("source_commit") != source_commit
|
||||
or not isinstance(admitted.get("candidate_id"), str)
|
||||
or not admitted.get("candidate_id")
|
||||
):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
||||
if effective_candidate_id != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit"):
|
||||
value = admitted.get(name)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) != 40
|
||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
||||
):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
return admitted
|
||||
|
||||
|
||||
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 assert_ready(self) -> None:
|
||||
"""Validate the server-owned evidence facility without requiring recipe evidence.
|
||||
|
||||
Readiness is an infrastructure/property check. Candidate-specific evidence
|
||||
remains mandatory for ``BuildTrustedCandidateV1`` and is validated by
|
||||
:meth:`__call__`; an empty evidence root therefore no longer makes the
|
||||
builder globally unavailable.
|
||||
"""
|
||||
|
||||
for path, code in (
|
||||
(self._authority_git_dir, "COLLECTOR_AUTHORITY_GIT_UNAVAILABLE"),
|
||||
(self._evidence_root, "COLLECTOR_EVIDENCE_ROOT_UNAVAILABLE"),
|
||||
):
|
||||
try:
|
||||
metadata = path.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise BuilderError(code) from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
||||
raise BuilderError(code)
|
||||
if path == self._evidence_root:
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_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_ROOT_OWNER_DENIED")
|
||||
|
||||
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 assert_ready(self) -> None:
|
||||
"""Fail closed on collector configuration, not on recipe evidence presence."""
|
||||
|
||||
if self._evidence_source is None:
|
||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
||||
checker = getattr(self._evidence_source, "assert_ready", None)
|
||||
if checker is not None:
|
||||
checker()
|
||||
|
||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self.assert_ready()
|
||||
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
|
||||
from release_migration_plan import (
|
||||
MigrationPlanError,
|
||||
MigrationPlanRequired,
|
||||
)
|
||||
|
||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
||||
release_plan_payload = dict(bundle["release_plan"])
|
||||
migration_resolver = self._config.migration_plan_resolver
|
||||
if migration_resolver is not None:
|
||||
try:
|
||||
migration = migration_resolver(
|
||||
recipe_name,
|
||||
recipe["candidate_id"],
|
||||
recipe["source_commit"],
|
||||
)
|
||||
except MigrationPlanRequired as exc:
|
||||
raise BuilderError("MIGRATION_PLAN_REQUIRED") from exc
|
||||
except MigrationPlanError as exc:
|
||||
raise BuilderError("MIGRATION_PLAN_INVALID") from exc
|
||||
if migration is not None:
|
||||
release_plan_payload["migration"] = migration.to_dict()
|
||||
release_plan = ReleasePlan.from_dict(release_plan_payload)
|
||||
checks = dict(raw_checks)
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
except BuilderError:
|
||||
raise
|
||||
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")
|
||||
if request["operation"] == self.config.readiness_operation:
|
||||
self.collector.assert_ready()
|
||||
# Accessing the identity properties validates that the signer is
|
||||
# configured and usable without invoking the private key.
|
||||
builder_key_id = self.signer.key_id
|
||||
builder_key_generation = self.signer.key_generation
|
||||
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": builder_key_id,
|
||||
"builder_key_generation": builder_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
|
||||
|
||||
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")
|
||||
|
||||
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))
|
||||
source_admission_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT", ""
|
||||
).strip()
|
||||
if source_admission_root:
|
||||
from release_source_admission import open_source_admission_store
|
||||
|
||||
try:
|
||||
source_admission_hmac = (
|
||||
Path(credentials) / "source_admission_hmac"
|
||||
).read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_HMAC_UNAVAILABLE") from exc
|
||||
source_store = open_source_admission_store(
|
||||
Path(source_admission_root),
|
||||
repository=config.repository,
|
||||
policy=arguments.policy,
|
||||
hmac_key=source_admission_hmac,
|
||||
expected_owner_uid=0,
|
||||
writable=False,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=source_store.resolve_recipe,
|
||||
)
|
||||
migration_plan_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PLAN_ROOT", ""
|
||||
).strip()
|
||||
migration_profile_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PROFILE_ROOT", ""
|
||||
).strip()
|
||||
if bool(migration_plan_root) != bool(migration_profile_root):
|
||||
raise BuilderError("MIGRATION_PLAN_CONFIGURATION_INVALID")
|
||||
if migration_plan_root:
|
||||
from release_migration_plan import FileMigrationPlanResolver
|
||||
|
||||
migration_resolver = FileMigrationPlanResolver(
|
||||
Path(migration_plan_root),
|
||||
profile_root=Path(migration_profile_root),
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
migration_plan_resolver=migration_resolver.resolve,
|
||||
)
|
||||
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()
|
||||
+687
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot governed executor for an already approved exact release binding.
|
||||
|
||||
This module deliberately does not build release artifacts. The build/staging
|
||||
evidence is completed before Owner production approval. Production execution
|
||||
consumes that immutable binding, revalidates it, and performs narrow
|
||||
side-effects through adapters in a durable fail-closed order.
|
||||
|
||||
Adapter protocol:
|
||||
stdin: JSON request with operation = "probe" | "apply" | "rollback"
|
||||
stdout: one JSON object on the final non-empty line
|
||||
shell: never used
|
||||
|
||||
The executor never rewrites tags, never writes refs/heads/main, and records
|
||||
Baseline last.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import dataclasses
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
REQUIRED_V2R1C7_MEMBERS = {
|
||||
"payload/VERSION",
|
||||
"payload/active-runtime/app/deployment_state.py",
|
||||
"payload/active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
"payload/active-runtime/app/v2r1c7_health_service.py",
|
||||
"payload/deploy/aios-v2/systemd/aios-v2r1c7-production.service",
|
||||
"payload/deploy/aios-v2/v2r1c7-runtime-config.json",
|
||||
"payload/deploy/aios-v2/v2r1c7-rollback.json",
|
||||
"payload/scripts/probe_v2r1c7_health.py",
|
||||
"payload/scripts/rollback_v2r1c7_release.sh",
|
||||
"payload/BUILD-METADATA.json",
|
||||
"payload/ARTIFACT-MANIFEST.json",
|
||||
"payload/ROLLBACK-METADATA.json",
|
||||
}
|
||||
|
||||
class ReleaseBlocked(RuntimeError):
|
||||
pass
|
||||
|
||||
class ManualOwnerReviewRequired(ReleaseBlocked):
|
||||
pass
|
||||
|
||||
class ReconciliationRequired(ReleaseBlocked):
|
||||
pass
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Binding:
|
||||
version: str
|
||||
target_oid: str
|
||||
evidence_closure_oid: str
|
||||
artifact_sha256: str
|
||||
approval_plan_id: str
|
||||
artifact_path: str
|
||||
artifact_size: int
|
||||
production_service: str
|
||||
production_port: int
|
||||
repository_identity: str = "aios/aios"
|
||||
|
||||
candidate_id: str | None = None
|
||||
release_plan_sha256: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_trusted_plan(cls, plan: TrustedReleasePlan) -> "Binding":
|
||||
value = plan.value
|
||||
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:
|
||||
if not self.version or not OID_RE.fullmatch(self.target_oid):
|
||||
raise ValueError("invalid release target binding")
|
||||
if not OID_RE.fullmatch(self.evidence_closure_oid):
|
||||
raise ValueError("invalid evidence closure OID")
|
||||
if not SHA_RE.fullmatch(self.artifact_sha256):
|
||||
raise ValueError("invalid artifact SHA-256")
|
||||
if not PLAN_RE.fullmatch(self.approval_plan_id):
|
||||
raise ValueError("invalid approval plan id")
|
||||
if self.artifact_size <= 0:
|
||||
raise ValueError("invalid artifact size")
|
||||
if not self.production_service:
|
||||
raise ValueError("production service is required")
|
||||
if not 1024 <= self.production_port <= 65535:
|
||||
raise ValueError("invalid production port")
|
||||
if self.repository_identity != "aios/aios":
|
||||
raise ValueError("unexpected repository identity")
|
||||
|
||||
@property
|
||||
def tag(self) -> str:
|
||||
return self.version
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
payload = {
|
||||
"version": self.version,
|
||||
"target_oid": self.target_oid,
|
||||
"evidence_closure_oid": self.evidence_closure_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"approval_plan_id": self.approval_plan_id,
|
||||
"artifact_size": self.artifact_size,
|
||||
"production_service": self.production_service,
|
||||
"production_port": self.production_port,
|
||||
"repository_identity": self.repository_identity,
|
||||
"candidate_id": self.candidate_id,
|
||||
"release_plan_sha256": self.release_plan_sha256,
|
||||
}
|
||||
return hashlib.sha256(canonical_json(payload)).hexdigest()
|
||||
|
||||
def request(self, operation: str) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "aios.production-executor-request.v1",
|
||||
"operation": operation,
|
||||
"binding_fingerprint": self.fingerprint,
|
||||
"version": self.version,
|
||||
"tag": self.tag,
|
||||
"target_oid": self.target_oid,
|
||||
"evidence_closure_oid": self.evidence_closure_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"artifact_path": self.artifact_path,
|
||||
"artifact_size": self.artifact_size,
|
||||
"approval_plan_id": self.approval_plan_id,
|
||||
"production_service": self.production_service,
|
||||
"production_port": self.production_port,
|
||||
"repository_identity": self.repository_identity,
|
||||
"candidate_id": self.candidate_id,
|
||||
"release_plan_sha256": self.release_plan_sha256,
|
||||
}
|
||||
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def secure_json(path: Path) -> dict[str, Any]:
|
||||
st = path.lstat()
|
||||
if not stat.S_ISREG(st.st_mode) or path.is_symlink():
|
||||
raise ReleaseBlocked(f"non-regular authorization file: {path}")
|
||||
if st.st_uid != 0:
|
||||
raise ReleaseBlocked(f"authorization file must be root-owned: {path}")
|
||||
if st.st_mode & 0o022:
|
||||
raise ReleaseBlocked(f"authorization file is group/world writable: {path}")
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ReleaseBlocked("authorization receipt must be a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseBlocked("artifact must be a regular non-symlink file")
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run_git(git_dir: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
if not OID_RE.fullmatch(authority_main):
|
||||
raise ReleaseBlocked("Authority main OID is invalid")
|
||||
|
||||
target_type = run_git(git_dir, "cat-file", "-t", binding.target_oid).stdout.strip()
|
||||
if target_type != "commit":
|
||||
raise ReleaseBlocked("target_oid is not a commit")
|
||||
|
||||
evidence_type = run_git(
|
||||
git_dir, "cat-file", "-t", binding.evidence_closure_oid
|
||||
).stdout.strip()
|
||||
if evidence_type != "commit":
|
||||
raise ReleaseBlocked("evidence_closure_oid is not a commit")
|
||||
|
||||
# A control-plane-only patch may advance main after release approval.
|
||||
# The approved release target remains valid only while it is still on main.
|
||||
target_on_main = run_git(
|
||||
git_dir,
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
binding.target_oid,
|
||||
authority_main,
|
||||
check=False,
|
||||
).returncode == 0
|
||||
if not target_on_main:
|
||||
raise ManualOwnerReviewRequired(
|
||||
"approved target is no longer an ancestor of Authority main"
|
||||
)
|
||||
|
||||
target_in_evidence = run_git(
|
||||
git_dir,
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
binding.target_oid,
|
||||
binding.evidence_closure_oid,
|
||||
check=False,
|
||||
).returncode == 0
|
||||
if not target_in_evidence:
|
||||
raise ReleaseBlocked("approved target is not an ancestor of evidence closure")
|
||||
|
||||
return {
|
||||
"authority_main_oid": authority_main,
|
||||
"target_oid": binding.target_oid,
|
||||
"target_is_ancestor_of_main": True,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"evidence_object_type": evidence_type,
|
||||
"target_is_ancestor_of_evidence": True,
|
||||
}
|
||||
|
||||
|
||||
def verify_artifact(binding: Binding) -> dict[str, Any]:
|
||||
path = Path(binding.artifact_path)
|
||||
if path.stat().st_size != binding.artifact_size:
|
||||
raise ReleaseBlocked("artifact size mismatch")
|
||||
actual = sha256_file(path)
|
||||
if actual != binding.artifact_sha256:
|
||||
raise ReleaseBlocked("artifact SHA-256 mismatch")
|
||||
|
||||
if binding.version == "V2R1C7":
|
||||
# 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:
|
||||
names: set[str] = set()
|
||||
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 {
|
||||
"artifact_path": str(path),
|
||||
"artifact_sha256": actual,
|
||||
"artifact_size": binding.artifact_size,
|
||||
"verified": True,
|
||||
}
|
||||
|
||||
|
||||
def verify_authorization(receipt: Mapping[str, Any], binding: Binding) -> dict[str, Any]:
|
||||
if receipt.get("accepted") is not True:
|
||||
raise ReleaseBlocked("Owner production authorization is not accepted")
|
||||
if receipt.get("binding_replaced") not in (None, False):
|
||||
raise ManualOwnerReviewRequired("Owner binding was replaced")
|
||||
expected = {
|
||||
"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.candidate_id is not None:
|
||||
expected["candidate_id"] = binding.candidate_id
|
||||
if binding.release_plan_sha256 is not None:
|
||||
expected["release_plan_sha256"] = binding.release_plan_sha256
|
||||
if receipt.get("candidate_binding_sha256") is None:
|
||||
raise ManualOwnerReviewRequired("Owner authorization missing candidate binding")
|
||||
for key, value in expected.items():
|
||||
if receipt.get(key) != value:
|
||||
raise ManualOwnerReviewRequired(f"Owner authorization mismatch: {key}")
|
||||
return {"accepted": True, **expected}
|
||||
|
||||
|
||||
class DurableState:
|
||||
def __init__(self, root: Path, binding: Binding):
|
||||
self.root = root
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(self.root, 0o700)
|
||||
self.path = self.root / "execution.json"
|
||||
self.binding = binding
|
||||
self.value: dict[str, Any]
|
||||
if self.path.exists():
|
||||
existing = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if existing.get("binding_fingerprint") != binding.fingerprint:
|
||||
raise ManualOwnerReviewRequired(
|
||||
"state root contains a different production binding"
|
||||
)
|
||||
self.value = existing
|
||||
else:
|
||||
self.value = {
|
||||
"schema": "aios.production-execution-state.v1",
|
||||
"binding_fingerprint": binding.fingerprint,
|
||||
"version": binding.version,
|
||||
"target_oid": binding.target_oid,
|
||||
"state": "AUTHORIZED",
|
||||
"completed": {},
|
||||
"history": [],
|
||||
}
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
temporary = self.path.with_suffix(".tmp")
|
||||
temporary.write_bytes(canonical_json(self.value))
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self.path)
|
||||
|
||||
def record(self, state: str, step: str, result: Mapping[str, Any]) -> None:
|
||||
now = time.time()
|
||||
sanitized = json.loads(json.dumps(result))
|
||||
self.value["state"] = state
|
||||
self.value["completed"][step] = sanitized
|
||||
self.value["history"].append(
|
||||
{"at": now, "state": state, "step": step, "result": sanitized}
|
||||
)
|
||||
self.save()
|
||||
|
||||
def fail(self, state: str, reason: str) -> None:
|
||||
self.value["state"] = state
|
||||
self.value["history"].append(
|
||||
{"at": time.time(), "state": state, "reason": reason}
|
||||
)
|
||||
self.save()
|
||||
|
||||
|
||||
class FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.handle = None
|
||||
|
||||
def __enter__(self) -> "FileLock":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
os.chmod(self.path, 0o600)
|
||||
try:
|
||||
fcntl.flock(
|
||||
self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB
|
||||
)
|
||||
except BlockingIOError as exc:
|
||||
raise ReleaseBlocked("production executor is already running") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self.handle is not None:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
def parse_command(values: Sequence[str]) -> list[str]:
|
||||
command = [str(x) for x in values if str(x)]
|
||||
if not command:
|
||||
raise ValueError("adapter command is required")
|
||||
if not os.path.isabs(command[0]):
|
||||
raise ValueError("adapter executable must be an absolute path")
|
||||
return command
|
||||
|
||||
|
||||
def run_adapter(command: Sequence[str], request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
completed = subprocess.run(
|
||||
list(command),
|
||||
input=canonical_json(request),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=180,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise ReleaseBlocked(
|
||||
f"adapter failed: {Path(command[0]).name} rc={completed.returncode}"
|
||||
)
|
||||
lines = [line for line in completed.stdout.decode("utf-8").splitlines() if line.strip()]
|
||||
if not lines:
|
||||
raise ReleaseBlocked("adapter returned no JSON result")
|
||||
try:
|
||||
value = json.loads(lines[-1])
|
||||
except ValueError as exc:
|
||||
raise ReleaseBlocked("adapter returned invalid JSON") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ReleaseBlocked("adapter result must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def require_exact_response(result: Mapping[str, Any], binding: Binding) -> None:
|
||||
for key, expected in {
|
||||
"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,
|
||||
}.items():
|
||||
if key in result and result[key] != expected:
|
||||
raise ManualOwnerReviewRequired(f"adapter binding mismatch: {key}")
|
||||
|
||||
|
||||
def tag_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
||||
request = binding.request("probe")
|
||||
probe = run_adapter(command, request)
|
||||
require_exact_response(probe, binding)
|
||||
state = probe.get("status")
|
||||
if state == "CONFLICT":
|
||||
raise ManualOwnerReviewRequired("same-name tag conflicts with approved binding")
|
||||
if state == "INDETERMINATE":
|
||||
raise ReconciliationRequired("tag publication state is indeterminate")
|
||||
if state not in {"APPLIED_VERIFIED", "NOT_APPLIED", "PARTIAL_LOCAL"}:
|
||||
raise ReleaseBlocked(f"unsupported tag probe status: {state}")
|
||||
if state != "APPLIED_VERIFIED":
|
||||
applied = run_adapter(command, binding.request("apply"))
|
||||
require_exact_response(applied, binding)
|
||||
if applied.get("status") == "CONFLICT":
|
||||
raise ManualOwnerReviewRequired("tag conflict during publication")
|
||||
probe = run_adapter(command, request)
|
||||
require_exact_response(probe, binding)
|
||||
if probe.get("status") != "APPLIED_VERIFIED":
|
||||
raise ReconciliationRequired("tag was not independently verified")
|
||||
if probe.get("peeled_target_oid") != binding.target_oid:
|
||||
raise ManualOwnerReviewRequired("published tag peeled target mismatch")
|
||||
tag_oid = probe.get("tag_oid")
|
||||
if not isinstance(tag_oid, str) or not OID_RE.fullmatch(tag_oid):
|
||||
raise ReleaseBlocked("verified annotated tag object OID missing")
|
||||
return dict(probe)
|
||||
|
||||
|
||||
def deploy_step(command: Sequence[str], binding: Binding) -> dict[str, Any]:
|
||||
probe = run_adapter(command, binding.request("probe"))
|
||||
require_exact_response(probe, binding)
|
||||
if probe.get("status") == "ACTIVE_VERIFIED":
|
||||
return dict(probe)
|
||||
if probe.get("status") == "CONFLICT":
|
||||
raise ManualOwnerReviewRequired("production runtime has conflicting binding")
|
||||
applied = run_adapter(command, binding.request("apply"))
|
||||
require_exact_response(applied, binding)
|
||||
probe = run_adapter(command, binding.request("probe"))
|
||||
require_exact_response(probe, binding)
|
||||
if probe.get("status") != "ACTIVE_VERIFIED":
|
||||
try:
|
||||
run_adapter(command, binding.request("rollback"))
|
||||
except Exception:
|
||||
pass
|
||||
raise ReconciliationRequired("production runtime did not verify after apply")
|
||||
if (
|
||||
probe.get("deployment_state") != "ACTIVE"
|
||||
or probe.get("production_deployed") is not True
|
||||
):
|
||||
raise ReconciliationRequired("production health contract is not ACTIVE")
|
||||
return dict(probe)
|
||||
|
||||
|
||||
def finalize_step(
|
||||
command: Sequence[str],
|
||||
binding: Binding,
|
||||
*,
|
||||
name: str,
|
||||
tag_oid: str,
|
||||
) -> dict[str, Any]:
|
||||
request = binding.request("probe")
|
||||
request["tag_oid"] = tag_oid
|
||||
probe = run_adapter(command, request)
|
||||
require_exact_response(probe, binding)
|
||||
if probe.get("status") == "CONFLICT":
|
||||
raise ManualOwnerReviewRequired(f"{name} has conflicting release binding")
|
||||
if probe.get("status") != "VERIFIED":
|
||||
apply_request = binding.request("apply")
|
||||
apply_request["tag_oid"] = tag_oid
|
||||
applied = run_adapter(command, apply_request)
|
||||
require_exact_response(applied, binding)
|
||||
probe = run_adapter(command, request)
|
||||
require_exact_response(probe, binding)
|
||||
if probe.get("status") != "VERIFIED":
|
||||
raise ReconciliationRequired(f"{name} did not verify after apply")
|
||||
if probe.get("tag_oid") not in (None, tag_oid):
|
||||
raise ManualOwnerReviewRequired(f"{name} tag object mismatch")
|
||||
return dict(probe)
|
||||
|
||||
|
||||
def execute(
|
||||
*,
|
||||
binding: Binding,
|
||||
authorization_path: Path,
|
||||
repository: Path,
|
||||
state_root: Path,
|
||||
tag_command: Sequence[str],
|
||||
deploy_command: Sequence[str],
|
||||
registry_command: Sequence[str],
|
||||
baseline_command: Sequence[str],
|
||||
release_plan_path: Path | None = None,
|
||||
) -> 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)
|
||||
lineage = verify_git_lineage(repository, binding)
|
||||
artifact = verify_artifact(binding)
|
||||
state = DurableState(state_root, binding)
|
||||
with FileLock(state_root / "executor.lock"):
|
||||
state.record(
|
||||
"PREFLIGHT_VERIFIED",
|
||||
"preflight",
|
||||
{"authorization": authorization, "lineage": lineage, "artifact": artifact},
|
||||
)
|
||||
tag: dict[str, Any] | None = None
|
||||
runtime: dict[str, Any] | None = None
|
||||
try:
|
||||
tag = tag_step(tag_command, binding)
|
||||
state.record("TAG_VERIFIED", "tag", tag)
|
||||
|
||||
runtime = deploy_step(deploy_command, binding)
|
||||
state.record("RUNTIME_VERIFIED", "runtime", runtime)
|
||||
|
||||
registry = finalize_step(
|
||||
registry_command,
|
||||
binding,
|
||||
name="registry",
|
||||
tag_oid=tag["tag_oid"],
|
||||
)
|
||||
state.record("REGISTRY_VERIFIED", "registry", registry)
|
||||
|
||||
baseline = finalize_step(
|
||||
baseline_command,
|
||||
binding,
|
||||
name="baseline",
|
||||
tag_oid=tag["tag_oid"],
|
||||
)
|
||||
state.record("BASELINE_VERIFIED", "baseline", baseline)
|
||||
|
||||
# Final readback: no mutation, all four planes must still agree.
|
||||
final_tag = tag_step(tag_command, binding)
|
||||
final_runtime = run_adapter(deploy_command, binding.request("probe"))
|
||||
final_registry_request = binding.request("probe")
|
||||
final_registry_request["tag_oid"] = tag["tag_oid"]
|
||||
final_registry = run_adapter(registry_command, final_registry_request)
|
||||
final_baseline_request = binding.request("probe")
|
||||
final_baseline_request["tag_oid"] = tag["tag_oid"]
|
||||
final_baseline = run_adapter(baseline_command, final_baseline_request)
|
||||
for value in (final_runtime, final_registry, final_baseline):
|
||||
require_exact_response(value, binding)
|
||||
if final_runtime.get("status") != "ACTIVE_VERIFIED":
|
||||
raise ReconciliationRequired("final runtime readback failed")
|
||||
if final_registry.get("status") != "VERIFIED":
|
||||
raise ReconciliationRequired("final Registry readback failed")
|
||||
if final_baseline.get("status") != "VERIFIED":
|
||||
raise ReconciliationRequired("final Baseline readback failed")
|
||||
final = {
|
||||
"status": "PRODUCTION_ACCEPTED",
|
||||
"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,
|
||||
"tag_oid": tag["tag_oid"],
|
||||
"tag": final_tag,
|
||||
"runtime": final_runtime,
|
||||
"registry": final_registry,
|
||||
"baseline": final_baseline,
|
||||
"force_push_used": False,
|
||||
"tag_rewritten": False,
|
||||
"direct_main_push": False,
|
||||
"secrets_committed": False,
|
||||
}
|
||||
state.record("COMPLETED", "final_consistency", final)
|
||||
return final
|
||||
except ManualOwnerReviewRequired as exc:
|
||||
state.fail("MANUAL_OWNER_REVIEW_REQUIRED", str(exc))
|
||||
raise
|
||||
except ReconciliationRequired as exc:
|
||||
state.fail("RECONCILIATION_REQUIRED", str(exc))
|
||||
raise
|
||||
except Exception as exc:
|
||||
# Once a formal tag has been verified, no later error is a clean
|
||||
# pre-side-effect failure. Preserve the tag and require idempotent
|
||||
# reconciliation rather than pretending the release never started.
|
||||
if tag is not None:
|
||||
reason = f"{type(exc).__name__}: {exc}"
|
||||
state.fail("RECONCILIATION_REQUIRED", reason)
|
||||
raise ReconciliationRequired(reason) from exc
|
||||
state.fail("FAIL_CLOSED", f"{type(exc).__name__}: {exc}")
|
||||
raise
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser()
|
||||
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("--state-root", required=True, type=Path)
|
||||
p.add_argument("--artifact", type=Path)
|
||||
p.add_argument("--artifact-size", type=int)
|
||||
p.add_argument("--version")
|
||||
p.add_argument("--target-oid")
|
||||
p.add_argument("--evidence-closure-oid")
|
||||
p.add_argument("--artifact-sha256")
|
||||
p.add_argument("--approval-plan-id")
|
||||
p.add_argument("--production-service")
|
||||
p.add_argument("--production-port", type=int)
|
||||
p.add_argument("--repository-identity", default="aios/aios")
|
||||
p.add_argument("--tag-adapter", required=True, nargs="+")
|
||||
p.add_argument("--deploy-adapter", required=True, nargs="+")
|
||||
p.add_argument("--registry-adapter", required=True, nargs="+")
|
||||
p.add_argument("--baseline-adapter", required=True, nargs="+")
|
||||
return p
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
plan = TrustedReleasePlan.load(args.release_plan) if args.release_plan else None
|
||||
if plan is not None:
|
||||
binding = Binding.from_trusted_plan(plan)
|
||||
else:
|
||||
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)
|
||||
if any(value is None for value in legacy): raise SystemExit("legacy binding is incomplete")
|
||||
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)
|
||||
try:
|
||||
result = execute(
|
||||
binding=binding,
|
||||
authorization_path=args.authorization,
|
||||
repository=args.repository,
|
||||
state_root=args.state_root,
|
||||
tag_command=parse_command(args.tag_adapter),
|
||||
deploy_command=parse_command(args.deploy_adapter),
|
||||
registry_command=parse_command(args.registry_adapter),
|
||||
baseline_command=parse_command(args.baseline_adapter),
|
||||
release_plan_path=args.release_plan,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except ManualOwnerReviewRequired as exc:
|
||||
print(json.dumps({"status": "MANUAL_OWNER_REVIEW_REQUIRED", "reason": str(exc)}, sort_keys=True))
|
||||
return 20
|
||||
except ReconciliationRequired as exc:
|
||||
print(json.dumps({"status": "RECONCILIATION_REQUIRED", "reason": str(exc)}, sort_keys=True))
|
||||
return 21
|
||||
except Exception as exc:
|
||||
print(json.dumps({"status": "FAIL_CLOSED", "error": type(exc).__name__, "reason": str(exc)}, sort_keys=True))
|
||||
return 22
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
@@ -306,14 +307,76 @@ def authoritative_diff(
|
||||
from_oid = validate_git_revision(from_revision, "from", 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]
|
||||
result = run_authoritative_git(args, git_dir=path)
|
||||
patch_limit = max(1, min(_MAX_OUTPUT, 256 * 1024))
|
||||
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 {
|
||||
"code": 0,
|
||||
"stdout": result.stdout,
|
||||
"stdout": patch,
|
||||
"stderr": "",
|
||||
"cmd": ["git", f"--git-dir={path}", *args],
|
||||
"from_oid": from_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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -314,6 +314,27 @@ def _tool_argv(
|
||||
return [str(path), *args]
|
||||
|
||||
|
||||
def _provisioner_env_argv(
|
||||
config: ProvisioningConfig,
|
||||
root_config: Path,
|
||||
path: Path,
|
||||
*args: str,
|
||||
) -> list[str]:
|
||||
"""Build the provisioner argv with an explicit AIOS_GRANT_ROOT_CONFIG so it
|
||||
survives sudo `Defaults env_reset` in production sudoers (same systemic fix
|
||||
as the validator). Direct (non-sudo) runs keep the original form."""
|
||||
if config.governed_use_sudo:
|
||||
return [
|
||||
str(config.sudo_path),
|
||||
"-n",
|
||||
"env",
|
||||
f"AIOS_GRANT_ROOT_CONFIG={root_config}",
|
||||
str(path),
|
||||
*args,
|
||||
]
|
||||
return [str(path), *args]
|
||||
|
||||
|
||||
def _verify_tool_contract(
|
||||
path: Path,
|
||||
name: str,
|
||||
@@ -819,7 +840,7 @@ class AuthorizedTaskWriterProvisioner:
|
||||
raise ProvisioningError(
|
||||
"SIGNER_COMPATIBILITY_FAILED",
|
||||
503,
|
||||
details={"error": type(exc).__name__},
|
||||
details={"error": type(exc).__name__, "message": str(exc)},
|
||||
) from exc
|
||||
signer_result = _read_json(signer_result_path)
|
||||
signer = _verify_signer_result(
|
||||
@@ -837,7 +858,7 @@ class AuthorizedTaskWriterProvisioner:
|
||||
raise ProvisioningError(
|
||||
"VALIDATOR_COMPATIBILITY_FAILED",
|
||||
503,
|
||||
details={"error": type(exc).__name__},
|
||||
details={"error": type(exc).__name__, "message": str(exc)},
|
||||
) from exc
|
||||
validator_result = _read_json(validator_result_path)
|
||||
_verify_validator_result(
|
||||
@@ -851,8 +872,9 @@ class AuthorizedTaskWriterProvisioner:
|
||||
validator_config_path
|
||||
)
|
||||
_run(
|
||||
_tool_argv(
|
||||
_provisioner_env_argv(
|
||||
self.config,
|
||||
validator_config_path,
|
||||
self.config.provisioner,
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
@@ -951,3 +973,51 @@ class AuthorizedTaskWriterProvisioner:
|
||||
return {**public_result, "prestart": prestart}
|
||||
finally:
|
||||
shutil.rmtree(temporary_root, ignore_errors=True)
|
||||
|
||||
|
||||
# v2 continuation validation is intentionally separate from the legacy Owner
|
||||
# bootstrap path above. It consumes a recovered campaign authorization and
|
||||
# never creates a branch or asks the Owner to authorize again.
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
|
||||
|
||||
def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict[str, Any]:
|
||||
"""Validate a CONTINUE_EXISTING request against the live exact branch head."""
|
||||
required = {
|
||||
"schema",
|
||||
"task_id",
|
||||
"repository",
|
||||
"branch_mode",
|
||||
"base_oid",
|
||||
"expected_branch_oid",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"lease_seconds",
|
||||
}
|
||||
if set(raw) != required or raw.get("schema") not in WriterLeaseRequestV2.accepted_schemas:
|
||||
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
||||
request = WriterLeaseRequestV2(
|
||||
task_id=str(raw["task_id"]),
|
||||
repository=str(raw["repository"]),
|
||||
branch_mode=str(raw["branch_mode"]),
|
||||
base_oid=str(raw["base_oid"]),
|
||||
expected_branch_oid=str(raw["expected_branch_oid"]),
|
||||
target_branch=str(raw["target_branch"]),
|
||||
generation=raw["generation"],
|
||||
lease_seconds=raw["lease_seconds"],
|
||||
)
|
||||
request.validate(probe)
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_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": request.generation,
|
||||
"lease_seconds": request.lease_seconds,
|
||||
"create_target_branch": request.branch_mode == "CREATE_NEW",
|
||||
"worktree_mode": "detached_at_base_oid" if request.branch_mode == "CREATE_NEW" else "detached_at_expected_head",
|
||||
"cas_ref_update_required": True,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Single canonical Production Baseline authority for the GitOps reader.
|
||||
|
||||
The API and the one-shot writer import this module. Workspace and release
|
||||
copies are projections only; they are never selected as a fallback source.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
AUTHORITY_PATH = Path(os.getenv(
|
||||
"AIOS_GITOPS_BASELINE_AUTHORITY",
|
||||
"/var/lib/aios-gitops-runtime-anchor/baseline/current.json",
|
||||
))
|
||||
AUTHORITY_SHA_PATH = AUTHORITY_PATH.with_suffix(".sha256")
|
||||
|
||||
|
||||
class BaselineAuthorityError(RuntimeError):
|
||||
"""Fail-closed error raised for an unavailable or inconsistent authority."""
|
||||
|
||||
|
||||
class BaselineAuthority:
|
||||
def __init__(self, path: Path | str = AUTHORITY_PATH):
|
||||
self.path = Path(path)
|
||||
self.sha_path = self.path.with_suffix(".sha256")
|
||||
|
||||
def _raw(self) -> bytes:
|
||||
try:
|
||||
raw = self.path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise BaselineAuthorityError(f"authority_unavailable:{exc}") from exc
|
||||
if not raw:
|
||||
raise BaselineAuthorityError("authority_empty")
|
||||
return raw
|
||||
|
||||
def read(self) -> dict[str, Any]:
|
||||
raw = self._raw()
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BaselineAuthorityError("authority_invalid_json") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise BaselineAuthorityError("authority_not_object")
|
||||
expected = str(value.get("authority_sha256", ""))
|
||||
if expected and expected != digest:
|
||||
raise BaselineAuthorityError("authority_embedded_digest_mismatch")
|
||||
if self.sha_path.exists():
|
||||
sidecar = self.sha_path.read_text(encoding="utf-8").strip().split()[0]
|
||||
if sidecar != digest:
|
||||
raise BaselineAuthorityError("authority_sidecar_digest_mismatch")
|
||||
required = {
|
||||
"current_baseline", "previous_baseline", "target_oid", "tag_oid",
|
||||
"evidence_closure_oid", "approval_plan_id", "status", "generation",
|
||||
"source_identity",
|
||||
}
|
||||
missing = sorted(required.difference(value))
|
||||
if missing:
|
||||
raise BaselineAuthorityError("authority_missing:" + ",".join(missing))
|
||||
value = dict(value)
|
||||
value["authority_sha256"] = digest
|
||||
value["authority_path"] = str(self.path)
|
||||
return value
|
||||
|
||||
def read_consistent(self, policy: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
value = self.read()
|
||||
if policy:
|
||||
expected = policy.get("release_binding", policy)
|
||||
for key, field in (("version", "current_baseline"),
|
||||
("previous_version", "previous_baseline"),
|
||||
("target_oid", "target_oid"),
|
||||
("tag_oid", "tag_oid"),
|
||||
("evidence_closure_oid", "evidence_closure_oid"),
|
||||
("approval_plan_id", "approval_plan_id")):
|
||||
wanted = expected.get(key)
|
||||
if wanted is not None and value.get(field) != wanted:
|
||||
raise BaselineAuthorityError(f"authority_binding_mismatch:{field}")
|
||||
return value
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Atomic, predicate-bound activation of the durable release campaign."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_campaign_authorization import CampaignError, validate_policy
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
CAMPAIGN_ID = "AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807"
|
||||
|
||||
|
||||
class CampaignActivationError(CampaignError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def digest_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def build_active_policy(current: Mapping[str, Any], facts: Mapping[str, Any]) -> dict[str, Any]:
|
||||
policy = validate_policy(dict(current))
|
||||
required = {
|
||||
"campaign_id",
|
||||
"repository",
|
||||
"source_main_oid",
|
||||
"gitea_main_oid",
|
||||
"authority_main_oid",
|
||||
"runtime_source_oid",
|
||||
"durable_job_state",
|
||||
"writer_ready",
|
||||
"gitea_machine_authenticated",
|
||||
"governance_executor_ready",
|
||||
"preapproval_publisher_ready",
|
||||
"runtime_exact_main",
|
||||
"main_lineage_verified",
|
||||
"campaign_revoked",
|
||||
}
|
||||
if set(facts) != required:
|
||||
raise CampaignActivationError("ACTIVATION_FACTS_SCHEMA_INVALID")
|
||||
if policy["activation_state"] != "PENDING_CONTROL_PLANE_MERGE" or policy["active"] is not False:
|
||||
raise CampaignActivationError("CAMPAIGN_NOT_PENDING")
|
||||
if policy["campaign_id"] != CAMPAIGN_ID or facts["campaign_id"] != CAMPAIGN_ID:
|
||||
raise CampaignActivationError("CAMPAIGN_ID_MISMATCH")
|
||||
if facts["repository"] != "aios/aios" or policy["repository"] != facts["repository"]:
|
||||
raise CampaignActivationError("REPOSITORY_MISMATCH")
|
||||
for name in ("source_main_oid", "gitea_main_oid", "authority_main_oid", "runtime_source_oid"):
|
||||
if not isinstance(facts[name], str) or not OID_RE.fullmatch(facts[name]):
|
||||
raise CampaignActivationError("ACTIVATION_OID_INVALID")
|
||||
if not (
|
||||
facts["source_main_oid"]
|
||||
== facts["gitea_main_oid"]
|
||||
== facts["authority_main_oid"]
|
||||
== facts["runtime_source_oid"]
|
||||
):
|
||||
raise CampaignActivationError("ACTIVATION_MAIN_DIVERGENCE")
|
||||
if facts["durable_job_state"] != "COMPLETED":
|
||||
raise CampaignActivationError("DURABLE_JOB_NOT_COMPLETED")
|
||||
boolean_fields = (
|
||||
"writer_ready",
|
||||
"gitea_machine_authenticated",
|
||||
"governance_executor_ready",
|
||||
"preapproval_publisher_ready",
|
||||
"runtime_exact_main",
|
||||
"main_lineage_verified",
|
||||
)
|
||||
if any(facts[name] is not True for name in boolean_fields) or facts["campaign_revoked"] is not False:
|
||||
raise CampaignActivationError("ACTIVATION_PREDICATE_FAILED")
|
||||
activated = dict(policy)
|
||||
activated["activation_state"] = "ACTIVE"
|
||||
activated["active"] = True
|
||||
return validate_policy(activated)
|
||||
|
||||
|
||||
def atomic_write_policy(path: Path, sidecar: Path, receipt_path: Path, policy: Mapping[str, Any], receipt: Mapping[str, Any]) -> str:
|
||||
if path.is_symlink() or sidecar.is_symlink() or receipt_path.exists() or receipt_path.is_symlink():
|
||||
raise CampaignActivationError("ACTIVATION_PATH_INVALID")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
raw = canonical_json(dict(policy))
|
||||
digest = digest_bytes(raw)
|
||||
for target, data, mode in (
|
||||
(path, raw, "wb"),
|
||||
(sidecar, f"{digest} {path.name}\n".encode("ascii"), "wb"),
|
||||
(receipt_path, canonical_json(dict(receipt)), "wb"),
|
||||
):
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, mode) as stream:
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, target)
|
||||
os.chmod(target, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return digest
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Machine campaign approval gate for protected engineering PRs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_campaign_authorization import CampaignAuthorization, CampaignError
|
||||
|
||||
|
||||
class GateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def git_is_ancestor(repository: Path, ancestor: str, descendant: str) -> bool:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={repository}",
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
ancestor,
|
||||
descendant,
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
if result.returncode == 1:
|
||||
return False
|
||||
raise GateError("git_ancestor_check_failed")
|
||||
|
||||
|
||||
def evaluate(
|
||||
campaign: CampaignAuthorization,
|
||||
facts: Mapping[str, Any],
|
||||
repository: Path,
|
||||
) -> dict[str, Any]:
|
||||
required = {
|
||||
"repository",
|
||||
"pr_number",
|
||||
"base_ref",
|
||||
"base_oid",
|
||||
"head_oid",
|
||||
"required_checks_all_success",
|
||||
"required_checks_same_head",
|
||||
"unresolved_conversations",
|
||||
"mergeable",
|
||||
"protected_target",
|
||||
}
|
||||
if set(facts) != required:
|
||||
raise GateError("pr_facts_fields_invalid")
|
||||
if facts["repository"] != "aios/aios":
|
||||
raise GateError("repository_mismatch")
|
||||
if facts["base_ref"] != "refs/heads/main" or facts["protected_target"] != "main":
|
||||
raise GateError("protected_main_required")
|
||||
if facts["required_checks_all_success"] is not True:
|
||||
raise GateError("required_checks_not_success")
|
||||
if facts["required_checks_same_head"] is not True:
|
||||
raise GateError("required_checks_stale")
|
||||
if facts["unresolved_conversations"] != 0:
|
||||
raise GateError("unresolved_conversations")
|
||||
if facts["mergeable"] is not True:
|
||||
raise GateError("pr_not_mergeable")
|
||||
if not git_is_ancestor(repository, campaign.anchor_oid, str(facts["base_oid"])):
|
||||
raise GateError("base_outside_campaign_lineage")
|
||||
if not git_is_ancestor(repository, str(facts["base_oid"]), str(facts["head_oid"])):
|
||||
raise GateError("head_outside_pr_lineage")
|
||||
campaign.assert_operation("protected_merge")
|
||||
return {
|
||||
"schema": "aios.campaign-approval-gate-result.v1",
|
||||
"status": "SUCCESS",
|
||||
"campaign_id": campaign.campaign_id,
|
||||
"pr_number": facts["pr_number"],
|
||||
"head_oid": facts["head_oid"],
|
||||
"owner_ui_interaction_required": False,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--campaign", type=Path, required=True)
|
||||
p.add_argument("--revocation", type=Path)
|
||||
p.add_argument("--pr-facts", type=Path, required=True)
|
||||
p.add_argument("--repository", type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
campaign = CampaignAuthorization.load(
|
||||
a.campaign,
|
||||
revocation=a.revocation,
|
||||
require_active=True,
|
||||
)
|
||||
facts = json.loads(a.pr_facts.read_text(encoding="utf-8"))
|
||||
print(json.dumps(evaluate(campaign, facts, a.repository), sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (CampaignError, GateError) as exc:
|
||||
print(json.dumps({"status": "FAILURE", "reason": str(exc)}, sort_keys=True))
|
||||
raise SystemExit(2)
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automatically accept an exact release binding under an ACTIVE campaign."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_campaign_authorization import CampaignAuthorization, CampaignError
|
||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
||||
|
||||
|
||||
class AcceptanceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise AcceptanceError(f"invalid_file:{path}")
|
||||
st = path.lstat()
|
||||
if st.st_uid != 0 or stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise AcceptanceError(f"unsafe_file:{path}")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise AcceptanceError(f"json_object_required:{path}")
|
||||
return value
|
||||
|
||||
|
||||
def atomic_new(path: Path, value: Mapping[str, Any]) -> None:
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AcceptanceError("acceptance_receipt_exists")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
if os.geteuid() == 0:
|
||||
os.fchown(fd, 0, 0)
|
||||
with os.fdopen(fd, "w", encoding="utf-8", closefd=True) as stream:
|
||||
json.dump(dict(value), stream, sort_keys=True, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
os.chmod(path, 0o600)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def evaluate(
|
||||
campaign: CampaignAuthorization,
|
||||
binding: Mapping[str, Any],
|
||||
evidence: Mapping[str, Any],
|
||||
artifact: Mapping[str, Any],
|
||||
staging: Mapping[str, Any],
|
||||
main: Mapping[str, Any],
|
||||
tag: Mapping[str, Any],
|
||||
release_plan: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
required_binding = {
|
||||
"version",
|
||||
"target_oid",
|
||||
"evidence_closure_oid",
|
||||
"artifact_sha256",
|
||||
"artifact_size",
|
||||
"approval_plan_id",
|
||||
}
|
||||
if set(binding) != required_binding:
|
||||
raise AcceptanceError("binding_fields_invalid")
|
||||
if main.get("repository") != "aios/aios":
|
||||
raise AcceptanceError("repository_identity_mismatch")
|
||||
if main.get("target_in_main_lineage") is not True:
|
||||
raise AcceptanceError("target_outside_main_lineage")
|
||||
if evidence.get("object_type") != "commit":
|
||||
raise AcceptanceError("evidence_not_commit")
|
||||
if evidence.get("target_is_ancestor") is not True:
|
||||
raise AcceptanceError("target_not_evidence_ancestor")
|
||||
if evidence.get("closure_oid") != binding["evidence_closure_oid"]:
|
||||
raise AcceptanceError("evidence_oid_mismatch")
|
||||
if artifact.get("sha256") != binding["artifact_sha256"]:
|
||||
raise AcceptanceError("artifact_digest_mismatch")
|
||||
if artifact.get("size") != binding["artifact_size"]:
|
||||
raise AcceptanceError("artifact_size_mismatch")
|
||||
if artifact.get("retained") is not True:
|
||||
raise AcceptanceError("artifact_not_retained")
|
||||
if staging.get("install") != "PASS":
|
||||
raise AcceptanceError("staging_install_failed")
|
||||
if staging.get("health") != "PASS":
|
||||
raise AcceptanceError("staging_health_failed")
|
||||
if staging.get("restart") != "PASS":
|
||||
raise AcceptanceError("staging_restart_failed")
|
||||
if staging.get("rollback_readiness") != "PASS":
|
||||
raise AcceptanceError("rollback_readiness_failed")
|
||||
if tag.get("conflict") is not False:
|
||||
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")
|
||||
result = {
|
||||
"schema": "aios.campaign-release-acceptance.v1",
|
||||
"accepted": True,
|
||||
"binding_replaced": False,
|
||||
"campaign_id": campaign.campaign_id,
|
||||
"campaign_generation": campaign.data["generation"],
|
||||
**dict(binding),
|
||||
"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:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--campaign", type=Path, required=True)
|
||||
p.add_argument("--revocation", type=Path)
|
||||
p.add_argument("--binding", type=Path, required=True)
|
||||
p.add_argument("--evidence", type=Path, required=True)
|
||||
p.add_argument("--artifact", type=Path, required=True)
|
||||
p.add_argument("--staging", 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("--release-plan", type=Path)
|
||||
p.add_argument("--output", type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
|
||||
campaign = CampaignAuthorization.load(
|
||||
a.campaign,
|
||||
revocation=a.revocation,
|
||||
require_active=True,
|
||||
)
|
||||
receipt = evaluate(
|
||||
campaign,
|
||||
secure_json(a.binding),
|
||||
secure_json(a.evidence),
|
||||
secure_json(a.artifact),
|
||||
secure_json(a.staging),
|
||||
secure_json(a.main_facts),
|
||||
secure_json(a.tag_facts),
|
||||
secure_json(a.release_plan) if a.release_plan else None,
|
||||
)
|
||||
atomic_new(a.output, receipt)
|
||||
print(json.dumps(receipt, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (CampaignError, AcceptanceError) as exc:
|
||||
print(json.dumps({"status": "BLOCKED", "reason": str(exc)}, sort_keys=True))
|
||||
raise SystemExit(2)
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
@@ -23,10 +24,15 @@ try:
|
||||
from authoritative_git import AuthoritativeGitError, run_authoritative_git
|
||||
except ModuleNotFoundError:
|
||||
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}$")
|
||||
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||
VERSION_RE = RELEASE_VERSION_RE
|
||||
DEPLOYMENT_EVIDENCE_RE = re.compile(r"^(?:sha256:[0-9a-f]{64}|git:[0-9a-f]{40})$")
|
||||
FINGERPRINT_FIELDS = (
|
||||
"version",
|
||||
@@ -83,9 +89,14 @@ class PublicationManualOwnerReviewRequired(
|
||||
|
||||
|
||||
def validate_version(version: str) -> str:
|
||||
value = str(version or "")
|
||||
if not VERSION_RE.fullmatch(value):
|
||||
raise ValueError("invalid release version")
|
||||
return validate_release_version(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
|
||||
|
||||
|
||||
@@ -411,13 +422,11 @@ class GiteaTagClient:
|
||||
timeout: float = 15,
|
||||
token_line: int = 1,
|
||||
):
|
||||
self.api_url = api_url.rstrip("/")
|
||||
self.api_url = validate_gitea_api_url(api_url)
|
||||
self.repository = repository.strip("/")
|
||||
self.token_file = token_file
|
||||
self.timeout = timeout
|
||||
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:
|
||||
raise ValueError("Gitea repository must be owner/name")
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Authenticated Gitea consistency provider using the real governed Git transport.
|
||||
|
||||
The provider proves Gitea consistency without inventing a REST API:
|
||||
|
||||
* local HEAD == source_oid,
|
||||
* local source_oid^{tree} == tree_oid,
|
||||
* origin identifies the governed repository,
|
||||
* exact checked-out branch exists remotely, and
|
||||
* git ls-remote reports exactly source_oid for that branch.
|
||||
|
||||
It then writes an append-only root-owned v2 provider receipt using only governed
|
||||
policy for provider identity, operation, store root, store id and modes.
|
||||
|
||||
The result explicitly records ``transport=git-ls-remote`` and
|
||||
``api_verified=false``. It must never be described as REST/API evidence.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
from typing import Callable, Mapping
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
RECEIPT_SCHEMA = "aios.trusted-evidence-provider-receipt.v2"
|
||||
RECEIPT_VERSION = 2
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REF_RE = re.compile(r"^refs/heads/[A-Za-z0-9._/-]+$")
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
class GiteaProviderError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GitCommandResult:
|
||||
returncode: int
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
GitRunner = Callable[[tuple[str, ...], Path], GitCommandResult]
|
||||
|
||||
|
||||
def canonical_json_bytes(payload: Mapping) -> bytes:
|
||||
return json.dumps(
|
||||
dict(payload),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def canonical_sha256(payload: Mapping) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(payload)).hexdigest()
|
||||
|
||||
|
||||
def _runner(args: tuple[str, ...], cwd: Path) -> GitCommandResult:
|
||||
proc = subprocess.run(
|
||||
list(args),
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=45,
|
||||
check=False,
|
||||
)
|
||||
return GitCommandResult(proc.returncode, proc.stdout, proc.stderr)
|
||||
|
||||
|
||||
def _git(run: GitRunner, cwd: Path, *args: str, error: str) -> str:
|
||||
result = run(("git", *args), cwd)
|
||||
if result.returncode != 0:
|
||||
raise GiteaProviderError(f"{error}: {result.stderr.strip()[:300]}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
value = str(value).strip().lower()
|
||||
if not _OID_RE.fullmatch(value):
|
||||
raise GiteaProviderError(f"{field.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _mode(value) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
if text.isdigit():
|
||||
return int(text, 8)
|
||||
raise GiteaProviderError("PROVIDER_MODE_INVALID")
|
||||
|
||||
|
||||
def _safe_ref(value: str) -> Path:
|
||||
raw = str(value).strip()
|
||||
p = Path(raw)
|
||||
if not raw or p.is_absolute() or any(part in ("", ".", "..") for part in p.parts):
|
||||
raise GiteaProviderError("PROVIDER_RECEIPT_REF_INVALID")
|
||||
return p
|
||||
|
||||
|
||||
def _safe_remote_url(url: str) -> str:
|
||||
url = str(url).strip()
|
||||
if "://" in url:
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or ""
|
||||
port = f":{parts.port}" if parts.port else ""
|
||||
return urlunsplit((parts.scheme, host + port, parts.path, "", ""))
|
||||
if ":" in url and not url.startswith(("/", "./", "../")):
|
||||
left, right = url.split(":", 1)
|
||||
return f"{left.rsplit('@', 1)[-1]}:{right}"
|
||||
return url
|
||||
|
||||
|
||||
def repository_identity_from_remote(url: str) -> str:
|
||||
safe = _safe_remote_url(url)
|
||||
if "://" in safe:
|
||||
path = urlsplit(safe).path
|
||||
elif ":" in safe and not safe.startswith("/"):
|
||||
path = safe.split(":", 1)[1]
|
||||
else:
|
||||
path = safe
|
||||
parts = [x for x in path.strip("/").split("/") if x]
|
||||
if parts and parts[0] == "git":
|
||||
parts = parts[1:]
|
||||
if len(parts) < 2:
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_UNAVAILABLE")
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
if repo.endswith(".git"):
|
||||
repo = repo[:-4]
|
||||
identity = f"{owner}/{repo}".lower()
|
||||
if not _REPO_RE.fullmatch(identity):
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_INVALID")
|
||||
return identity
|
||||
|
||||
|
||||
def load_gitea_policy(policy_path: str | Path) -> dict:
|
||||
raw = json.loads(Path(policy_path).read_text(encoding="utf-8"))
|
||||
defaults = dict(raw.get("defaults") or {})
|
||||
providers = raw.get("providers")
|
||||
if not isinstance(providers, dict) or not isinstance(providers.get("gitea"), dict):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_POLICY_MISSING")
|
||||
cfg = dict(providers["gitea"])
|
||||
return {
|
||||
"provider_identity": str(cfg["provider_identity"]),
|
||||
"store_id": str(cfg["store_id"]),
|
||||
"store_root": Path(str(cfg["store_root"])),
|
||||
"provider_operation": str(cfg["allowed_operations"][0]),
|
||||
"expected_owner_uid": int(cfg.get("expected_owner_uid", defaults.get("expected_owner_uid", 0))),
|
||||
"directory_mode": min(
|
||||
_mode(x) for x in cfg.get(
|
||||
"allowed_directory_modes",
|
||||
defaults.get("allowed_directory_modes", ["0700", "0750"]),
|
||||
)
|
||||
),
|
||||
"receipt_mode": min(
|
||||
_mode(x) for x in cfg.get(
|
||||
"allowed_receipt_modes",
|
||||
defaults.get("allowed_receipt_modes", ["0600", "0640"]),
|
||||
)
|
||||
),
|
||||
"authentication_kind": str(
|
||||
cfg.get(
|
||||
"authentication_kind",
|
||||
defaults.get("authentication_kind", "root-owned-store-v1"),
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def collect_gitea_transport_consistency(
|
||||
*,
|
||||
repo_root: str | Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
expected_repository: str = "aios/aios",
|
||||
remote: str = "origin",
|
||||
git_runner: GitRunner | None = None,
|
||||
) -> dict:
|
||||
repo_root = Path(repo_root).resolve()
|
||||
if not repo_root.is_dir():
|
||||
raise GiteaProviderError("GITEA_CHECKOUT_UNAVAILABLE")
|
||||
source_oid = _oid(source_oid, "source_oid")
|
||||
tree_oid = _oid(tree_oid, "tree_oid")
|
||||
expected_repository = str(expected_repository).strip().lower()
|
||||
if not _REPO_RE.fullmatch(expected_repository):
|
||||
raise GiteaProviderError("GITEA_EXPECTED_REPOSITORY_INVALID")
|
||||
if not re.fullmatch(r"[A-Za-z0-9._/-]+", remote) or remote.startswith(("/", "-")):
|
||||
raise GiteaProviderError("GITEA_REMOTE_NAME_INVALID")
|
||||
|
||||
run = git_runner or _runner
|
||||
|
||||
if _git(run, repo_root, "rev-parse", "--is-inside-work-tree",
|
||||
error="GITEA_LOCAL_REPOSITORY_REQUIRED").lower() != "true":
|
||||
raise GiteaProviderError("GITEA_LOCAL_REPOSITORY_REQUIRED")
|
||||
|
||||
actual_source = _oid(
|
||||
_git(run, repo_root, "rev-parse", "HEAD^{commit}",
|
||||
error="GITEA_LOCAL_SOURCE_UNAVAILABLE"),
|
||||
"actual_source_oid",
|
||||
)
|
||||
if actual_source != source_oid:
|
||||
raise GiteaProviderError("GITEA_LOCAL_SOURCE_OID_MISMATCH")
|
||||
|
||||
actual_tree = _oid(
|
||||
_git(run, repo_root, "rev-parse", f"{source_oid}^{{tree}}",
|
||||
error="GITEA_LOCAL_TREE_UNAVAILABLE"),
|
||||
"actual_tree_oid",
|
||||
)
|
||||
if actual_tree != tree_oid:
|
||||
raise GiteaProviderError("GITEA_LOCAL_TREE_OID_MISMATCH")
|
||||
|
||||
branch = _git(
|
||||
run, repo_root, "symbolic-ref", "--quiet", "HEAD",
|
||||
error="GITEA_GOVERNED_BRANCH_REQUIRED",
|
||||
)
|
||||
if not _REF_RE.fullmatch(branch):
|
||||
raise GiteaProviderError("GITEA_GOVERNED_BRANCH_INVALID")
|
||||
|
||||
remote_url = _git(
|
||||
run, repo_root, "remote", "get-url", remote,
|
||||
error="GITEA_REMOTE_UNAVAILABLE",
|
||||
)
|
||||
identity = repository_identity_from_remote(remote_url)
|
||||
if identity != expected_repository:
|
||||
raise GiteaProviderError("GITEA_REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
output = _git(
|
||||
run, repo_root, "ls-remote", "--exit-code", remote, branch,
|
||||
error="GITEA_REMOTE_REF_UNAVAILABLE",
|
||||
)
|
||||
rows = []
|
||||
for line in output.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[1] == branch:
|
||||
rows.append((parts[0].lower(), parts[1]))
|
||||
if len(rows) != 1:
|
||||
raise GiteaProviderError("GITEA_REMOTE_REF_AMBIGUOUS")
|
||||
remote_oid = _oid(rows[0][0], "remote_ref_oid")
|
||||
if remote_oid != source_oid:
|
||||
raise GiteaProviderError("GITEA_REMOTE_REF_OID_MISMATCH")
|
||||
|
||||
return {
|
||||
"consistent": True,
|
||||
"repository": identity,
|
||||
"source_commit": source_oid,
|
||||
"remote_ref_oid": remote_oid,
|
||||
"tree_oid": tree_oid,
|
||||
"remote_ref": branch,
|
||||
"remote_url": _safe_remote_url(remote_url),
|
||||
"transport": "git-ls-remote",
|
||||
"api_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_root_directory(path: Path, mode: int, expected_uid: int) -> None:
|
||||
if os.geteuid() != expected_uid:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_ROOT_REQUIRED")
|
||||
path.mkdir(parents=True, exist_ok=True, mode=mode)
|
||||
st = os.lstat(path)
|
||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_INVALID")
|
||||
if st.st_uid != expected_uid:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_STORE_MODE_INVALID")
|
||||
|
||||
|
||||
def _write_receipt_exclusive(path: Path, payload: Mapping, mode: int) -> str:
|
||||
data = canonical_json_bytes(payload)
|
||||
if path.exists() or path.is_symlink():
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_EXISTS")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
flags |= getattr(os, "O_BINARY", 0)
|
||||
fd = os.open(path, flags, mode)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written <= 0:
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_WRITE_FAILED")
|
||||
view = view[written:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def produce_gitea_consistency_receipt(
|
||||
*,
|
||||
repo_root: str | Path,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
policy_path: str | Path,
|
||||
receipt_ref: str,
|
||||
expected_repository: str = "aios/aios",
|
||||
) -> tuple[str, dict]:
|
||||
cfg = load_gitea_policy(policy_path)
|
||||
if cfg["authentication_kind"] != "root-owned-store-v1":
|
||||
raise GiteaProviderError("GITEA_PROVIDER_AUTH_KIND_UNSUPPORTED")
|
||||
result = collect_gitea_transport_consistency(
|
||||
repo_root=repo_root,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
expected_repository=expected_repository,
|
||||
)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
run_id = f"gitea-git-transport-{source_oid[:12]}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
|
||||
rel = _safe_ref(receipt_ref)
|
||||
target = cfg["store_root"].joinpath(rel)
|
||||
root = cfg["store_root"].resolve()
|
||||
if not str(target.resolve(strict=False)).startswith(str(root) + os.sep):
|
||||
raise GiteaProviderError("GITEA_PROVIDER_RECEIPT_REF_ESCAPE")
|
||||
_ensure_root_directory(root, cfg["directory_mode"], cfg["expected_owner_uid"])
|
||||
parent = root
|
||||
for part in rel.parts[:-1]:
|
||||
parent = parent / part
|
||||
_ensure_root_directory(parent, cfg["directory_mode"], cfg["expected_owner_uid"])
|
||||
|
||||
payload = {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"version": RECEIPT_VERSION,
|
||||
"provider_identity": cfg["provider_identity"],
|
||||
"provider_operation": cfg["provider_operation"],
|
||||
"operation_run_id": run_id,
|
||||
"result_reference": f"git:{result['remote_url']}#{result['remote_ref']}",
|
||||
"content_sha256": canonical_sha256(result),
|
||||
"source_oid": _oid(source_oid, "source_oid"),
|
||||
"tree_oid": _oid(tree_oid, "tree_oid"),
|
||||
"environment": "hw-isolated-linux-git-transport",
|
||||
"issued_at": now,
|
||||
"result": result,
|
||||
"authentication": {
|
||||
"kind": "root-owned-store-v1",
|
||||
"store_id": cfg["store_id"],
|
||||
},
|
||||
}
|
||||
sha = _write_receipt_exclusive(target, payload, cfg["receipt_mode"])
|
||||
return sha, payload
|
||||
@@ -0,0 +1 @@
|
||||
"""Permanent AIOS governance source components."""
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import pathlib
|
||||
import socket
|
||||
import time
|
||||
|
||||
SOCKET = '/run/aios-governance-executor/executor.sock'
|
||||
STATE = pathlib.Path('/var/lib/aios-gitops/governance-executor/drift-monitor.json')
|
||||
APPROVED = pathlib.Path('/var/lib/aios-gitops/runtime/tasks/AIOS-PROTECTION-SNAPSHOT-DRIFT-FORENSICS-V1/protection-drift/semantic-analysis.json')
|
||||
|
||||
|
||||
def call():
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(20)
|
||||
sock.connect(SOCKET)
|
||||
request = {'operation': 'GET_BRANCH_PROTECTION', 'payload': {'repository': 'aios/aios', 'branch': 'main'}}
|
||||
sock.sendall((json.dumps(request, separators=(',', ':')) + '\n').encode())
|
||||
chunks = []
|
||||
while True:
|
||||
part = sock.recv(1024 * 1024)
|
||||
if not part:
|
||||
break
|
||||
chunks.append(part)
|
||||
if b'\n' in part:
|
||||
break
|
||||
sock.close()
|
||||
return json.loads(b''.join(chunks).splitlines()[0])
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
result = call()
|
||||
if result.get('status') != 'PASS':
|
||||
raise RuntimeError(str(result.get('code') or 'EXECUTOR_GET_FAILED'))
|
||||
approved = json.loads(APPROVED.read_text(encoding='utf-8'))
|
||||
approved_transport = approved['approved_transport_digest_from_evidence']
|
||||
approved_semantic = approved['approved_semantic_digest']
|
||||
transport = result['transport_digest']
|
||||
semantic = result['semantic_digest']
|
||||
if semantic != approved_semantic:
|
||||
state = 'GOVERNANCE_POLICY_DRIFT'
|
||||
elif transport != approved_transport:
|
||||
state = 'GOVERNANCE_TRANSPORT_METADATA_CHANGED'
|
||||
else:
|
||||
state = 'NO_DRIFT'
|
||||
payload = {
|
||||
'schema': 'aios.governance-drift-monitor.v1',
|
||||
'observed_at_unix': int(time.time()),
|
||||
'status': state,
|
||||
'release_blocking': False,
|
||||
'approved_transport_digest': approved_transport,
|
||||
'observed_transport_digest': transport,
|
||||
'approved_semantic_digest': approved_semantic,
|
||||
'observed_semantic_digest': semantic,
|
||||
'semantic_schema': result.get('semantic_schema'),
|
||||
'source': 'Dedicated Governance Executor GET_BRANCH_PROTECTION',
|
||||
}
|
||||
except Exception as exc:
|
||||
payload = {
|
||||
'schema': 'aios.governance-drift-monitor.v1',
|
||||
'observed_at_unix': int(time.time()),
|
||||
'status': 'MONITOR_UNAVAILABLE',
|
||||
'release_blocking': False,
|
||||
'error_code': str(exc),
|
||||
}
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = STATE.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
temporary.replace(STATE)
|
||||
STATE.chmod(0o640)
|
||||
print(json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+417
@@ -0,0 +1,417 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import socket
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
SOURCE_GOVERNANCE = "/opt/aios/aios-control-plane/current/active-runtime/app/governance"
|
||||
if SOURCE_GOVERNANCE not in __import__("sys").path:
|
||||
__import__("sys").path.insert(0, SOURCE_GOVERNANCE)
|
||||
from stage2_gate_migration import (
|
||||
CAMPAIGN_GATE,
|
||||
CAMPAIGN_ID,
|
||||
OWNER_GATE,
|
||||
STAGE1_SEMANTIC_SHA,
|
||||
Stage2GateError,
|
||||
build_target,
|
||||
semantic_digest as stage2_semantic_digest,
|
||||
validate_stage2_facts,
|
||||
)
|
||||
|
||||
API = "http://127.0.0.1:3000"
|
||||
REPO = "aios/aios"
|
||||
SOCKET = "/run/aios-governance-executor/executor.sock"
|
||||
JOURNAL = pathlib.Path("/var/lib/aios-gitops/governance-executor/executor-journal.jsonl")
|
||||
RECONCILIATION_BINDING_PATH = pathlib.Path("/var/lib/aios-gitops/governance-executor/reconciliation-binding.json")
|
||||
PR54_HEAD = "015185d7e5191d18c5c1596160807ac228782a0a"
|
||||
PR54_REVIEW = 104
|
||||
PR54_MANIFEST_SHA = "1b11ee42d53a9f6c1646e5f5ddd8ce5b7ecf301483b9f3d47a068290d541ba4b"
|
||||
PR54_PREVIOUS_PROTECTION_SHA = "8b3370add21cd23c2b4f41d8d84d3eaaa09ddf0451549b72c7941bbd92d5d60a"
|
||||
SEMANTIC_SCHEMA = "aios.semantic-protection.v1"
|
||||
APPROVED_SEMANTIC_SHA = "ba15d512ed8c1f43e5f72c84125a3a71a7347ef59e18000bbfca24d6e69f1b19"
|
||||
STAGE1_SEMANTIC_SHA = "d911e856f71de6105b8efa08e13338a448e0b6d37536332decd80c9f409ecf27"
|
||||
USER_IDS = {"owner": 2, "aios-autonomy-agent": 153}
|
||||
|
||||
|
||||
def canon(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
|
||||
|
||||
def sha(value):
|
||||
return hashlib.sha256(canon(value)).hexdigest()
|
||||
|
||||
|
||||
def token():
|
||||
value = (pathlib.Path(os.environ["CREDENTIALS_DIRECTORY"]) / "gitea-governance-executor-token").read_text().strip()
|
||||
if len(value) < 16:
|
||||
raise RuntimeError("EXECUTOR_CREDENTIAL_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def api(method, path, value, body=None, expected=(200,)):
|
||||
raw = None if body is None else json.dumps(body, separators=(",", ":")).encode()
|
||||
req = urllib.request.Request(API + path, data=raw, method=method)
|
||||
req.add_header("Authorization", "token " + value)
|
||||
req.add_header("Accept", "application/json")
|
||||
if raw is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as response:
|
||||
status = response.status
|
||||
payload = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in expected:
|
||||
payload = exc.read()
|
||||
return exc.code, json.loads(payload) if payload else None
|
||||
raise RuntimeError("GITEA_HTTP_" + str(exc.code))
|
||||
if status not in expected:
|
||||
raise RuntimeError("GITEA_HTTP_" + str(status))
|
||||
if not payload or not payload.strip():
|
||||
raise RuntimeError("GITEA_EMPTY_JSON:" + path)
|
||||
try:
|
||||
decoded = json.loads(payload)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("GITEA_INVALID_JSON:" + path) from exc
|
||||
return status, decoded
|
||||
|
||||
|
||||
def journal(event, **fields):
|
||||
JOURNAL.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = {"schema": "aios.governance-executor-journal.v2", "event": event, "unix": int(time.time()), **fields}
|
||||
with JOURNAL.open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def typed_json(value):
|
||||
if value in (None, "null"):
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
def user_ids(names):
|
||||
result = []
|
||||
for name in names or []:
|
||||
if name not in USER_IDS:
|
||||
raise RuntimeError("UNKNOWN_GOVERNANCE_USER:" + str(name))
|
||||
result.append(USER_IDS[name])
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def semantic_policy(value):
|
||||
policy = {
|
||||
"schema_version": SEMANTIC_SCHEMA,
|
||||
"repo_id": 1,
|
||||
"protection_id": 2,
|
||||
"branch_name": value.get("branch_name"),
|
||||
"can_push": value.get("enable_push"),
|
||||
"can_force_push": value.get("enable_force_push"),
|
||||
"enable_merge_whitelist": value.get("enable_merge_whitelist"),
|
||||
"merge_whitelist_user_ids": user_ids(value.get("merge_whitelist_usernames")),
|
||||
"merge_whitelist_team_ids": [],
|
||||
"required_approvals": value.get("required_approvals"),
|
||||
"enable_status_check": value.get("enable_status_check"),
|
||||
"status_check_contexts": sorted(value.get("status_check_contexts") or []),
|
||||
"block_admin_merge_override": value.get("block_admin_merge_override"),
|
||||
"block_on_official_review_requests": value.get("block_on_official_review_requests"),
|
||||
"block_on_outdated_branch": value.get("block_on_outdated_branch"),
|
||||
"block_on_rejected_reviews": value.get("block_on_rejected_reviews"),
|
||||
"dismiss_stale_approvals": value.get("dismiss_stale_approvals"),
|
||||
"ignore_stale_approvals": value.get("ignore_stale_approvals"),
|
||||
"enable_approvals_whitelist": value.get("enable_approvals_whitelist"),
|
||||
"approvals_whitelist_user_ids": user_ids(value.get("approvals_whitelist_username")),
|
||||
"approvals_whitelist_team_ids": [],
|
||||
"enable_bypass_allowlist": value.get("enable_bypass_allowlist"),
|
||||
"bypass_allowlist_user_ids": user_ids(value.get("bypass_allowlist_usernames")),
|
||||
"bypass_allowlist_team_ids": [],
|
||||
"enable_force_push_allowlist": value.get("enable_force_push_allowlist"),
|
||||
"force_push_allowlist_user_ids": user_ids(value.get("force_push_allowlist_usernames")),
|
||||
"force_push_allowlist_team_ids": [],
|
||||
"enable_whitelist": value.get("enable_push_whitelist"),
|
||||
"whitelist_user_ids": user_ids(value.get("push_whitelist_usernames")),
|
||||
"whitelist_team_ids": [],
|
||||
"force_push_allowlist_deploy_keys": value.get("force_push_allowlist_deploy_keys"),
|
||||
"whitelist_deploy_keys": value.get("push_whitelist_deploy_keys"),
|
||||
"require_signed_commits": value.get("require_signed_commits"),
|
||||
"priority": value.get("priority"),
|
||||
"protected_file_patterns": value.get("protected_file_patterns"),
|
||||
"unprotected_file_patterns": value.get("unprotected_file_patterns"),
|
||||
}
|
||||
return policy
|
||||
|
||||
|
||||
def semantic_sha(value):
|
||||
return sha(semantic_policy(value))
|
||||
|
||||
|
||||
def verify_binding(binding, value):
|
||||
required = {
|
||||
"governance_request_PR", "exact_request_head", "Owner_review_id", "Owner_review_exact_head",
|
||||
"manifest_sha256", "expected_previous_snapshot_sha256", "approved_delta",
|
||||
"semantic_reconciliation_request_id", "semantic_reconciliation_head",
|
||||
"semantic_reconciliation_review_id", "semantic_reconciliation_review_head",
|
||||
"approved_semantic_digest",
|
||||
}
|
||||
if set(binding) != required:
|
||||
raise RuntimeError("GOVERNANCE_BINDING_SCHEMA_INVALID")
|
||||
if binding["governance_request_PR"] != 54 or binding["exact_request_head"] != PR54_HEAD:
|
||||
raise RuntimeError("GOVERNANCE_BINDING_PR54_MISMATCH")
|
||||
if binding["Owner_review_id"] != PR54_REVIEW or binding["Owner_review_exact_head"] != PR54_HEAD:
|
||||
raise RuntimeError("GOVERNANCE_BINDING_REVIEW_MISMATCH")
|
||||
if binding["manifest_sha256"] != PR54_MANIFEST_SHA:
|
||||
raise RuntimeError("GOVERNANCE_BINDING_MANIFEST_MISMATCH")
|
||||
if binding["expected_previous_snapshot_sha256"] != PR54_PREVIOUS_PROTECTION_SHA:
|
||||
raise RuntimeError("GOVERNANCE_BINDING_SNAPSHOT_MISMATCH")
|
||||
if binding["approved_delta"] != {"add_merge_whitelist_user_id": 153}:
|
||||
raise RuntimeError("GOVERNANCE_BINDING_DELTA_DENIED")
|
||||
if binding["approved_semantic_digest"] != APPROVED_SEMANTIC_SHA:
|
||||
raise RuntimeError("GOVERNANCE_BINDING_SEMANTIC_DIGEST_MISMATCH")
|
||||
if not RECONCILIATION_BINDING_PATH.is_file():
|
||||
raise RuntimeError("SEMANTIC_RECONCILIATION_BINDING_REQUIRED")
|
||||
try:
|
||||
reconciliation = json.loads(RECONCILIATION_BINDING_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeError("SEMANTIC_RECONCILIATION_BINDING_INVALID") from None
|
||||
required_reconciliation = {
|
||||
"schema", "request_id", "pr_number", "request_head", "owner_review_id",
|
||||
"owner_review_head", "approved_semantic_digest", "approved_delta",
|
||||
}
|
||||
if set(reconciliation) != required_reconciliation or reconciliation.get("schema") != "aios.semantic-reconciliation-binding.v1":
|
||||
raise RuntimeError("SEMANTIC_RECONCILIATION_BINDING_INVALID")
|
||||
if reconciliation["approved_semantic_digest"] != APPROVED_SEMANTIC_SHA or reconciliation["approved_delta"] != binding["approved_delta"]:
|
||||
raise RuntimeError("SEMANTIC_RECONCILIATION_BINDING_MISMATCH")
|
||||
if (
|
||||
reconciliation["request_id"] != binding["semantic_reconciliation_request_id"]
|
||||
or reconciliation["request_head"] != binding["semantic_reconciliation_head"]
|
||||
or reconciliation["owner_review_id"] != binding["semantic_reconciliation_review_id"]
|
||||
or reconciliation["owner_review_head"] != binding["semantic_reconciliation_review_head"]
|
||||
):
|
||||
raise RuntimeError("SEMANTIC_RECONCILIATION_BINDING_MISMATCH")
|
||||
_, reconciliation_pr = api("GET", f"/api/v1/repos/{REPO}/pulls/{reconciliation['pr_number']}", value)
|
||||
if (
|
||||
reconciliation_pr.get("state") != "open"
|
||||
or reconciliation_pr.get("merged")
|
||||
or reconciliation_pr.get("head", {}).get("sha") != reconciliation["request_head"]
|
||||
or reconciliation_pr.get("base", {}).get("ref") != "governance/approvals"
|
||||
):
|
||||
raise RuntimeError("SEMANTIC_RECONCILIATION_REQUEST_STALE")
|
||||
_, reconciliation_reviews = api("GET", f"/api/v1/repos/{REPO}/pulls/{reconciliation['pr_number']}/reviews", value)
|
||||
reconciliation_review = next((x for x in reconciliation_reviews if x.get("id") == reconciliation["owner_review_id"]), None)
|
||||
if (
|
||||
not reconciliation_review
|
||||
or reconciliation_review.get("user", {}).get("id") != 2
|
||||
or reconciliation_review.get("state") != "APPROVED"
|
||||
or reconciliation_review.get("commit_id") != reconciliation["owner_review_head"]
|
||||
or reconciliation_review.get("dismissed") is not False
|
||||
):
|
||||
raise RuntimeError("SEMANTIC_RECONCILIATION_OWNER_REVIEW_STALE")
|
||||
_, pr = api("GET", f"/api/v1/repos/{REPO}/pulls/54", value)
|
||||
if pr.get("state") != "open" or pr.get("merged") or pr.get("head", {}).get("sha") != PR54_HEAD:
|
||||
raise RuntimeError("PR54_APPROVAL_STALE")
|
||||
_, reviews = api("GET", f"/api/v1/repos/{REPO}/pulls/54/reviews", value)
|
||||
review = next((x for x in reviews if x.get("id") == PR54_REVIEW), None)
|
||||
if not review or review.get("user", {}).get("id") != 2 or review.get("state") != "APPROVED" or review.get("commit_id") != PR54_HEAD or review.get("dismissed") is not False:
|
||||
raise RuntimeError("PR54_APPROVAL_STALE")
|
||||
manifest_path = "approvals/governance-policy/AIOS-AUTONOMOUS-MERGE-GOVERNANCE-SCOPE-EXTENSION-V1.json"
|
||||
_, content = api("GET", f"/api/v1/repos/{REPO}/contents/{urllib.parse.quote(manifest_path, safe='/')}?ref={PR54_HEAD}", value)
|
||||
raw = base64.b64decode("".join(content.get("content", "").split()))
|
||||
if hashlib.sha256(raw).hexdigest() != PR54_MANIFEST_SHA:
|
||||
raise RuntimeError("PR54_MANIFEST_STALE")
|
||||
|
||||
|
||||
def get_protection(value, branch):
|
||||
if branch not in {"main", "governance/approvals"}:
|
||||
raise RuntimeError("BRANCH_DENIED")
|
||||
_, protection = api("GET", f"/api/v1/repos/{REPO}/branch_protections/{urllib.parse.quote(branch, safe='')}", value)
|
||||
return protection
|
||||
|
||||
|
||||
def mutation_receipt(before, after, binding, target_semantic):
|
||||
return {
|
||||
"before": {"transport_digest": sha(before), "semantic_digest": semantic_sha(before)},
|
||||
"approved": {
|
||||
"mutation_predicate_digest": sha({"operation": "ADD_MERGE_EXECUTOR", "subject_user_id": 153, "preserve_all_unmodified_security_fields": True}),
|
||||
"Owner_review_id": binding["Owner_review_id"],
|
||||
"semantic_policy_digest": APPROVED_SEMANTIC_SHA,
|
||||
},
|
||||
"after": {"transport_digest": sha(after), "semantic_digest": semantic_sha(after)},
|
||||
"delta": {"add_merge_whitelist_user_id": 153},
|
||||
"target_semantic_digest": sha(target_semantic),
|
||||
"unmodified_security_fields": {"verified": True},
|
||||
}
|
||||
|
||||
|
||||
def apply_stage1(binding, value):
|
||||
verify_binding(binding, value)
|
||||
before = get_protection(value, "main")
|
||||
before_semantic = semantic_policy(before)
|
||||
before_semantic_sha = sha(before_semantic)
|
||||
names = list(before.get("merge_whitelist_usernames") or [])
|
||||
name_ids = user_ids(names)
|
||||
if name_ids not in ([2], [2, 153]):
|
||||
raise RuntimeError("PROTECTION_WHITELIST_PRESTATE_INVALID")
|
||||
target_names = ["owner", "aios-autonomy-agent"]
|
||||
target_semantic = copy.deepcopy(before_semantic)
|
||||
target_semantic["merge_whitelist_user_ids"] = [2, 153]
|
||||
if name_ids == [2, 153]:
|
||||
if before_semantic_sha != STAGE1_SEMANTIC_SHA:
|
||||
journal("APPLY_BLOCKED_STAGE1_IDEMPOTENCY_SEMANTIC_CONFLICT", transport_digest=sha(before), semantic_digest=before_semantic_sha, expected_semantic_digest=STAGE1_SEMANTIC_SHA)
|
||||
raise RuntimeError("PROTECTION_STAGE1_SEMANTIC_CONFLICT")
|
||||
receipt = mutation_receipt(before, before, binding, target_semantic)
|
||||
journal("APPLY_APPROVED_PROTECTION_DELTA_IDEMPOTENT_SUCCESS", receipt=receipt)
|
||||
return {"status": "IDEMPOTENT_SUCCESS", "protection": before, "transport_digest": sha(before), "semantic_digest": before_semantic_sha, "target_semantic_digest": sha(target_semantic)}
|
||||
if before_semantic_sha != APPROVED_SEMANTIC_SHA:
|
||||
journal("APPLY_BLOCKED_SEMANTIC_CAS", transport_digest=sha(before), semantic_digest=before_semantic_sha, expected_semantic_digest=APPROVED_SEMANTIC_SHA)
|
||||
raise RuntimeError("PROTECTION_SEMANTIC_CAS_CONFLICT")
|
||||
after_api = api("PATCH", f"/api/v1/repos/{REPO}/branch_protections/main", value, {"merge_whitelist_usernames": target_names})[1]
|
||||
after = get_protection(value, "main")
|
||||
for key in before:
|
||||
if key in {"updated_at", "merge_whitelist_usernames"}:
|
||||
continue
|
||||
if after.get(key) != before.get(key):
|
||||
raise RuntimeError("PROTECTION_UNAPPROVED_FIELD_CHANGED:" + key)
|
||||
if user_ids(after.get("merge_whitelist_usernames")) != [2, 153]:
|
||||
raise RuntimeError("PROTECTION_TARGET_READBACK_FAILED")
|
||||
after_semantic = semantic_policy(after)
|
||||
if after_semantic != target_semantic:
|
||||
raise RuntimeError("PROTECTION_SEMANTIC_TARGET_READBACK_FAILED")
|
||||
receipt = mutation_receipt(before, after, binding, target_semantic)
|
||||
journal("APPLY_APPROVED_PROTECTION_DELTA_COMPLETED", receipt=receipt)
|
||||
return {"status": "APPLIED", "protection": after, "previous_transport_digest": sha(before), "previous_semantic_digest": before_semantic_sha, "transport_digest": sha(after), "semantic_digest": sha(after_semantic), "receipt": receipt}
|
||||
|
||||
|
||||
def apply_stage2(binding, facts, value):
|
||||
verify_binding(binding, value)
|
||||
validate_stage2_facts(facts)
|
||||
before = get_protection(value, "main")
|
||||
if OWNER_GATE in (before.get("status_check_contexts") or []) and stage2_semantic_digest(before) != STAGE1_SEMANTIC_SHA:
|
||||
raise RuntimeError("STAGE2_SEMANTIC_PRECONDITION_INVALID")
|
||||
try:
|
||||
status, target = build_target(before)
|
||||
except Stage2GateError as exc:
|
||||
journal("MIGRATE_APPROVED_GATE_BLOCKED", reason=str(exc), semantic_digest=stage2_semantic_digest(before))
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
target_digest = stage2_semantic_digest(target)
|
||||
if status == "IDEMPOTENT_SUCCESS":
|
||||
journal(
|
||||
"MIGRATE_APPROVED_GATE_IDEMPOTENT_SUCCESS",
|
||||
before_semantic_digest=stage2_semantic_digest(before),
|
||||
target_semantic_digest=target_digest,
|
||||
campaign_id=CAMPAIGN_ID,
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"protection": before,
|
||||
"transport_digest": sha(before),
|
||||
"semantic_digest": stage2_semantic_digest(before),
|
||||
"target_semantic_digest": target_digest,
|
||||
"gate_transition": {"removed": OWNER_GATE, "required": CAMPAIGN_GATE},
|
||||
}
|
||||
after_api = api(
|
||||
"PATCH",
|
||||
f"/api/v1/repos/{REPO}/branch_protections/main",
|
||||
value,
|
||||
{"status_check_contexts": target["status_check_contexts"]},
|
||||
)[1]
|
||||
after = get_protection(value, "main")
|
||||
for key in before:
|
||||
if key in {"updated_at", "status_check_contexts"}:
|
||||
continue
|
||||
if after.get(key) != before.get(key):
|
||||
raise RuntimeError("PROTECTION_UNAPPROVED_FIELD_CHANGED:" + key)
|
||||
if after.get("status_check_contexts") != target["status_check_contexts"]:
|
||||
raise RuntimeError("STAGE2_GATE_TARGET_READBACK_FAILED")
|
||||
if stage2_semantic_digest(after) != target_digest:
|
||||
raise RuntimeError("STAGE2_SEMANTIC_TARGET_READBACK_FAILED")
|
||||
receipt = {
|
||||
"before_transport_digest": sha(before),
|
||||
"before_semantic_digest": stage2_semantic_digest(before),
|
||||
"after_transport_digest": sha(after),
|
||||
"after_semantic_digest": stage2_semantic_digest(after),
|
||||
"target_semantic_digest": target_digest,
|
||||
"campaign_id": CAMPAIGN_ID,
|
||||
"gate_transition": {"removed": OWNER_GATE, "required": CAMPAIGN_GATE},
|
||||
"preserved_security_fields": True,
|
||||
"facts_digest": sha(facts),
|
||||
}
|
||||
journal("MIGRATE_APPROVED_GATE_COMPLETED", receipt=receipt)
|
||||
return {
|
||||
"status": "APPLIED",
|
||||
"protection": after,
|
||||
"previous_transport_digest": sha(before),
|
||||
"previous_semantic_digest": stage2_semantic_digest(before),
|
||||
"transport_digest": sha(after),
|
||||
"semantic_digest": stage2_semantic_digest(after),
|
||||
"target_semantic_digest": target_digest,
|
||||
"receipt": receipt,
|
||||
}
|
||||
|
||||
|
||||
def handle(request, value):
|
||||
if not isinstance(request, dict) or set(request) != {"operation", "payload"}:
|
||||
raise RuntimeError("REQUEST_SCHEMA_INVALID")
|
||||
operation = request["operation"]
|
||||
payload = request["payload"]
|
||||
if not isinstance(payload, dict) or payload.get("repository") != REPO:
|
||||
raise RuntimeError("REPOSITORY_DENIED")
|
||||
if operation == "GET_BRANCH_PROTECTION":
|
||||
protection = get_protection(value, payload.get("branch"))
|
||||
journal("GET_BRANCH_PROTECTION", branch=payload.get("branch"), transport_digest=sha(protection), semantic_digest=semantic_sha(protection))
|
||||
return {"status": "PASS", "protection": protection, "snapshot_sha256": sha(protection), "transport_digest": sha(protection), "semantic_digest": semantic_sha(protection), "semantic_schema": SEMANTIC_SCHEMA}
|
||||
if operation == "READBACK_BRANCH_PROTECTION":
|
||||
protection = get_protection(value, payload.get("branch"))
|
||||
return {"status": "PASS", "protection": protection, "snapshot_sha256": sha(protection), "transport_digest": sha(protection), "semantic_digest": semantic_sha(protection), "semantic_schema": SEMANTIC_SCHEMA}
|
||||
if operation == "APPLY_APPROVED_PROTECTION_DELTA":
|
||||
binding = payload.get("GovernanceExecutionBinding")
|
||||
if not isinstance(binding, dict):
|
||||
raise RuntimeError("GOVERNANCE_BINDING_REQUIRED")
|
||||
return apply_stage1(binding, value)
|
||||
if operation == "MIGRATE_APPROVED_GATE":
|
||||
binding = payload.get("GovernanceExecutionBinding")
|
||||
facts = payload.get("Stage2Facts")
|
||||
if not isinstance(binding, dict) or not isinstance(facts, dict):
|
||||
raise RuntimeError("STAGE2_BINDING_OR_FACTS_REQUIRED")
|
||||
return apply_stage2(binding, facts, value)
|
||||
raise RuntimeError("OPERATION_DENIED")
|
||||
|
||||
|
||||
def main():
|
||||
value = token()
|
||||
sock_path = pathlib.Path(SOCKET)
|
||||
sock_path.unlink(missing_ok=True)
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(SOCKET)
|
||||
os.chmod(SOCKET, 0o660)
|
||||
server.listen(16)
|
||||
journal("EXECUTOR_STARTED", api_surface=["GET_BRANCH_PROTECTION", "APPLY_APPROVED_PROTECTION_DELTA", "MIGRATE_APPROVED_GATE", "READBACK_BRANCH_PROTECTION"], semantic_schema=SEMANTIC_SCHEMA, approved_semantic_digest=APPROVED_SEMANTIC_SHA, stage1_semantic_digest=STAGE1_SEMANTIC_SHA, campaign_gate=CAMPAIGN_GATE)
|
||||
try:
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
with conn:
|
||||
data = conn.recv(1024 * 1024 + 1)
|
||||
if len(data) > 1024 * 1024:
|
||||
result = {"status": "FAIL", "code": "REQUEST_TOO_LARGE"}
|
||||
else:
|
||||
try:
|
||||
request = json.loads(data.decode())
|
||||
result = handle(request, value)
|
||||
except Exception as exc:
|
||||
result = {"status": "FAIL", "code": str(exc)}
|
||||
conn.sendall((json.dumps(result, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n").encode())
|
||||
finally:
|
||||
server.close()
|
||||
sock_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
LOCAL_API = 'http://127.0.0.1:3000/api/v1'
|
||||
NETWORK_API = 'https://g.cnnetmkt.top/git/api/v1'
|
||||
NETWORK_REMOTE = 'https://g.cnnetmkt.top/git/aios/aios.git'
|
||||
REPO = 'aios/aios'
|
||||
BASE_BRANCH = 'governance/approvals'
|
||||
REQUEST_BRANCH = 'governance/request/protection-semantic-reconciliation-v1-20260808'
|
||||
SOCKET_PATH = '/run/aios-governance-preapproval-publisher/publisher.sock'
|
||||
CREDENTIAL_NAME = 'gitea-machine-token'
|
||||
AUTHORITY_REPO = '/data/aios-gitops/repo.git'
|
||||
TASK_ROOT = pathlib.Path('/var/lib/aios-gitops/runtime/tasks/AIOS-PROTECTION-SNAPSHOT-DRIFT-FORENSICS-V1')
|
||||
INPUT_ROOT = TASK_ROOT / 'publication-input'
|
||||
RECEIPT = TASK_ROOT / 'publication-receipt.json'
|
||||
MANIFEST_REL = 'approvals/governance-policy/AIOS-PROTECTION-SEMANTIC-SNAPSHOT-RECONCILIATION-V1.json'
|
||||
EVIDENCE_REL = 'approvals/governance-policy/AIOS-PROTECTION-SEMANTIC-SNAPSHOT-RECONCILIATION-V1.evidence.json'
|
||||
|
||||
|
||||
def digest_bytes(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def digest_json(value):
|
||||
return digest_bytes(json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode())
|
||||
|
||||
|
||||
def load_token():
|
||||
path = pathlib.Path(os.environ['CREDENTIALS_DIRECTORY']) / CREDENTIAL_NAME
|
||||
value = path.read_text(encoding='utf-8').strip()
|
||||
if len(value) < 16:
|
||||
raise RuntimeError('MACHINE_CREDENTIAL_INVALID')
|
||||
return value
|
||||
|
||||
|
||||
def api(base, method, path, token, body=None, expected=(200,)):
|
||||
raw = None if body is None else json.dumps(body, separators=(',', ':'), ensure_ascii=False).encode()
|
||||
request = urllib.request.Request(base + path, data=raw, method=method)
|
||||
request.add_header('Authorization', 'token ' + token)
|
||||
request.add_header('Accept', 'application/json')
|
||||
if raw is not None:
|
||||
request.add_header('Content-Type', 'application/json')
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
payload = response.read()
|
||||
if response.status not in expected:
|
||||
raise RuntimeError('GITEA_HTTP_' + str(response.status))
|
||||
return response.status, json.loads(payload) if payload else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
payload = exc.read()
|
||||
if exc.code in expected:
|
||||
return exc.code, json.loads(payload) if payload else None
|
||||
raise RuntimeError('GITEA_HTTP_' + str(exc.code) + ':' + method + ':' + path) from None
|
||||
|
||||
|
||||
def authority_head():
|
||||
result = subprocess.run(
|
||||
['git', '--git-dir=' + AUTHORITY_REPO, 'rev-parse', 'refs/heads/' + BASE_BRANCH],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError('AUTHORITY_BASE_UNAVAILABLE')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def branch_api(base, token, branch, expected=(200,)):
|
||||
return api(base, 'GET', '/repos/' + REPO + '/branches/' + urllib.parse.quote(branch, safe=''), token, expected=expected)
|
||||
|
||||
|
||||
def verify_base(token):
|
||||
authority = authority_head()
|
||||
_, local = branch_api(LOCAL_API, token, BASE_BRANCH)
|
||||
_, network = branch_api(NETWORK_API, token, BASE_BRANCH)
|
||||
local_oid = local.get('commit', {}).get('id')
|
||||
network_oid = network.get('commit', {}).get('id')
|
||||
if not authority or authority != local_oid or authority != network_oid:
|
||||
raise RuntimeError('RECONCILIATION_REQUIRED')
|
||||
return authority
|
||||
|
||||
|
||||
def verify_identity(token):
|
||||
_, user = api(LOCAL_API, 'GET', '/user', token)
|
||||
if user.get('login') != 'aios-autonomy-agent' or user.get('id') != 153 or user.get('is_admin') is not False:
|
||||
raise RuntimeError('MACHINE_IDENTITY_REJECTED')
|
||||
return {'login': user['login'], 'id': user['id'], 'owner': user.get('is_admin')}
|
||||
|
||||
|
||||
def load_inputs():
|
||||
manifest = json.dumps(json.loads((INPUT_ROOT / 'manifest.json').read_text(encoding='utf-8')), sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode()
|
||||
evidence = json.dumps(json.loads((INPUT_ROOT / 'evidence.json').read_text(encoding='utf-8')), sort_keys=True, separators=(',', ':'), ensure_ascii=False).encode()
|
||||
for data in (manifest, evidence):
|
||||
if any(secret in data.lower() for secret in (b'authorization:', b'ghp_', b'github_pat_', b'secret=', b'token=')):
|
||||
raise RuntimeError('SECRET_CONTENT_REJECTED')
|
||||
return manifest, evidence
|
||||
|
||||
|
||||
def publication_key(base_oid, manifest_sha, evidence_sha):
|
||||
payload = REPO + base_oid + REQUEST_BRANCH + manifest_sha + evidence_sha
|
||||
return hashlib.sha256(payload.encode()).hexdigest()
|
||||
|
||||
|
||||
def write_receipt(state, **fields):
|
||||
record = {
|
||||
'schema': 'aios.governance-preapproval-publication.v1',
|
||||
'operation': 'SEMANTIC_RECONCILIATION_REQUEST',
|
||||
'state': state,
|
||||
'recorded_unix': int(time.time()),
|
||||
'request_branch': REQUEST_BRANCH,
|
||||
'manifest_path': MANIFEST_REL,
|
||||
'evidence_path': EVIDENCE_REL,
|
||||
'machine_actor': 'aios-autonomy-agent',
|
||||
'owner': False,
|
||||
'credential_ref': {'scheme': 'systemd-credential', 'name': CREDENTIAL_NAME},
|
||||
'governance_capability_executed': False,
|
||||
'protection_mutation': False,
|
||||
'force_push': False,
|
||||
**fields,
|
||||
}
|
||||
RECEIPT.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = RECEIPT.with_suffix('.tmp')
|
||||
temporary.write_text(json.dumps(record, sort_keys=True, separators=(',', ':'), ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
temporary.replace(RECEIPT)
|
||||
RECEIPT.chmod(0o640)
|
||||
return record
|
||||
|
||||
|
||||
def existing_prs(token):
|
||||
_, prs = api(LOCAL_API, 'GET', '/repos/' + REPO + '/pulls?state=all&base=' + urllib.parse.quote(BASE_BRANCH, safe='') + '&limit=100', token)
|
||||
return [pr for pr in prs if pr.get('head', {}).get('ref') == REQUEST_BRANCH and pr.get('base', {}).get('ref') == BASE_BRANCH]
|
||||
|
||||
|
||||
def verify_existing_branch(token, branch_oid, manifest, evidence):
|
||||
_, commit = api(LOCAL_API, 'GET', '/repos/' + REPO + '/git/commits/' + branch_oid, token)
|
||||
if commit.get('parents', [{}])[0].get('sha') is None:
|
||||
raise RuntimeError('REQUEST_BRANCH_COMMIT_INVALID')
|
||||
for relative, expected in ((MANIFEST_REL, manifest), (EVIDENCE_REL, evidence)):
|
||||
_, content = api(LOCAL_API, 'GET', '/repos/' + REPO + '/contents/' + urllib.parse.quote(relative, safe='/') + '?ref=' + branch_oid, token)
|
||||
actual = base64.b64decode(''.join(content.get('content', '').split()))
|
||||
if actual != expected:
|
||||
raise RuntimeError('GOVERNANCE_REQUEST_BRANCH_CONFLICT')
|
||||
|
||||
|
||||
def push_one_commit(token, base_oid, manifest, evidence):
|
||||
work_root = TASK_ROOT / 'publisher-work'
|
||||
work_root.mkdir(parents=True, exist_ok=True)
|
||||
temporary = pathlib.Path(tempfile.mkdtemp(prefix='semantic-reconciliation-', dir=work_root))
|
||||
askpass = temporary / 'askpass.sh'
|
||||
clone = temporary / 'repo'
|
||||
credential_path = pathlib.Path(os.environ['CREDENTIALS_DIRECTORY']) / CREDENTIAL_NAME
|
||||
askpass.write_text(
|
||||
'#!/bin/sh\ncase "$1" in *Username*) printf "%s\\n" "$GITEA_MACHINE_ACTOR";; *) cat "$CREDENTIAL_FILE";; esac\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
askpass.chmod(0o700)
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
'GIT_ASKPASS': str(askpass),
|
||||
'GIT_TERMINAL_PROMPT': '0',
|
||||
'GITEA_MACHINE_ACTOR': 'aios-autonomy-agent',
|
||||
'CREDENTIAL_FILE': str(credential_path),
|
||||
'GIT_CONFIG_COUNT': '1',
|
||||
'GIT_CONFIG_KEY_0': 'safe.directory',
|
||||
'GIT_CONFIG_VALUE_0': AUTHORITY_REPO,
|
||||
})
|
||||
try:
|
||||
run = subprocess.run(
|
||||
['git', 'clone', '--branch', BASE_BRANCH, '--single-branch', '--quiet', NETWORK_REMOTE, str(clone)],
|
||||
text=True, capture_output=True, env=env, check=False,
|
||||
)
|
||||
if run.returncode != 0:
|
||||
detail = ' '.join(run.stderr.strip().split())[:240]
|
||||
raise RuntimeError('PUBLISHER_CLONE_FAILED:' + detail)
|
||||
def git(*args, push_env=None):
|
||||
result = subprocess.run(['git', *args], cwd=clone, text=True, capture_output=True, env=push_env or env, check=False)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError('PUBLISHER_GIT_OPERATION_FAILED')
|
||||
return result.stdout.strip()
|
||||
git('checkout', '--detach', base_oid)
|
||||
git('switch', '--create', REQUEST_BRANCH)
|
||||
manifest_path = clone / MANIFEST_REL
|
||||
evidence_path = clone / EVIDENCE_REL
|
||||
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
evidence_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path.write_bytes(manifest)
|
||||
evidence_path.write_bytes(evidence)
|
||||
git('add', '--', MANIFEST_REL, EVIDENCE_REL)
|
||||
staged = git('diff', '--cached', '--name-only').splitlines()
|
||||
if staged != sorted([MANIFEST_REL, EVIDENCE_REL]):
|
||||
raise RuntimeError('PUBLISHER_PATH_ALLOWLIST_FAILED')
|
||||
git('-c', 'user.name=aios-autonomy-agent', '-c', 'user.email=aios-autonomy-agent@aios.invalid', 'commit', '--quiet', '-m', '[governance] publish semantic reconciliation evidence')
|
||||
if git('rev-list', '--count', base_oid + '..HEAD') != '1':
|
||||
raise RuntimeError('PUBLISHER_COMMIT_COUNT_FAILED')
|
||||
git('push', '--porcelain', NETWORK_REMOTE, 'HEAD:refs/heads/' + REQUEST_BRANCH, push_env=env)
|
||||
head = git('rev-parse', 'HEAD')
|
||||
return head
|
||||
finally:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
|
||||
|
||||
def publish():
|
||||
token = load_token()
|
||||
identity = verify_identity(token)
|
||||
base_oid = verify_base(token)
|
||||
manifest, evidence = load_inputs()
|
||||
manifest_sha = digest_bytes(manifest)
|
||||
evidence_sha = digest_bytes(evidence)
|
||||
key = publication_key(base_oid, manifest_sha, evidence_sha)
|
||||
write_receipt('REQUESTED', governance_base_oid=base_oid, manifest_sha256=manifest_sha, evidence_sha256=evidence_sha, idempotency_key=key, machine_identity=identity)
|
||||
write_receipt('LOCAL_CANDIDATE_VERIFIED', governance_base_oid=base_oid, manifest_sha256=manifest_sha, evidence_sha256=evidence_sha, idempotency_key=key, machine_identity=identity)
|
||||
|
||||
branch_status, branch = branch_api(LOCAL_API, token, REQUEST_BRANCH, expected=(200, 404))
|
||||
if branch_status == 200:
|
||||
branch_oid = branch.get('commit', {}).get('id')
|
||||
verify_existing_branch(token, branch_oid, manifest, evidence)
|
||||
else:
|
||||
branch_oid = push_one_commit(token, base_oid, manifest, evidence)
|
||||
verify_existing_branch(token, branch_oid, manifest, evidence)
|
||||
write_receipt('BRANCH_PUSHED', governance_base_oid=base_oid, manifest_sha256=manifest_sha, evidence_sha256=evidence_sha, idempotency_key=key, request_head_oid=branch_oid, machine_identity=identity)
|
||||
|
||||
prs = existing_prs(token)
|
||||
if len(prs) > 1:
|
||||
raise RuntimeError('MULTIPLE_MATCHING_APPROVAL_PRS')
|
||||
if prs:
|
||||
pr = prs[0]
|
||||
if pr.get('head', {}).get('sha') != branch_oid:
|
||||
raise RuntimeError('APPROVAL_PR_HEAD_MISMATCH')
|
||||
pr_number = pr.get('number')
|
||||
else:
|
||||
_, pr = api(LOCAL_API, 'POST', '/repos/' + REPO + '/pulls', token, {
|
||||
'title': '[GOVERNANCE RECONCILIATION] Semantic protection snapshot schema migration',
|
||||
'head': REQUEST_BRANCH,
|
||||
'base': BASE_BRANCH,
|
||||
'body': 'Schema-only reconciliation for the existing PR54 Stage-1 and Stage-2 predicates. No new governance capability; Owner exact-head re-attestation required.',
|
||||
}, expected=(201,))
|
||||
pr_number = pr.get('number')
|
||||
write_receipt('PR_CREATED', governance_base_oid=base_oid, manifest_sha256=manifest_sha, evidence_sha256=evidence_sha, idempotency_key=key, request_head_oid=branch_oid, pr_number=pr_number, machine_identity=identity)
|
||||
|
||||
_, remote_branch = branch_api(NETWORK_API, token, REQUEST_BRANCH)
|
||||
if remote_branch.get('commit', {}).get('id') != branch_oid:
|
||||
raise RuntimeError('NETWORK_BRANCH_READBACK_MISMATCH')
|
||||
_, read_pr = api(LOCAL_API, 'GET', '/repos/' + REPO + '/pulls/' + str(pr_number), token)
|
||||
if read_pr.get('head', {}).get('sha') != branch_oid or read_pr.get('base', {}).get('ref') != BASE_BRANCH:
|
||||
raise RuntimeError('PR_READBACK_MISMATCH')
|
||||
result = write_receipt('COMPLETED', governance_base_oid=base_oid, manifest_sha256=manifest_sha, evidence_sha256=evidence_sha, idempotency_key=key, request_head_oid=branch_oid, pr_number=pr_number, pr_head_oid=read_pr.get('head', {}).get('sha'), machine_identity=identity)
|
||||
return result
|
||||
|
||||
|
||||
def self_test():
|
||||
token = load_token()
|
||||
identity = verify_identity(token)
|
||||
base_oid = verify_base(token)
|
||||
branch_status, _ = branch_api(LOCAL_API, token, REQUEST_BRANCH, expected=(200, 404))
|
||||
return {
|
||||
'status': 'PASS',
|
||||
'governance_base_oid': base_oid,
|
||||
'machine_identity': identity,
|
||||
'credential_ref': {'scheme': 'systemd-credential', 'name': CREDENTIAL_NAME},
|
||||
'branch_namespace': 'refs/heads/governance/request/*',
|
||||
'target_base': BASE_BRANCH,
|
||||
'request_branch_status': 'EXISTS' if branch_status == 200 else 'ABSENT',
|
||||
'operation_allowlist': ['READ_BASE', 'CREATE_ONE_REQUEST_BRANCH', 'WRITE_TWO_EVIDENCE_PATHS_ONE_COMMIT', 'CREATE_OR_READ_ONE_APPROVAL_PR', 'READBACK'],
|
||||
'denied': ['MAIN_WRITE', 'DIRECT_APPROVALS_WRITE', 'BRANCH_PROTECTION', 'MERGE', 'FORCE_PUSH', 'TAG', 'PRODUCTION', 'CREDENTIAL_MUTATION'],
|
||||
'governance_capability_executed': False,
|
||||
}
|
||||
|
||||
|
||||
def read_publication_status():
|
||||
token = load_token()
|
||||
receipt = json.loads(RECEIPT.read_text(encoding='utf-8'))
|
||||
pr_number = receipt.get('pr_number')
|
||||
if not pr_number:
|
||||
raise RuntimeError('PUBLICATION_RECEIPT_PR_MISSING')
|
||||
_, pr = api(LOCAL_API, 'GET', '/repos/' + REPO + '/pulls/' + str(pr_number), token)
|
||||
_, reviews = api(LOCAL_API, 'GET', '/repos/' + REPO + '/pulls/' + str(pr_number) + '/reviews', token)
|
||||
_, branch = branch_api(LOCAL_API, token, REQUEST_BRANCH)
|
||||
_, network_branch = branch_api(NETWORK_API, token, REQUEST_BRANCH)
|
||||
return {
|
||||
'status': 'PASS',
|
||||
'request_branch': REQUEST_BRANCH,
|
||||
'branch_head': branch.get('commit', {}).get('id'),
|
||||
'network_branch_head': network_branch.get('commit', {}).get('id'),
|
||||
'pr_number': pr.get('number'),
|
||||
'pr_state': pr.get('state'),
|
||||
'pr_merged': pr.get('merged'),
|
||||
'pr_head': pr.get('head', {}).get('sha'),
|
||||
'pr_base': pr.get('base', {}).get('ref'),
|
||||
'reviews': [
|
||||
{
|
||||
'id': item.get('id'),
|
||||
'reviewer_id': item.get('user', {}).get('id'),
|
||||
'state': item.get('state'),
|
||||
'commit_id': item.get('commit_id'),
|
||||
'dismissed': item.get('dismissed'),
|
||||
}
|
||||
for item in reviews
|
||||
],
|
||||
'governance_capability_executed': False,
|
||||
'protection_mutation': False,
|
||||
}
|
||||
|
||||
|
||||
def handle(request):
|
||||
if not isinstance(request, dict) or set(request) != {'operation'}:
|
||||
raise RuntimeError('REQUEST_SCHEMA_INVALID')
|
||||
if request['operation'] == 'DRY_RUN_SELF_TEST':
|
||||
return self_test()
|
||||
if request['operation'] == 'PUBLISH_SEMANTIC_RECONCILIATION':
|
||||
return publish()
|
||||
if request['operation'] == 'READ_SEMANTIC_RECONCILIATION_STATUS':
|
||||
return read_publication_status()
|
||||
raise RuntimeError('OPERATION_DENIED')
|
||||
|
||||
|
||||
def serve(listener):
|
||||
conn, _ = listener.accept()
|
||||
with conn:
|
||||
try:
|
||||
data = conn.recv(1024 * 1024 + 1)
|
||||
if len(data) > 1024 * 1024:
|
||||
raise RuntimeError('REQUEST_TOO_LARGE')
|
||||
result = handle(json.loads(data.decode()))
|
||||
except Exception as exc:
|
||||
result = {'status': 'FAIL', 'code': str(exc)}
|
||||
conn.sendall((json.dumps(result, sort_keys=True, separators=(',', ':'), ensure_ascii=False) + '\n').encode())
|
||||
|
||||
|
||||
def main():
|
||||
if os.environ.get('LISTEN_FDS') != '1':
|
||||
raise SystemExit('SOCKET_ACTIVATION_REQUIRED')
|
||||
listener = socket.fromfd(3, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
serve(listener)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Fail-closed Authority/Gitea ref reconciliation after protected merge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
class RefReconciliationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value):
|
||||
raise RefReconciliationError(field + "_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RefReconciler:
|
||||
git_dir: Path
|
||||
|
||||
def read(self, ref: str) -> str:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "rev-parse", "--verify", ref],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RefReconciliationError("AUTHORITY_REF_UNAVAILABLE")
|
||||
return result.stdout.strip()
|
||||
|
||||
def is_ancestor(self, ancestor: str, descendant: str) -> bool:
|
||||
_oid(ancestor, "ancestor")
|
||||
_oid(descendant, "descendant")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
if result.returncode == 1:
|
||||
return False
|
||||
raise RefReconciliationError("AUTHORITY_ANCESTRY_CHECK_FAILED")
|
||||
|
||||
def reconcile_main(self, *, expected_oid: str, merged_oid: str, gitea_main_oid: str) -> str:
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(merged_oid, "merged_oid")
|
||||
_oid(gitea_main_oid, "gitea_main_oid")
|
||||
if gitea_main_oid != merged_oid:
|
||||
raise RefReconciliationError("GITEA_MAIN_EXPECTATION_MISMATCH")
|
||||
if not self.is_ancestor(expected_oid, merged_oid):
|
||||
raise RefReconciliationError("MAIN_FF_REQUIRED")
|
||||
current = self.read("refs/heads/main")
|
||||
if current == merged_oid:
|
||||
return merged_oid
|
||||
if current != expected_oid:
|
||||
raise RefReconciliationError("AUTHORITY_MAIN_EXPECTATION_MISMATCH")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "update-ref", "refs/heads/main", merged_oid, expected_oid],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RefReconciliationError("AUTHORITY_MAIN_CAS_FAILED")
|
||||
if self.read("refs/heads/main") != merged_oid:
|
||||
raise RefReconciliationError("AUTHORITY_MAIN_CAS_READBACK_MISMATCH")
|
||||
return merged_oid
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Canonical semantic branch-protection model used by AIOS governance gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
SCHEMA = "aios.semantic-protection.v1"
|
||||
USER_IDS = {"owner": 2, "aios-autonomy-agent": 153}
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _typed_json(value: Any) -> list[Any]:
|
||||
if value in (None, "null"):
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return list(value)
|
||||
|
||||
|
||||
def _user_ids(names: Any) -> list[int]:
|
||||
result = []
|
||||
for name in names or []:
|
||||
if name not in USER_IDS:
|
||||
raise ValueError("UNKNOWN_GOVERNANCE_USER:" + str(name))
|
||||
result.append(USER_IDS[name])
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def semantic_policy(protection: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Drop transport metadata and canonicalize semantic security fields."""
|
||||
return {
|
||||
"schema_version": SCHEMA,
|
||||
"repo_id": 1,
|
||||
"protection_id": 2,
|
||||
"branch_name": protection.get("branch_name"),
|
||||
"can_push": protection.get("enable_push"),
|
||||
"can_force_push": protection.get("enable_force_push"),
|
||||
"enable_merge_whitelist": protection.get("enable_merge_whitelist"),
|
||||
"merge_whitelist_user_ids": _user_ids(protection.get("merge_whitelist_usernames")),
|
||||
"merge_whitelist_team_ids": [],
|
||||
"required_approvals": protection.get("required_approvals"),
|
||||
"enable_status_check": protection.get("enable_status_check"),
|
||||
"status_check_contexts": sorted(protection.get("status_check_contexts") or []),
|
||||
"block_admin_merge_override": protection.get("block_admin_merge_override"),
|
||||
"block_on_official_review_requests": protection.get("block_on_official_review_requests"),
|
||||
"block_on_outdated_branch": protection.get("block_on_outdated_branch"),
|
||||
"block_on_rejected_reviews": protection.get("block_on_rejected_reviews"),
|
||||
"dismiss_stale_approvals": protection.get("dismiss_stale_approvals"),
|
||||
"ignore_stale_approvals": protection.get("ignore_stale_approvals"),
|
||||
"enable_approvals_whitelist": protection.get("enable_approvals_whitelist"),
|
||||
"approvals_whitelist_user_ids": _user_ids(protection.get("approvals_whitelist_username")),
|
||||
"approvals_whitelist_team_ids": [],
|
||||
"enable_bypass_allowlist": protection.get("enable_bypass_allowlist"),
|
||||
"bypass_allowlist_user_ids": _user_ids(protection.get("bypass_allowlist_usernames")),
|
||||
"bypass_allowlist_team_ids": [],
|
||||
"enable_force_push_allowlist": protection.get("enable_force_push_allowlist"),
|
||||
"force_push_allowlist_user_ids": _user_ids(protection.get("force_push_allowlist_usernames")),
|
||||
"enable_whitelist": protection.get("enable_push_whitelist"),
|
||||
"whitelist_user_ids": _user_ids(protection.get("push_whitelist_usernames")),
|
||||
"whitelist_team_ids": [],
|
||||
"force_push_allowlist_deploy_keys": protection.get("force_push_allowlist_deploy_keys"),
|
||||
"whitelist_deploy_keys": protection.get("push_whitelist_deploy_keys"),
|
||||
"require_signed_commits": protection.get("require_signed_commits"),
|
||||
"priority": protection.get("priority"),
|
||||
"protected_file_patterns": _typed_json(protection.get("protected_file_patterns")),
|
||||
"unprotected_file_patterns": _typed_json(protection.get("unprotected_file_patterns")),
|
||||
}
|
||||
|
||||
|
||||
def semantic_digest(protection: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(canonical_json(semantic_policy(protection))).hexdigest()
|
||||
|
||||
|
||||
def transport_digest(protection: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(canonical_json(protection)).hexdigest()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Fail-closed Stage-2 campaign gate migration predicates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
SEMANTIC_SCHEMA = "aios.semantic-protection.v1"
|
||||
STAGE1_SEMANTIC_SHA = "d911e856f71de6105b8efa08e13338a448e0b6d37536332decd80c9f409ecf27"
|
||||
CAMPAIGN_ID = "AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807"
|
||||
OWNER_GATE = "owner-approval-gate"
|
||||
CAMPAIGN_GATE = "campaign-approval-gate"
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class Stage2GateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _typed_json(value: Any) -> list[Any]:
|
||||
if value in (None, "", "null"):
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return list(value)
|
||||
|
||||
|
||||
def _user_ids(names: Any) -> list[int]:
|
||||
mapping = {"owner": 2, "aios-autonomy-agent": 153}
|
||||
result = []
|
||||
for name in names or []:
|
||||
if name not in mapping:
|
||||
raise Stage2GateError("UNKNOWN_GOVERNANCE_USER")
|
||||
result.append(mapping[name])
|
||||
return sorted(result)
|
||||
|
||||
|
||||
def semantic_policy(protection: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": SEMANTIC_SCHEMA,
|
||||
"repo_id": 1,
|
||||
"protection_id": 2,
|
||||
"branch_name": protection.get("branch_name"),
|
||||
"can_push": protection.get("enable_push"),
|
||||
"can_force_push": protection.get("enable_force_push"),
|
||||
"enable_merge_whitelist": protection.get("enable_merge_whitelist"),
|
||||
"merge_whitelist_user_ids": _user_ids(protection.get("merge_whitelist_usernames")),
|
||||
"merge_whitelist_team_ids": [],
|
||||
"required_approvals": protection.get("required_approvals"),
|
||||
"enable_status_check": protection.get("enable_status_check"),
|
||||
"status_check_contexts": sorted(protection.get("status_check_contexts") or []),
|
||||
"block_admin_merge_override": protection.get("block_admin_merge_override"),
|
||||
"block_on_official_review_requests": protection.get("block_on_official_review_requests"),
|
||||
"block_on_outdated_branch": protection.get("block_on_outdated_branch"),
|
||||
"block_on_rejected_reviews": protection.get("block_on_rejected_reviews"),
|
||||
"dismiss_stale_approvals": protection.get("dismiss_stale_approvals"),
|
||||
"ignore_stale_approvals": protection.get("ignore_stale_approvals"),
|
||||
"enable_approvals_whitelist": protection.get("enable_approvals_whitelist"),
|
||||
"approvals_whitelist_user_ids": _user_ids(protection.get("approvals_whitelist_username")),
|
||||
"approvals_whitelist_team_ids": [],
|
||||
"enable_bypass_allowlist": protection.get("enable_bypass_allowlist"),
|
||||
"bypass_allowlist_user_ids": _user_ids(protection.get("bypass_allowlist_usernames")),
|
||||
"bypass_allowlist_team_ids": [],
|
||||
"enable_force_push_allowlist": protection.get("enable_force_push_allowlist"),
|
||||
"force_push_allowlist_user_ids": _user_ids(protection.get("force_push_allowlist_usernames")),
|
||||
"force_push_allowlist_team_ids": [],
|
||||
"enable_whitelist": protection.get("enable_push_whitelist"),
|
||||
"whitelist_user_ids": _user_ids(protection.get("push_whitelist_usernames")),
|
||||
"whitelist_team_ids": [],
|
||||
"force_push_allowlist_deploy_keys": protection.get("force_push_allowlist_deploy_keys"),
|
||||
"whitelist_deploy_keys": protection.get("push_whitelist_deploy_keys"),
|
||||
"require_signed_commits": protection.get("require_signed_commits"),
|
||||
"priority": protection.get("priority"),
|
||||
"protected_file_patterns": protection.get("protected_file_patterns"),
|
||||
"unprotected_file_patterns": protection.get("unprotected_file_patterns"),
|
||||
}
|
||||
|
||||
|
||||
def semantic_digest(protection: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(canonical_json(semantic_policy(protection))).hexdigest()
|
||||
|
||||
|
||||
def build_target(protection: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
current = list(protection.get("status_check_contexts") or [])
|
||||
has_owner = OWNER_GATE in current
|
||||
has_campaign = CAMPAIGN_GATE in current
|
||||
if has_owner and has_campaign:
|
||||
raise Stage2GateError("GATE_PRESTATE_CONFLICT")
|
||||
if not has_owner and not has_campaign:
|
||||
raise Stage2GateError("OWNER_GATE_MISSING")
|
||||
if has_campaign:
|
||||
return "IDEMPOTENT_SUCCESS", copy.deepcopy(dict(protection))
|
||||
if semantic_digest(protection) != STAGE1_SEMANTIC_SHA:
|
||||
raise Stage2GateError("SEMANTIC_PRECONDITION_INVALID")
|
||||
target = copy.deepcopy(dict(protection))
|
||||
target["status_check_contexts"] = sorted(
|
||||
[gate for gate in current if gate != OWNER_GATE] + [CAMPAIGN_GATE]
|
||||
)
|
||||
return "READY", target
|
||||
|
||||
|
||||
def validate_stage2_facts(facts: Mapping[str, Any]) -> None:
|
||||
required = {
|
||||
"campaign_id",
|
||||
"campaign_active",
|
||||
"campaign_revoked",
|
||||
"machine_actor",
|
||||
"required_checks_all_success",
|
||||
"required_checks_same_head",
|
||||
"unresolved_conversations",
|
||||
"mergeable",
|
||||
"protected_target",
|
||||
"current_main_oid",
|
||||
"gitea_main_oid",
|
||||
"current_main_lineage_verified",
|
||||
}
|
||||
if set(facts) != required:
|
||||
raise Stage2GateError("STAGE2_FACTS_SCHEMA_INVALID")
|
||||
if facts["campaign_id"] != CAMPAIGN_ID or facts["campaign_active"] is not True or facts["campaign_revoked"] is not False:
|
||||
raise Stage2GateError("CAMPAIGN_NOT_ACTIVE")
|
||||
if facts["machine_actor"] != "aios-autonomy-agent":
|
||||
raise Stage2GateError("MACHINE_ACTOR_MISMATCH")
|
||||
if facts["required_checks_all_success"] is not True or facts["required_checks_same_head"] is not True:
|
||||
raise Stage2GateError("REQUIRED_CHECKS_NOT_CURRENT")
|
||||
if facts["unresolved_conversations"] != 0 or facts["mergeable"] is not True:
|
||||
raise Stage2GateError("CAMPAIGN_GATE_FACTS_INVALID")
|
||||
if facts["protected_target"] != "main" or facts["current_main_lineage_verified"] is not True:
|
||||
raise Stage2GateError("PROTECTED_TARGET_OR_LINEAGE_INVALID")
|
||||
if (
|
||||
not isinstance(facts["current_main_oid"], str)
|
||||
or not OID_RE.fullmatch(facts["current_main_oid"])
|
||||
or facts["gitea_main_oid"] != facts["current_main_oid"]
|
||||
):
|
||||
raise Stage2GateError("MAIN_READBACK_MISMATCH")
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from governed_provider_auth_adapter import (
|
||||
ALL_RELEASE_EVIDENCE_KINDS,
|
||||
authenticate_build_artifacts,
|
||||
gate_w_authentication_status,
|
||||
)
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
|
||||
def produce_authenticated_evidence_closure(
|
||||
request: Any,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
source_root: Path,
|
||||
builder_policy: Path,
|
||||
legacy_producer=None,
|
||||
):
|
||||
"""Production wrapper for the existing evidence producer.
|
||||
|
||||
This is the governed operation entrypoint. It refuses to call the legacy
|
||||
closure assembler until *all* final-release provider receipts are resolved
|
||||
from authenticated stores. For Gate-W, use `validate_gate_w_provider_auth`
|
||||
instead; canary/rollback remain pending until Gate-R.
|
||||
"""
|
||||
if provider_receipt_resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
if legacy_producer is None:
|
||||
from governed_release_evidence_producer import produce_evidence_closure as legacy_producer
|
||||
|
||||
authenticated_request = authenticate_build_artifacts(
|
||||
request,
|
||||
resolver=provider_receipt_resolver,
|
||||
required_kinds=ALL_RELEASE_EVIDENCE_KINDS,
|
||||
)
|
||||
return legacy_producer(
|
||||
authenticated_request,
|
||||
source_root=source_root,
|
||||
builder_policy=builder_policy,
|
||||
)
|
||||
|
||||
|
||||
def validate_gate_w_provider_auth(
|
||||
request: Any,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Gate-W authentication check without pretending Gate-R evidence exists."""
|
||||
return gate_w_authentication_status(
|
||||
request,
|
||||
resolver=provider_receipt_resolver,
|
||||
)
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed evidence runtime registration chain (V2R2C2 P0).
|
||||
|
||||
Establishes the REAL runtime chain for the governed release evidence producer:
|
||||
|
||||
Inventory -> Resolver -> operation -> Producer -> closure
|
||||
|
||||
The Producer alone (governed_release_evidence_producer) is not "runtime
|
||||
registered" just because its OperationDefinition exists — registration only
|
||||
holds when a runtime can actually discover the operation in an inventory,
|
||||
resolve its ``handler_ref`` to the real callable, and invoke it to produce a
|
||||
closure. That full path is what this module wires and what the
|
||||
``PRODUCER_RUNTIME_REGISTERED_PASS`` verdict proves.
|
||||
|
||||
- Inventory : EvidenceOperationInventory — the set of governed evidence
|
||||
operations the authority can execute (unions the producer
|
||||
registry; extensible to future governed operations).
|
||||
- Resolver : resolve_handler(handler_ref) maps the operation's declared
|
||||
handler reference to the actual ``produce_evidence_closure``
|
||||
callable. Resolution is exact and restrictive — it never
|
||||
imports by arbitrary string eval.
|
||||
- Producer : the resolved callable executes with validated, receipt-bound
|
||||
build artifacts and materializes the evidence closure.
|
||||
- closure : the produced source_root/<version>/ closure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
|
||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
||||
from governed_authenticated_evidence_operation import (
|
||||
produce_authenticated_evidence_closure,
|
||||
)
|
||||
from governed_provider_auth_adapter import gate_w_authentication_status
|
||||
from governed_release_evidence_producer import (
|
||||
EvidenceProduceError,
|
||||
GovernedBuildArtifacts,
|
||||
ProduceResult,
|
||||
TRUSTED_PROVIDER_EVIDENCE_TYPES,
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
||||
produce_evidence_closure,
|
||||
producer_operation_definition,
|
||||
producer_registry,
|
||||
)
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
TrustedProviderPolicy,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
|
||||
# Exact, restrictive handler_ref -> callable map. This is the ONLY way
|
||||
# handler_refs are resolved; no arbitrary import/eval.
|
||||
_HANDLERS: Mapping[str, Callable[..., ProduceResult]] = {
|
||||
"governed_release_evidence_producer.produce_evidence_closure": produce_evidence_closure,
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF: produce_authenticated_evidence_closure,
|
||||
}
|
||||
|
||||
|
||||
class EvidenceOperationInventory:
|
||||
"""Inventory of governed evidence operations available to the authority."""
|
||||
|
||||
def __init__(self, *registries: OperationRegistry) -> None:
|
||||
definitions: list[OperationDefinition] = []
|
||||
seen: set[str] = set()
|
||||
for reg in registries:
|
||||
for definition in reg.definitions:
|
||||
if definition.operation_id in seen:
|
||||
continue
|
||||
seen.add(definition.operation_id)
|
||||
definitions.append(definition)
|
||||
self._registry = OperationRegistry(definitions)
|
||||
self._by_id = {d.operation_id: d for d in definitions}
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "EvidenceOperationInventory":
|
||||
"""The standard inventory: the governed release evidence producer."""
|
||||
return cls(producer_registry())
|
||||
|
||||
def contains(self, operation_id: str) -> bool:
|
||||
return operation_id in self._by_id
|
||||
|
||||
def operation_ids(self) -> tuple[str, ...]:
|
||||
return tuple(self._by_id)
|
||||
|
||||
def get(self, operation_id: str) -> OperationDefinition | None:
|
||||
return self._by_id.get(operation_id)
|
||||
|
||||
|
||||
def resolve_handler(handler_ref: str) -> Callable[..., ProduceResult]:
|
||||
"""Resolver: turn an OperationDefinition.handler_ref into the real callable.
|
||||
|
||||
Exact lookup in a fixed allowlist. Unknown or non-producer references raise.
|
||||
"""
|
||||
handler = _HANDLERS.get(handler_ref)
|
||||
if handler is None:
|
||||
raise EvidenceProduceError("HANDLER_REF_UNRESOLVED")
|
||||
return handler
|
||||
|
||||
|
||||
def _payload_to_artifacts(payload: Mapping[str, Any]) -> GovernedBuildArtifacts:
|
||||
try:
|
||||
return GovernedBuildArtifacts(
|
||||
version=payload["version"],
|
||||
candidate_id=payload["candidate_id"],
|
||||
source_commit=payload["source_commit"],
|
||||
tree_oid=payload["tree_oid"],
|
||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
||||
manifest=payload["manifest"],
|
||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
||||
policy=payload["policy"],
|
||||
tests=payload["tests"],
|
||||
canary=payload["canary"],
|
||||
gitea=payload["gitea"],
|
||||
rollback=payload["rollback"],
|
||||
secret_scan=payload["secret_scan"],
|
||||
deployment=payload["deployment"],
|
||||
tag=payload["tag"],
|
||||
finalization=payload["finalization"],
|
||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
||||
issued_at=payload.get("issued_at", ""),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise EvidenceProduceError("PAYLOAD_SCHEMA_INVALID") from exc
|
||||
|
||||
|
||||
def invoke_governed_produce(
|
||||
*,
|
||||
builder_policy: Path,
|
||||
source_root: Path,
|
||||
payload: Mapping[str, Any],
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Real runtime chain: Inventory -> Resolver -> operation -> Producer -> closure.
|
||||
|
||||
Returns a verdict dict. Only a genuine end-to-end invocation that reaches
|
||||
evidence production reports ``status == "PRODUCER_RUNTIME_REGISTERED_PASS"``.
|
||||
"""
|
||||
inventory = EvidenceOperationInventory.default()
|
||||
operation_id = "governed.release.evidence.produce"
|
||||
if not inventory.contains(operation_id):
|
||||
raise EvidenceProduceError("OPERATION_NOT_IN_INVENTORY")
|
||||
|
||||
definition = inventory.get(operation_id)
|
||||
assert definition is not None # guaranteed by contains()
|
||||
handler = resolve_handler(definition.handler_ref)
|
||||
|
||||
request = _payload_to_artifacts(payload)
|
||||
result = handler(
|
||||
builder_policy=builder_policy,
|
||||
source_root=source_root,
|
||||
request=request,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
# The resolved handler returned a real ProduceResult from a real closure;
|
||||
# the registered operation genuinely produced evidence.
|
||||
return {
|
||||
"status": "PRODUCER_RUNTIME_REGISTERED_PASS",
|
||||
"operation_id": operation_id,
|
||||
"handler_ref": definition.handler_ref,
|
||||
"handler_resolved": handler.__module__ + "." + handler.__name__,
|
||||
"evidence_types_bound": list(TRUSTED_PROVIDER_EVIDENCE_TYPES),
|
||||
"result": {
|
||||
"produce_status": result.status,
|
||||
"idempotent": result.idempotent,
|
||||
"receipt_sha256": result.receipt_sha256,
|
||||
"closure_dir": result.closure_dir,
|
||||
"evidence_digests": dict(result.evidence_digests),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V2R2C2 Round-3: Provider-Authenticated runtime chain.
|
||||
#
|
||||
# Inventory -> Resolver -> authenticated operation -> TrustedProviderReceiptResolver
|
||||
# -> Producer assembler (second-layer bound)
|
||||
#
|
||||
# The Round-3 runtime builds TrustedProviderReceiptResolver from the governed
|
||||
# evidence-provider policy (never from request-supplied store paths, provider
|
||||
# identities, key ids, or HMAC material). Gate-W authenticates only the
|
||||
# provider classes that can exist before staging (tests/Gitea/secret-scan);
|
||||
# canary/rollback remain pending until Gate-R.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sequence used for a full final release closure (Gate-R).
|
||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
# Provider classes that are real before staging (Gate-W).
|
||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
||||
|
||||
_DEFAULT_PROVIDER_POLICY = Path(__file__).resolve().parents[2] / "config" / "gitops" / "trusted-evidence-providers.json"
|
||||
|
||||
|
||||
def _load_provider_policy(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_UNAVAILABLE") from exc
|
||||
if not isinstance(decoded, Mapping) or decoded.get("schema") != "aios.trusted-evidence-providers.v1":
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
return decoded
|
||||
|
||||
|
||||
def _policy_from_config(name: str, raw: Mapping[str, Any], defaults: Mapping[str, Any]) -> TrustedProviderPolicy:
|
||||
def _mode(value: object, field: str) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
return int(value, 8)
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
|
||||
try:
|
||||
store_root = Path(str(raw["store_root"])).expanduser()
|
||||
allowed_ops = frozenset(str(op) for op in raw["allowed_operations"])
|
||||
return TrustedProviderPolicy(
|
||||
evidence_kind=name,
|
||||
provider_identity=str(raw["provider_identity"]),
|
||||
store_id=str(raw["store_id"]),
|
||||
store_root=store_root,
|
||||
allowed_operations=allowed_ops,
|
||||
expected_owner_uid=_mode(raw.get("expected_owner_uid", defaults.get("expected_owner_uid", 0)), "expected_owner_uid"),
|
||||
allowed_directory_modes=frozenset(
|
||||
_mode(v, "allowed_directory_modes")
|
||||
for v in raw.get("allowed_directory_modes", defaults.get("allowed_directory_modes", ["0700", "0750"]))
|
||||
),
|
||||
allowed_receipt_modes=frozenset(
|
||||
_mode(v, "allowed_receipt_modes")
|
||||
for v in raw.get("allowed_receipt_modes", defaults.get("allowed_receipt_modes", ["0600", "0640"]))
|
||||
),
|
||||
authentication_kind=str(raw.get("authentication_kind", defaults.get("authentication_kind", "root-owned-store-v1"))),
|
||||
max_age_seconds=raw.get("max_age_seconds", defaults.get("max_age_seconds")),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID") from exc
|
||||
|
||||
|
||||
def build_provider_receipt_resolver(
|
||||
provider_policy_path: Path = _DEFAULT_PROVIDER_POLICY,
|
||||
*,
|
||||
hmac_key_resolver: Callable[[str], bytes] | None = None,
|
||||
now_fn: Callable[[], Any] | None = None,
|
||||
) -> TrustedProviderReceiptResolver:
|
||||
"""Build TrustedProviderReceiptResolver from the governed evidence-provider policy.
|
||||
|
||||
Store paths, provider identities, key ids and HMAC material come only from
|
||||
the governed policy/secret resolver — never from a request payload.
|
||||
"""
|
||||
policy = _load_provider_policy(provider_policy_path)
|
||||
defaults = dict(policy.get("defaults") or {})
|
||||
providers = policy.get("providers")
|
||||
if not isinstance(providers, Mapping) or not providers:
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
policies: dict[str, TrustedProviderPolicy] = {}
|
||||
for name, raw in providers.items():
|
||||
if not isinstance(raw, Mapping):
|
||||
raise EvidenceProduceError("PROVIDER_POLICY_INVALID")
|
||||
policies[name] = _policy_from_config(name, raw, defaults)
|
||||
return TrustedProviderReceiptResolver(policies, hmac_key_resolver=hmac_key_resolver, now_fn=now_fn)
|
||||
|
||||
|
||||
def _resolver_guard(resolver: TrustedProviderReceiptResolver | None) -> TrustedProviderReceiptResolver:
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
return resolver
|
||||
|
||||
|
||||
def validate_gate_w_provider_auth(
|
||||
request: GovernedBuildArtifacts,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Gate-W: authenticate only the provider classes real before staging.
|
||||
|
||||
Returns PRODUCER_RUNTIME_REGISTRATION_PASS / PROVIDER_AUTH_ENFORCEMENT_PASS /
|
||||
FINAL_RELEASE_CLOSURE_PENDING_GATE_R. Canary/rollback are intentionally not
|
||||
required here — they remain pending until Gate-R.
|
||||
"""
|
||||
resolver = _resolver_guard(provider_receipt_resolver)
|
||||
gate_w_authentication_status(request, resolver=resolver)
|
||||
return {
|
||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
||||
}
|
||||
|
||||
|
||||
def _legacy_closure_adapter(authenticated_request: GovernedBuildArtifacts, *, source_root: Path, builder_policy: Path) -> ProduceResult:
|
||||
"""Invoke the legacy closure assembler on the resolver-authenticated request.
|
||||
|
||||
The classified evidence fields now carry ``provider_receipt`` provenance;
|
||||
the producer's second-layer validator re-asserts content/OID binding on it.
|
||||
"""
|
||||
return produce_evidence_closure(
|
||||
builder_policy=builder_policy,
|
||||
source_root=source_root,
|
||||
request=authenticated_request,
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
|
||||
|
||||
def produce_authenticated_evidence(
|
||||
request: GovernedBuildArtifacts,
|
||||
*,
|
||||
provider_receipt_resolver: TrustedProviderReceiptResolver | None,
|
||||
source_root: Path,
|
||||
builder_policy: Path,
|
||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
||||
) -> ProduceResult:
|
||||
"""Gate-R production path: authenticate all five final-release provider
|
||||
receipts, then invoke the legacy assembler only after authentication."""
|
||||
resolver = _resolver_guard(provider_receipt_resolver)
|
||||
try:
|
||||
return produce_authenticated_evidence_closure(
|
||||
request,
|
||||
provider_receipt_resolver=resolver,
|
||||
source_root=source_root,
|
||||
builder_policy=builder_policy,
|
||||
legacy_producer=_legacy_closure_adapter,
|
||||
)
|
||||
except ProviderAuthenticationError as exc:
|
||||
raise EvidenceProduceError(str(exc)) from exc
|
||||
@@ -0,0 +1,713 @@
|
||||
"""Governed release orchestration with a trusted-plan V2R2C2 path.
|
||||
|
||||
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
|
||||
uses the existing TagPublisher and metadata adapters; it does not introduce a
|
||||
second Git writer, a second durable job store, or an alternate baseline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from trusted_release_execution_plan import PlanError, TrustedReleasePlan
|
||||
|
||||
from gitea_tag_publication import (
|
||||
GiteaTagClient,
|
||||
GitTagBackend,
|
||||
PublicationManualOwnerReviewRequired,
|
||||
PublicationPending,
|
||||
PublicationReconciliationRequired,
|
||||
PublicationRequest,
|
||||
TagPublisher,
|
||||
)
|
||||
|
||||
|
||||
VERSION = "V2R2C1"
|
||||
TARGET_OID = "998d30304787e31e5ae607c0bd86b5edb2b0e5ad"
|
||||
EVIDENCE_CLOSURE_OID = "a8b906d1bc24088c19647e98de6a679268f6d0a2"
|
||||
APPROVAL_PLAN_ID = "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e"
|
||||
REPOSITORY = "aios/aios"
|
||||
SERVICE = "aios-v2r1c7-production.service"
|
||||
PORT = 18927
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ReleaseBlocked(RuntimeError):
|
||||
"""A release gate failed and no unsafe recovery is permitted."""
|
||||
|
||||
|
||||
class ManualOwnerReviewRequired(ReleaseBlocked):
|
||||
pass
|
||||
|
||||
|
||||
class ReconciliationRequired(ReleaseBlocked):
|
||||
pass
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ReleaseBlocked("artifact must be a 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 atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
temporary.write_bytes(canonical(dict(value)))
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseBinding:
|
||||
version: str = VERSION
|
||||
target_oid: str = TARGET_OID
|
||||
evidence_closure_oid: str = EVIDENCE_CLOSURE_OID
|
||||
approval_plan_id: str = APPROVAL_PLAN_ID
|
||||
repository: str = REPOSITORY
|
||||
production_service: str = SERVICE
|
||||
production_port: int = PORT
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.version != VERSION or self.repository != REPOSITORY:
|
||||
raise ManualOwnerReviewRequired("release binding is outside V2R2C1 scope")
|
||||
if not OID_RE.fullmatch(self.target_oid) or self.target_oid != TARGET_OID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 target_oid mismatch")
|
||||
if not OID_RE.fullmatch(self.evidence_closure_oid) or self.evidence_closure_oid != EVIDENCE_CLOSURE_OID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 evidence closure mismatch")
|
||||
if self.approval_plan_id != APPROVAL_PLAN_ID:
|
||||
raise ManualOwnerReviewRequired("V2R2C1 approval plan mismatch")
|
||||
if self.production_service != SERVICE or int(self.production_port) != PORT:
|
||||
raise ManualOwnerReviewRequired("production service binding mismatch")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"version": self.version,
|
||||
"target_oid": self.target_oid,
|
||||
"evidence_closure_oid": self.evidence_closure_oid,
|
||||
"approval_plan_id": self.approval_plan_id,
|
||||
"repository": self.repository,
|
||||
"production_service": self.production_service,
|
||||
"production_port": self.production_port,
|
||||
}
|
||||
|
||||
|
||||
class StateLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self.handle = None
|
||||
|
||||
def __enter__(self) -> "StateLock":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.handle = self.path.open("a+", encoding="utf-8")
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
if self.handle is not None:
|
||||
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
|
||||
self.handle.close()
|
||||
|
||||
|
||||
class ReleaseState:
|
||||
def __init__(self, root: Path, binding: ReleaseBinding):
|
||||
self.root = root
|
||||
self.path = root / "release-state.json"
|
||||
self.binding = binding
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
if self.path.exists():
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if value.get("binding") != binding.as_dict():
|
||||
raise ManualOwnerReviewRequired("persisted release binding conflict")
|
||||
self.value = value
|
||||
else:
|
||||
self.value = {
|
||||
"schema": "aios.v2r2c1.governed-production-release-state.v1",
|
||||
"binding": binding.as_dict(),
|
||||
"state": "INITIAL",
|
||||
"completed": {},
|
||||
"history": [],
|
||||
}
|
||||
self.save()
|
||||
|
||||
def save(self) -> None:
|
||||
atomic_json(self.path, self.value)
|
||||
|
||||
def record(self, state: str, step: str, result: Mapping[str, Any]) -> None:
|
||||
safe = json.loads(json.dumps(dict(result)))
|
||||
self.value["state"] = state
|
||||
self.value.setdefault("completed", {})[step] = safe
|
||||
self.value.setdefault("history", []).append(
|
||||
{"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "state": state, "step": step, "result": safe}
|
||||
)
|
||||
self.save()
|
||||
|
||||
def fail(self, state: str, reason: str) -> None:
|
||||
self.value["state"] = state
|
||||
self.value.setdefault("history", []).append(
|
||||
{"at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "state": state, "reason": str(reason)[:500]}
|
||||
)
|
||||
self.save()
|
||||
|
||||
|
||||
def _git(git_dir: Path, *args: str, check: bool = True) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", *args],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
raise ReconciliationRequired(completed.stderr.strip() or "authoritative git query failed")
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def _ancestor(git_dir: Path, ancestor: str, descendant: str) -> bool:
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "merge-base", "--is-ancestor", ancestor, descendant],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
).returncode == 0
|
||||
|
||||
|
||||
def verify_lineage(git_dir: Path, binding: ReleaseBinding) -> dict[str, Any]:
|
||||
binding.validate()
|
||||
if _git(git_dir, "cat-file", "-t", binding.target_oid) != "commit":
|
||||
raise ReleaseBlocked("approved target_oid is not a commit")
|
||||
if _git(git_dir, "cat-file", "-t", binding.evidence_closure_oid) != "commit":
|
||||
raise ReleaseBlocked("evidence closure is not a commit")
|
||||
if not _ancestor(git_dir, binding.target_oid, binding.evidence_closure_oid):
|
||||
raise ManualOwnerReviewRequired("approved target is not an ancestor of evidence closure")
|
||||
return {
|
||||
"target_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"target_is_ancestor_of_evidence": True,
|
||||
"main_oid": _git(git_dir, "rev-parse", "refs/heads/main"),
|
||||
}
|
||||
|
||||
|
||||
def verify_owner_approval(value: Mapping[str, Any], binding: ReleaseBinding) -> dict[str, Any]:
|
||||
approval = value.get("owner_approval")
|
||||
if not isinstance(approval, Mapping):
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_RECEIPT_REQUIRED")
|
||||
if approval.get("approved") is not True or approval.get("status") != "APPROVED":
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_NOT_ACCEPTED")
|
||||
if approval.get("approved_by") != "aiman":
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_ACTOR_MISMATCH")
|
||||
if approval.get("approval_source") != "explicit_owner_approval_in_chat":
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_SOURCE_MISMATCH")
|
||||
for key, expected in binding.as_dict().items():
|
||||
if key in {"repository", "production_service", "production_port"}:
|
||||
continue
|
||||
if approval.get(key) != expected:
|
||||
raise ManualOwnerReviewRequired("OWNER_APPROVAL_BINDING_MISMATCH:" + key)
|
||||
return dict(approval)
|
||||
|
||||
|
||||
def _payload_file(git_dir: Path, oid: str, path: str) -> bytes:
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "show", f"{oid}:{path}"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=45,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise ReleaseBlocked("required release source missing:" + path)
|
||||
return completed.stdout
|
||||
|
||||
|
||||
def _commit_epoch(git_dir: Path, oid: str) -> int:
|
||||
return int(_git(git_dir, "show", "-s", "--format=%ct", oid))
|
||||
|
||||
|
||||
def build_production_artifact(
|
||||
git_dir: Path,
|
||||
binding: ReleaseBinding,
|
||||
output_root: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a deterministic executable V2R2C1 health artifact from target_oid."""
|
||||
binding.validate()
|
||||
verify_lineage(git_dir, binding)
|
||||
epoch = _commit_epoch(git_dir, binding.target_oid)
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
stem = f"aios-{VERSION}-{binding.target_oid[:12]}"
|
||||
output = output_root / (stem + ".tar.zst")
|
||||
with tempfile.TemporaryDirectory(prefix="v2r2c1-artifact-") as temp:
|
||||
root = Path(temp) / "payload"
|
||||
root.mkdir(parents=True)
|
||||
source_paths = {
|
||||
"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",
|
||||
}
|
||||
for destination, source in source_paths.items():
|
||||
target = root / destination
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(_payload_file(git_dir, binding.target_oid, source))
|
||||
generated: dict[str, str] = {
|
||||
"VERSION": VERSION + "\n",
|
||||
"active-runtime/app/v2r2c1_health_service.py": (
|
||||
"from v2r1c6_privacy_tools import main\n\n"
|
||||
"if __name__ == '__main__':\n raise SystemExit(main())\n"
|
||||
),
|
||||
"deploy/aios-v2/systemd/aios-v2r1c7-production.service": (
|
||||
"[Unit]\nDescription=AIOS V2R2C1 governed health contract service\n"
|
||||
"After=network.target\n\n[Service]\nType=simple\n"
|
||||
"User=aios-v2r1c7\nGroup=aios-v2r1c7\n"
|
||||
"WorkingDirectory=/opt/aios/v2r2c1\n"
|
||||
"EnvironmentFile=/etc/aios/aios-v2r2c1.env\n"
|
||||
"ExecStart=/usr/bin/python3 /opt/aios/v2r2c1/active-runtime/app/v2r2c1_health_service.py --serve --host 127.0.0.1 --port ${AIOS_V2R1C7_PORT}\n"
|
||||
"Restart=on-failure\nRestartSec=5\nNoNewPrivileges=true\nPrivateTmp=true\n"
|
||||
"ProtectHome=true\nProtectSystem=strict\nProtectKernelLogs=true\n"
|
||||
"ProtectKernelModules=true\nProtectKernelTunables=true\nProtectControlGroups=true\n"
|
||||
"RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6\nIPAddressDeny=any\n"
|
||||
"IPAddressAllow=localhost\nUMask=0077\n\n[Install]\nWantedBy=multi-user.target\n"
|
||||
),
|
||||
"deploy/aios-v2/v2r2c1-runtime-config.json": json.dumps({
|
||||
"schema": "aios.v2r2c1.production-runtime.v1",
|
||||
"version": VERSION,
|
||||
"target_oid": binding.target_oid,
|
||||
"service": SERVICE,
|
||||
"port": PORT,
|
||||
"health_path": "/health",
|
||||
"deployment_state_source": "environment_and_state_file",
|
||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
"deploy/aios-v2/v2r2c1-rollback.json": json.dumps({
|
||||
"schema": "aios.v2r2c1.rollback-metadata.v1",
|
||||
"rollback_version": "V2R1C7",
|
||||
"rollback_service": SERVICE,
|
||||
"rollback_target_is_previous_production": True,
|
||||
"automatic_rollback": False,
|
||||
}, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
"scripts/probe_v2r2c1_health.py": (
|
||||
"import json,sys,urllib.request\n"
|
||||
"url=sys.argv[1] if len(sys.argv)>1 else 'http://127.0.0.1:18927/health'\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",
|
||||
"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():
|
||||
target = root / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
if rel.startswith("scripts/"):
|
||||
target.chmod(0o755)
|
||||
metadata = {
|
||||
"schema": "aios.release-artifact-build.v3",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"source_date_epoch": epoch,
|
||||
"clean_source": True,
|
||||
"executable_payload": True,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
(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"))))
|
||||
names = sorted("payload/" + str(path.relative_to(root)).replace(os.sep, "/") for path in root.rglob("*") if path.is_file())
|
||||
(root / "ARTIFACT-MANIFEST.json").write_bytes(canonical({
|
||||
"schema": "aios.release-artifact-manifest.v3",
|
||||
"version": VERSION,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"repository": REPOSITORY,
|
||||
"files": names + ["payload/ARTIFACT-MANIFEST.json"],
|
||||
"deployable": True,
|
||||
"executable_payload": True,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}))
|
||||
tar_path = Path(temp) / "release.tar"
|
||||
with tarfile.open(tar_path, "w") as archive:
|
||||
for path in sorted(root.rglob("*")):
|
||||
relative = str(path.relative_to(root)).replace(os.sep, "/")
|
||||
info = archive.gettarinfo(str(path), arcname="payload/" + relative)
|
||||
info.uid = info.gid = 0
|
||||
info.uname = "root"
|
||||
info.gname = "root"
|
||||
info.mtime = epoch
|
||||
info.mode = 0o755 if path.is_dir() or path.suffix == ".sh" else 0o644
|
||||
if path.is_file():
|
||||
with path.open("rb") as stream:
|
||||
archive.addfile(info, stream)
|
||||
else:
|
||||
archive.addfile(info)
|
||||
subprocess.run(["zstd", "-T1", "-19", "--no-progress", "-q", "-f", str(tar_path), "-o", str(output)], check=True, timeout=120)
|
||||
digest = sha256_file(output)
|
||||
receipt = {
|
||||
"schema": "aios.release-artifact-receipt.v3",
|
||||
"version": VERSION,
|
||||
"repository": REPOSITORY,
|
||||
"source_head_oid": binding.target_oid,
|
||||
"evidence_closure_oid": binding.evidence_closure_oid,
|
||||
"artifact": str(output),
|
||||
"artifact_sha256": digest,
|
||||
"artifact_size": output.stat().st_size,
|
||||
"deployable": True,
|
||||
"executable_payload": True,
|
||||
"production_deployed": False,
|
||||
"tag_created": False,
|
||||
"baseline_changed": False,
|
||||
}
|
||||
atomic_json(output.with_suffix(output.suffix + ".receipt.json"), receipt, mode=0o640)
|
||||
return receipt
|
||||
|
||||
|
||||
class GovernedProductionReleaseExecutor:
|
||||
def __init__(self, config: Any):
|
||||
self.config = config
|
||||
self.binding = ReleaseBinding()
|
||||
self.binding.validate()
|
||||
self.root = Path(config.state_root) / "v2r2c1-governed-production-release"
|
||||
self.state = ReleaseState(self.root, self.binding)
|
||||
workspace = self.root / "publication-workspace"
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
self.publisher = TagPublisher(
|
||||
workspace,
|
||||
GitTagBackend(workspace, authoritative_repo=Path(config.authoritative_git_dir)),
|
||||
GiteaTagClient(config.api_base, REPOSITORY, Path(config.token_path)),
|
||||
config.remote_name,
|
||||
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:
|
||||
binding = self.binding
|
||||
for key, expected in binding.as_dict().items():
|
||||
if key in request and request[key] != expected:
|
||||
raise ManualOwnerReviewRequired("release request binding mismatch:" + key)
|
||||
return PublicationRequest(binding.version, binding.target_oid, binding.evidence_closure_oid, binding.approval_plan_id)
|
||||
|
||||
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")
|
||||
owner = verify_owner_approval(request, self.binding)
|
||||
with StateLock(self.root / "release.lock"):
|
||||
current = self.state.value.get("approval")
|
||||
approval_digest = hashlib.sha256(canonical(owner)).hexdigest()
|
||||
record = {
|
||||
"schema": "aios.v2r2c1.tag-approval-request.v1",
|
||||
"binding": self.binding.as_dict(),
|
||||
"owner_approval": owner,
|
||||
"owner_approval_digest_sha256": approval_digest,
|
||||
"execute": False,
|
||||
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
if current and current != record:
|
||||
raise ManualOwnerReviewRequired("persisted Owner approval differs")
|
||||
self.state.value["approval"] = record
|
||||
self.state.record("APPROVAL_REQUEST_RECORDED", "tag_approval_request", record)
|
||||
return {"status": "RECORDED", "execute": False, "binding": self.binding.as_dict(), "owner_approval_digest_sha256": approval_digest}
|
||||
|
||||
def _require_approval(self) -> None:
|
||||
approval = self.state.value.get("approval")
|
||||
if not isinstance(approval, Mapping) or approval.get("binding") != self.binding.as_dict():
|
||||
raise ReleaseBlocked("OWNER_APPROVAL_REQUEST_NOT_RECORDED")
|
||||
|
||||
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")
|
||||
self._require_approval()
|
||||
publication = self._request(request)
|
||||
with StateLock(self.root / "release.lock"):
|
||||
try:
|
||||
verified = self.publisher._verify_publication(publication, allow_push=True, previously_deployed=False)
|
||||
except PublicationManualOwnerReviewRequired as exc:
|
||||
self.state.fail("MANUAL_OWNER_REVIEW_REQUIRED", str(exc))
|
||||
raise ManualOwnerReviewRequired(str(exc)) from exc
|
||||
except PublicationPending as exc:
|
||||
self.state.fail("REMOTE_PUSH_PENDING", str(exc))
|
||||
raise ReconciliationRequired(str(exc)) from exc
|
||||
except PublicationReconciliationRequired as exc:
|
||||
self.state.fail("RECONCILIATION_REQUIRED", str(exc))
|
||||
raise ReconciliationRequired(str(exc)) from exc
|
||||
result = dict(verified)
|
||||
self.state.record("TAG_VERIFIED", "tag", result)
|
||||
return {"status": "REMOTE_VERIFIED", **result}
|
||||
|
||||
def tag_status(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self._request(request)
|
||||
return self.publisher.get_tag_publication_status(VERSION)
|
||||
|
||||
def build_artifact(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self._require_approval()
|
||||
self._request(request)
|
||||
receipt = build_production_artifact(Path(self.config.authoritative_git_dir), self.binding, self.root / "artifacts")
|
||||
self.state.record("ARTIFACT_VERIFIED", "production_artifact", receipt)
|
||||
return {"status": "VERIFIED", **receipt}
|
||||
|
||||
def execute_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self._require_approval()
|
||||
publication = self._request(request)
|
||||
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"}:
|
||||
raise ReleaseBlocked("REMOTE_TAG_PROOF_REQUIRED")
|
||||
artifact = self.state.value.get("completed", {}).get("production_artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
artifact = self.build_artifact(request)
|
||||
value = {
|
||||
**self.binding.as_dict(),
|
||||
"tag_oid": tag_state.get("tag_oid") or tag_state.get("gitea_tag_oid"),
|
||||
"artifact_path": artifact["artifact"],
|
||||
"artifact_size": int(artifact["artifact_size"]),
|
||||
"artifact_sha256": artifact["artifact_sha256"],
|
||||
}
|
||||
# The production adapter is privileged and is run through the root
|
||||
# approved-release service boundary by the caller. This handler is
|
||||
# still useful for a root-owned staging invocation and remains
|
||||
# fail-closed when the adapter is not available.
|
||||
import v2r2c1_production_adapter
|
||||
runtime = v2r2c1_production_adapter.execute_request({"schema": "aios.production-executor-request.v1", "operation": "apply", **value})
|
||||
if runtime.get("status") != "ACTIVE_VERIFIED":
|
||||
self.state.fail("RECONCILIATION_REQUIRED", "production health did not verify")
|
||||
raise ReconciliationRequired("production health did not verify")
|
||||
self.state.record("PRODUCTION_VERIFIED", "production", runtime)
|
||||
return {"status": "PRODUCTION_VERIFIED", "runtime": runtime, "artifact": artifact, "tag_oid": value["tag_oid"]}
|
||||
|
||||
def consistency(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
publication = self._request(request)
|
||||
result = self.publisher.get_release_consistency(publication)
|
||||
result["release_binding"] = self.binding.as_dict()
|
||||
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["baseline"] = _read_json(Path(self.config.baseline_path))
|
||||
return result
|
||||
|
||||
def acceptance(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
result = self.consistency(request)
|
||||
result["accepted"] = result.get("status") == "PASS" and result.get("production_health", {}).get("status") == "ACTIVE_VERIFIED"
|
||||
self.state.record("ACCEPTED" if result["accepted"] else "RECONCILIATION_REQUIRED", "acceptance", result)
|
||||
return result
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {"status": "MISSING", "path": str(path)}
|
||||
if not isinstance(value, dict):
|
||||
return {"status": "INVALID", "path": str(path)}
|
||||
return {"status": "PRESENT", "path": str(path), **value}
|
||||
|
||||
|
||||
def _health(url: str, binding: ReleaseBinding, artifact: Mapping[str, Any]) -> dict[str, Any]:
|
||||
import urllib.request
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except Exception as exc:
|
||||
return {"status": "UNAVAILABLE", "error": type(exc).__name__}
|
||||
expected_artifact = artifact.get("artifact_sha256")
|
||||
exact = (
|
||||
status == 200
|
||||
and body.get("version") == binding.version
|
||||
and body.get("commit_oid") == binding.target_oid
|
||||
and (not expected_artifact or body.get("artifact_sha256") == expected_artifact)
|
||||
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}
|
||||
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("build-artifact", "status"))
|
||||
parser.add_argument("--git-dir", type=Path, default=Path("/data/aios-gitops/repo.git"))
|
||||
parser.add_argument("--output-root", type=Path, default=Path("/var/lib/aios-gitops/approved-artifacts/V2R2C1"))
|
||||
args = parser.parse_args()
|
||||
binding = ReleaseBinding()
|
||||
if args.operation == "build-artifact":
|
||||
print(json.dumps(build_production_artifact(args.git_dir, binding, args.output_root), sort_keys=True))
|
||||
else:
|
||||
print(json.dumps({"binding": binding.as_dict(), "status": "READY"}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import is_dataclass, replace
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from trusted_provider_receipts import (
|
||||
ProviderAuthenticationError,
|
||||
ResolvedEvidence,
|
||||
TrustedProviderReceiptResolver,
|
||||
)
|
||||
|
||||
ALL_RELEASE_EVIDENCE_KINDS = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
GATE_W_EVIDENCE_KINDS = ("tests", "gitea", "secret_scan")
|
||||
|
||||
|
||||
def authenticate_evidence_request(
|
||||
evidence_kind: str,
|
||||
evidence_request: Mapping[str, Any],
|
||||
*,
|
||||
source_oid: str,
|
||||
tree_oid: str,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, Any]:
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
resolved: ResolvedEvidence = resolver.resolve_evidence(
|
||||
evidence_kind,
|
||||
evidence_request,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
)
|
||||
return {
|
||||
**dict(resolved.result),
|
||||
"provider_receipt": dict(resolved.provider_receipt),
|
||||
}
|
||||
|
||||
|
||||
def authenticate_build_artifacts(
|
||||
request: Any,
|
||||
*,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
required_kinds: Iterable[str] = ALL_RELEASE_EVIDENCE_KINDS,
|
||||
) -> Any:
|
||||
"""Authenticate receipt_ref-only evidence and return a replaced request.
|
||||
|
||||
The input is expected to be the existing GovernedBuildArtifacts dataclass.
|
||||
The function deliberately relies only on field names already used by the
|
||||
producer (`source_commit`, `tree_oid`, and the evidence kind fields), so it
|
||||
can be integrated without changing the deployment/release-plan contract.
|
||||
"""
|
||||
if resolver is None:
|
||||
raise ProviderAuthenticationError("PROVIDER_AUTHENTICATION_REQUIRED")
|
||||
if not is_dataclass(request):
|
||||
raise TypeError("request must be a dataclass instance")
|
||||
source_oid = getattr(request, "source_commit")
|
||||
tree_oid = getattr(request, "tree_oid")
|
||||
replacements: dict[str, Any] = {}
|
||||
for kind in tuple(required_kinds):
|
||||
evidence_request = getattr(request, kind, None)
|
||||
if evidence_request is None:
|
||||
raise ProviderAuthenticationError(f"EVIDENCE_{kind.upper()}_REQUIRED")
|
||||
replacements[kind] = authenticate_evidence_request(
|
||||
kind,
|
||||
evidence_request,
|
||||
source_oid=source_oid,
|
||||
tree_oid=tree_oid,
|
||||
resolver=resolver,
|
||||
)
|
||||
return replace(request, **replacements)
|
||||
|
||||
|
||||
def gate_w_authentication_status(
|
||||
request: Any,
|
||||
*,
|
||||
resolver: TrustedProviderReceiptResolver | None,
|
||||
) -> dict[str, str]:
|
||||
"""Authenticate Gate-W evidence without pretending final closure exists."""
|
||||
authenticate_build_artifacts(
|
||||
request,
|
||||
resolver=resolver,
|
||||
required_kinds=GATE_W_EVIDENCE_KINDS,
|
||||
)
|
||||
return {
|
||||
"producer_runtime": "PRODUCER_RUNTIME_REGISTRATION_PASS",
|
||||
"provider_auth": "PROVIDER_AUTH_ENFORCEMENT_PASS",
|
||||
"final_release_closure": "FINAL_RELEASE_CLOSURE_PENDING_GATE_R",
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed release evidence producer (V2R2C2 P0, operation governed.release.evidence.produce).
|
||||
|
||||
This module is the *producer* half of the governed evidence pipeline. It never
|
||||
invents candidate/release evidence: every digest and pass/fail flag in the closure
|
||||
is derived from real build artifacts and real verification executions supplied as
|
||||
input. It assembles a self-consistent, root-owned evidence closure below
|
||||
``source_root/<version>`` that ``TrustedBuilderEvidenceMaterializer`` (the projection
|
||||
half) can re-validate and project byte-for-byte.
|
||||
|
||||
Contract (see docs/V2R2C2-P0-convergence-20260909.md section 1):
|
||||
- operation_id : governed.release.evidence.produce
|
||||
- input : governed build artifacts + deployment conventions (no hand-write)
|
||||
- output : source_root/<version>/ full evidence_layout closure + receipt.json
|
||||
- fail-closed : mismatched secret-scan digest, tampered artifact, non-passing
|
||||
tests/canary/rollback/secret-scan, or inconsistent gitea all abort
|
||||
without writing a closure.
|
||||
- idempotent : an existing identical closure is accepted; a conflicting one fails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_baseline.operations import OperationDefinition, OperationRegistry
|
||||
from aios_trusted_candidate_builder import (
|
||||
load_builder_policy,
|
||||
TrustedCandidateBuilderConfig,
|
||||
)
|
||||
from release_evidence_materializer import EvidenceMaterializationError
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
PRODUCE_SCHEMA = "aios.governed-release-evidence-producer.v1"
|
||||
EVIDENCE_RECEIPT_SCHEMA = "aios.trusted-candidate-builder.evidence.v1"
|
||||
# Each governed evidence type (tests/canary/gitea/rollback/secret_scan) must be
|
||||
# bound to a trusted-provider receipt. A plain caller-supplied {"passed": true}
|
||||
# is never accepted on its own: the producer requires the receipt that proves a
|
||||
# trusted provider actually ran the check and produced the referenced result.
|
||||
TRUSTED_PROVIDER_RECEIPT_SCHEMA = "aios.trusted-provider.evidence.v1"
|
||||
TRUSTED_PROVIDER_EVIDENCE_TYPES = ("tests", "canary", "gitea", "rollback", "secret_scan")
|
||||
|
||||
|
||||
class EvidenceProduceError(RuntimeError):
|
||||
"""Stable, non-sensitive producer failure."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def _validate_oid(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if (
|
||||
len(normalized) != 40
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_identifier(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > 255
|
||||
or normalized[0] in ".-"
|
||||
or any(
|
||||
char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
|
||||
for char in normalized
|
||||
)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_sha256(value: object, name: str, *, allow_empty: bool = False) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if allow_empty and not normalized:
|
||||
return ""
|
||||
if (
|
||||
len(normalized) != 64
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceProduceError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedBuildArtifacts:
|
||||
"""Real build artifacts + real verification results. Producer never invents these."""
|
||||
|
||||
version: str
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
artifact: bytes
|
||||
manifest: Mapping[str, Any]
|
||||
package_closure: bytes
|
||||
policy: Mapping[str, Any]
|
||||
tests: Mapping[str, Any]
|
||||
canary: Mapping[str, Any]
|
||||
gitea: Mapping[str, Any]
|
||||
rollback: Mapping[str, Any]
|
||||
secret_scan: Mapping[str, Any]
|
||||
# Deployment conventions (supplied by release planning, not evidence invention).
|
||||
deployment: Mapping[str, Any]
|
||||
tag: Mapping[str, Any]
|
||||
finalization: Mapping[str, Any]
|
||||
rollback_sha256: str = ""
|
||||
issued_at: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProduceResult:
|
||||
status: str
|
||||
idempotent: bool
|
||||
receipt_sha256: str
|
||||
closure_dir: str
|
||||
evidence_digests: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _require_trusted_provider_receipt(
|
||||
name: str,
|
||||
evidence: Mapping[str, Any],
|
||||
source_commit: str,
|
||||
tree_oid: str,
|
||||
) -> None:
|
||||
"""Require a trusted-provider binding for `evidence` (second-layer check).
|
||||
|
||||
A caller may never pass a bare ``{"passed": true}``: each governed evidence
|
||||
type must be bound to a trusted provider. Two accepted forms, both
|
||||
fail-closed on any missing/structurally invalid/digest-mismatched binding:
|
||||
|
||||
1. Round-2 native ``receipt`` (schema ``aios.trusted-provider.evidence.v1``)
|
||||
— a caller-carried receipt.
|
||||
2. Round-3 resolver-authenticated ``provider_receipt`` provenance (schema
|
||||
``aios.trusted-evidence-provider-receipt.v2``) — produced by
|
||||
``TrustedProviderReceiptResolver`` from an authenticated store. Its input
|
||||
is the resolver-authenticated provenance, never a caller-supplied body.
|
||||
|
||||
This helper is a *second-layer* binding validator: for the Round-3 path the
|
||||
authoritative proof is the resolver's authenticated provenance embedded as
|
||||
``provider_receipt``, and this check only re-asserts content/OID binding on
|
||||
top of it.
|
||||
"""
|
||||
receipt = evidence.get("receipt")
|
||||
if isinstance(receipt, Mapping) and receipt:
|
||||
body = {k: v for k, v in dict(evidence).items() if k != "receipt"}
|
||||
expected = _sha256_bytes(_canonical_json(body))
|
||||
if receipt.get("schema") != TRUSTED_PROVIDER_RECEIPT_SCHEMA:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if receipt.get("content_sha256") != expected:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_CONTENT_MISMATCH")
|
||||
for f in ("provider_identity", "operation_run_id", "result_reference",
|
||||
"environment", "issued_at"):
|
||||
if not str(receipt.get(f, "") or "").strip():
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if receipt.get("source_oid") not in (None, "", source_commit):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
||||
if receipt.get("tree_oid") not in (None, "", tree_oid):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
||||
return
|
||||
|
||||
provider_receipt = evidence.get("provider_receipt")
|
||||
if isinstance(provider_receipt, Mapping) and provider_receipt:
|
||||
if (
|
||||
provider_receipt.get("schema")
|
||||
!= "aios.trusted-evidence-provider-receipt.v2"
|
||||
):
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
for f in ("provider_identity", "provider_operation", "operation_run_id",
|
||||
"result_reference", "content_sha256", "source_oid", "tree_oid",
|
||||
"issued_at"):
|
||||
if not str(provider_receipt.get(f, "") or "").strip():
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_INVALID")
|
||||
if provider_receipt.get("source_oid") != source_commit:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_SOURCE_OID_MISMATCH")
|
||||
if provider_receipt.get("tree_oid") != tree_oid:
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_TREE_OID_MISMATCH")
|
||||
return
|
||||
|
||||
raise EvidenceProduceError("EVIDENCE_RECEIPT_MISSING")
|
||||
|
||||
|
||||
def _verify_artifacts(
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
request: GovernedBuildArtifacts,
|
||||
) -> dict[str, str]:
|
||||
"""Validate input consistency (fail-closed) and return the evidence digests."""
|
||||
try:
|
||||
version = validate_release_version(request.version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
||||
if version != request.version:
|
||||
raise EvidenceProduceError("VERSION_INVALID")
|
||||
|
||||
candidate_id = _validate_identifier(request.candidate_id, "candidate_id")
|
||||
source_commit = _validate_oid(request.source_commit, "source_commit")
|
||||
tree_oid = _validate_oid(request.tree_oid, "tree_oid")
|
||||
|
||||
if (
|
||||
set(request.manifest) != {
|
||||
"artifact_sha256", "package_closure_sha256",
|
||||
}
|
||||
):
|
||||
raise EvidenceProduceError("MANIFEST_SCHEMA_INVALID")
|
||||
closure_sha = _validate_sha256(
|
||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
||||
)
|
||||
artifact_sha = _sha256_bytes(request.artifact)
|
||||
if request.manifest["artifact_sha256"] != artifact_sha:
|
||||
raise EvidenceProduceError("MANIFEST_ARTIFACT_DIGEST_MISMATCH")
|
||||
|
||||
# manifest_sha256 is the digest of the manifest evidence file itself. It is
|
||||
# derived (never invented) from the manifest content; the manifest does not
|
||||
# embed its own digest to avoid a self-referential fixed point.
|
||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
||||
if _sha256_bytes(request.package_closure) != closure_sha:
|
||||
raise EvidenceProduceError("PACKAGE_CLOSURE_DIGEST_MISMATCH")
|
||||
|
||||
# policy_sha256 is the digest of the policy evidence file content; derived
|
||||
# here (never invented, never self-referential).
|
||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
||||
|
||||
# Secret scan must cover the exact artifact bytes.
|
||||
secret_scan_artifact = _validate_sha256(
|
||||
request.secret_scan.get("artifact_sha256"), "secret_scan.artifact_sha256"
|
||||
)
|
||||
if secret_scan_artifact != artifact_sha:
|
||||
raise EvidenceProduceError("SECRET_SCAN_ARTIFACT_DIGEST_MISMATCH")
|
||||
|
||||
# Every governed evidence type must be bound to a trusted-provider receipt.
|
||||
# A bare caller-supplied {"passed": true} (no receipt) fails closed here;
|
||||
# the producer never accepts an invented pass flag.
|
||||
for etype in TRUSTED_PROVIDER_EVIDENCE_TYPES:
|
||||
_require_trusted_provider_receipt(
|
||||
etype, getattr(request, etype), source_commit, tree_oid
|
||||
)
|
||||
|
||||
if request.secret_scan.get("passed") is not True:
|
||||
raise EvidenceProduceError("SECRET_SCAN_NOT_PASSED")
|
||||
|
||||
if request.tests.get("passed") is not True:
|
||||
raise EvidenceProduceError("TESTS_NOT_PASSED")
|
||||
if request.canary.get("passed") is not True:
|
||||
raise EvidenceProduceError("CANARY_NOT_PASSED")
|
||||
if request.rollback.get("ready") is not True:
|
||||
raise EvidenceProduceError("ROLLBACK_NOT_READY")
|
||||
if (
|
||||
request.gitea.get("consistent") is not True
|
||||
or request.gitea.get("repository") != config.repository
|
||||
or request.gitea.get("source_commit") != source_commit
|
||||
or request.gitea.get("remote_ref_oid") != source_commit
|
||||
or request.gitea.get("tree_oid") != tree_oid
|
||||
):
|
||||
raise EvidenceProduceError("GITEA_INCONSISTENT")
|
||||
|
||||
digests = {
|
||||
"artifact": artifact_sha,
|
||||
"manifest": manifest_sha,
|
||||
"package_closure": closure_sha,
|
||||
"policy": policy_sha,
|
||||
"tests": _sha256_bytes(_canonical_json(dict(request.tests))),
|
||||
"canary": _sha256_bytes(_canonical_json(dict(request.canary))),
|
||||
"gitea": _sha256_bytes(_canonical_json(dict(request.gitea))),
|
||||
"rollback": _sha256_bytes(_canonical_json(dict(request.rollback))),
|
||||
"secret_scan": _sha256_bytes(_canonical_json(dict(request.secret_scan))),
|
||||
}
|
||||
return digests
|
||||
|
||||
|
||||
def _build_receipt(
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
request: GovernedBuildArtifacts,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Assemble the self-consistent receipt from verified artifacts."""
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseState
|
||||
|
||||
artifact_sha = _sha256_bytes(request.artifact)
|
||||
manifest_sha = _sha256_bytes(_canonical_json(dict(request.manifest)))
|
||||
closure_sha = _validate_sha256(
|
||||
request.manifest.get("package_closure_sha256"), "package_closure_sha256"
|
||||
)
|
||||
policy_sha = _sha256_bytes(_canonical_json(dict(request.policy)))
|
||||
|
||||
candidate_kwargs: dict[str, Any] = {}
|
||||
issued_at = str(request.issued_at or "").strip()
|
||||
if issued_at:
|
||||
# The evidence was produced at the build execution instant; bind it so the
|
||||
# same verified artifacts reproduce an identical, idempotent closure.
|
||||
candidate_kwargs["created_at"] = issued_at
|
||||
candidate_kwargs["updated_at"] = issued_at
|
||||
candidate = ReleaseCandidate(
|
||||
candidate_id=request.candidate_id,
|
||||
source_commit=request.source_commit,
|
||||
tree_oid=request.tree_oid,
|
||||
artifact_sha256=artifact_sha,
|
||||
manifest_sha256=manifest_sha,
|
||||
tests=dict(request.tests),
|
||||
canary=dict(request.canary),
|
||||
rollback_ready=request.rollback.get("ready") is True,
|
||||
git_consistency={
|
||||
"consistent": True,
|
||||
"repository": config.repository,
|
||||
"source_commit": request.source_commit,
|
||||
"tree_oid": request.tree_oid,
|
||||
},
|
||||
gitea_consistency=dict(request.gitea),
|
||||
state=ReleaseState.READY_FOR_APPROVAL,
|
||||
**candidate_kwargs,
|
||||
)
|
||||
|
||||
deployment_payload = dict(request.deployment)
|
||||
deployment_payload.update(
|
||||
{
|
||||
"service": request.deployment["service"],
|
||||
"source_commit": request.source_commit,
|
||||
"tree_oid": request.tree_oid,
|
||||
"artifact_sha256": artifact_sha,
|
||||
"manifest_sha256": manifest_sha,
|
||||
"package_closure_sha256": closure_sha,
|
||||
"policy_sha256": policy_sha,
|
||||
"rollback_sha256": _validate_sha256(
|
||||
request.rollback_sha256, "rollback_sha256"
|
||||
)
|
||||
or request.rollback.get("rollback_sha256", ""),
|
||||
"repository_identity": config.repository,
|
||||
"authoritative_git_dir": request.deployment["authoritative_git_dir"],
|
||||
}
|
||||
)
|
||||
if not deployment_payload["rollback_sha256"]:
|
||||
raise EvidenceProduceError("ROLLBACK_SHA256_REQUIRED")
|
||||
|
||||
from silent_release_hardening import (
|
||||
DeploymentPlan,
|
||||
FinalizationPlan,
|
||||
ReleasePlan,
|
||||
TagPlan,
|
||||
TrustedCandidateBundle,
|
||||
)
|
||||
|
||||
release_plan = ReleasePlan(
|
||||
deployment=DeploymentPlan.from_dict(deployment_payload),
|
||||
tag=TagPlan.from_dict(dict(request.tag)),
|
||||
finalization=FinalizationPlan.from_dict(dict(request.finalization)),
|
||||
policy_sha256=policy_sha,
|
||||
)
|
||||
checks = {name: True for name in config.required_checks}
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
|
||||
return {
|
||||
"schema": EVIDENCE_RECEIPT_SCHEMA,
|
||||
"version": request.version,
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": release_plan.to_dict(),
|
||||
}
|
||||
|
||||
|
||||
def _safe_target(source_root: Path, version: str) -> Path:
|
||||
relative = Path(version)
|
||||
if relative.is_absolute() or any(
|
||||
part in {"", ".", ".."} for part in relative.parts
|
||||
):
|
||||
raise EvidenceProduceError("CLOSURE_PATH_INVALID")
|
||||
candidate = Path(source_root) / version
|
||||
try:
|
||||
metadata = Path(source_root).stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise EvidenceProduceError("SOURCE_ROOT_UNAVAILABLE") from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or Path(source_root).is_symlink():
|
||||
raise EvidenceProduceError("SOURCE_ROOT_INVALID")
|
||||
return candidate
|
||||
|
||||
|
||||
def _write_durable_json(path: Path, payload: Mapping[str, Any], mode: int = 0o600) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
# The deterministic collector compares file digests against the canonical
|
||||
# payload hash (no trailing newline); write the exact canonical bytes so the
|
||||
# producer's closure re-validates under TrustedBuilderEvidenceMaterializer.
|
||||
raw = _canonical_json(payload)
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, raw)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def _write_durable_bytes(path: Path, value: bytes, mode: int = 0o600) -> str:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, value)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, mode)
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def produce_evidence_closure(
|
||||
*,
|
||||
builder_policy: Path,
|
||||
source_root: Path,
|
||||
request: GovernedBuildArtifacts,
|
||||
expected_owner_uid: int | None = 0,
|
||||
) -> ProduceResult:
|
||||
"""Produce a self-consistent, root-owned evidence closure, fail-closed and idempotent."""
|
||||
try:
|
||||
config = TrustedCandidateBuilderConfig.from_policy(
|
||||
load_builder_policy(builder_policy)
|
||||
)
|
||||
except Exception as exc:
|
||||
raise EvidenceProduceError(str(exc)) from exc
|
||||
|
||||
try:
|
||||
version = validate_release_version(request.version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceProduceError("VERSION_INVALID") from exc
|
||||
|
||||
digests = _verify_artifacts(config, request)
|
||||
receipt = _build_receipt(config, request)
|
||||
|
||||
# Resolve layout paths from policy (aligned with evidence_layout; never invented).
|
||||
target = _safe_target(source_root, version)
|
||||
layout_paths = {
|
||||
name: target / Path(template.format(version=version)).relative_to(version)
|
||||
for name, template in config.evidence_layout.items()
|
||||
}
|
||||
|
||||
if target.exists() or target.is_symlink():
|
||||
# Idempotency: an existing identical closure (same receipt digest + same
|
||||
# artifact bytes) is accepted; anything else is a conflict.
|
||||
existing_receipt = target / "receipt.json"
|
||||
if not existing_receipt.is_file():
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
try:
|
||||
existing = json.loads(existing_receipt.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT") from exc
|
||||
if _canonical_json(dict(existing)) != _canonical_json(dict(receipt)):
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
for name, path in layout_paths.items():
|
||||
if not (path.is_file() and _file_sha256(path) == digests[name]):
|
||||
raise EvidenceProduceError("CLOSURE_CONFLICT")
|
||||
return ProduceResult(
|
||||
status="EVIDENCE_PRODUCED",
|
||||
idempotent=True,
|
||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
||||
closure_dir=str(target),
|
||||
evidence_digests=dict(digests),
|
||||
)
|
||||
|
||||
temporary = source_root / (
|
||||
f".{version}.{os.getpid()}.{secrets.token_hex(8)}.prod.tmp"
|
||||
)
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
raise EvidenceProduceError("PRODUCE_TEMP_CONFLICT")
|
||||
temporary.mkdir(mode=0o700)
|
||||
# The closure directory mirrors source_root/<version>/; every written file uses
|
||||
# the policy-declared basename so the layout is aligned with evidence_layout.
|
||||
try:
|
||||
_write_durable_json(temporary / "receipt.json", receipt)
|
||||
_write_durable_bytes(temporary / "artifact.tar.gz", request.artifact)
|
||||
_write_durable_json(temporary / "manifest.json", request.manifest)
|
||||
_write_durable_bytes(temporary / "checksums.sha256", request.package_closure)
|
||||
_write_durable_json(temporary / "trusted-release-policy.json", request.policy)
|
||||
_write_durable_json(temporary / "tests.json", request.tests)
|
||||
_write_durable_json(temporary / "canary.json", request.canary)
|
||||
_write_durable_json(temporary / "gitea.json", request.gitea)
|
||||
_write_durable_json(temporary / "rollback.json", request.rollback)
|
||||
_write_durable_json(temporary / "secret-scan.json", request.secret_scan)
|
||||
os.replace(temporary, target)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return ProduceResult(
|
||||
status="EVIDENCE_PRODUCED",
|
||||
idempotent=False,
|
||||
receipt_sha256=_sha256_bytes(_canonical_json(dict(receipt))),
|
||||
closure_dir=str(target),
|
||||
evidence_digests=dict(digests),
|
||||
)
|
||||
|
||||
|
||||
def producer_operation_definition() -> OperationDefinition:
|
||||
"""Register the governed.release.evidence.produce operation (lookup-only registry).
|
||||
|
||||
This is the *legacy closure assembler* entrypoint. V2R2C2 Round-3 keeps it
|
||||
registered for backward compatibility with the existing runtime chain; the
|
||||
governed production path now resolves
|
||||
``governed_authenticated_evidence_operation.produce_authenticated_evidence_closure``
|
||||
(see ``authenticated_producer_operation_definition``).
|
||||
"""
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "governed.release.evidence.produce",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": "governed_release_evidence_producer.produce_evidence_closure",
|
||||
"input_schema": PRODUCE_SCHEMA,
|
||||
"required_permissions": ["governed.release.evidence.produce"],
|
||||
"timeout_seconds": 60.0,
|
||||
"readback": {"mode": "STATE", "required": True},
|
||||
"retry": {"mode": "NEVER"},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "COMPENSATE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
AUTHENTICATED_EVIDENCE_HANDLER_REF = (
|
||||
"governed_authenticated_evidence_operation.produce_authenticated_evidence_closure"
|
||||
)
|
||||
|
||||
|
||||
def authenticated_producer_operation_definition() -> OperationDefinition:
|
||||
"""Register the governed, provider-authenticated release evidence operation.
|
||||
|
||||
V2R2C2 Round-3 production entrypoint. Its ``handler_ref`` points at the
|
||||
authenticated wrapper, which refuses to invoke the legacy closure assembler
|
||||
until every final-release provider receipt has been resolved from an
|
||||
authenticated store (Gate-W only authenticates tests/Gitea/secret-scan;
|
||||
canary/rollback require Gate-R). See Round-3 Provider Authentication.
|
||||
"""
|
||||
return OperationDefinition.from_dict(
|
||||
{
|
||||
"operation_id": "governed.authenticated.release.evidence.produce",
|
||||
"version": 1,
|
||||
"effect_class": "RECONCILABLE",
|
||||
"handler_ref": AUTHENTICATED_EVIDENCE_HANDLER_REF,
|
||||
"input_schema": PRODUCE_SCHEMA,
|
||||
"required_permissions": ["governed.release.evidence.produce"],
|
||||
"timeout_seconds": 60.0,
|
||||
"readback": {"mode": "STATE", "required": True},
|
||||
"retry": {"mode": "NEVER"},
|
||||
"reconcile": {"mode": "EXACT_READBACK"},
|
||||
"rollback": {"mode": "COMPENSATE"},
|
||||
"owner_boundary": {"mode": "WITHIN_GRANT"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def producer_registry() -> OperationRegistry:
|
||||
"""Union of the legacy assembler and the authenticated production operation."""
|
||||
return OperationRegistry(
|
||||
[producer_operation_definition(), authenticated_producer_operation_definition()]
|
||||
)
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("produce",))
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--artifacts-json", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
try:
|
||||
payload = json.loads(arguments.artifacts_json.read_text(encoding="utf-8"))
|
||||
request = GovernedBuildArtifacts(
|
||||
version=arguments.version,
|
||||
candidate_id=payload["candidate_id"],
|
||||
source_commit=payload["source_commit"],
|
||||
tree_oid=payload["tree_oid"],
|
||||
artifact=bytes.fromhex(payload["artifact_hex"]),
|
||||
manifest=payload["manifest"],
|
||||
package_closure=bytes.fromhex(payload["package_closure_hex"]),
|
||||
policy=payload["policy"],
|
||||
tests=payload["tests"],
|
||||
canary=payload["canary"],
|
||||
gitea=payload["gitea"],
|
||||
rollback=payload["rollback"],
|
||||
secret_scan=payload["secret_scan"],
|
||||
deployment=payload["deployment"],
|
||||
tag=payload["tag"],
|
||||
finalization=payload["finalization"],
|
||||
rollback_sha256=payload.get("rollback_sha256", ""),
|
||||
)
|
||||
result = produce_evidence_closure(
|
||||
builder_policy=arguments.builder_policy,
|
||||
source_root=arguments.source_root,
|
||||
request=request,
|
||||
)
|
||||
except (EvidenceProduceError, KeyError, ValueError, OSError, json.JSONDecodeError) as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
from dataclasses import asdict
|
||||
|
||||
print(json.dumps(asdict(result), sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
Executable
+297
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thin production tag adapter over AIOS's existing mature TagPublisher.
|
||||
|
||||
This adapter deliberately does not call TagPublisher.publish(), because that
|
||||
legacy method also mutates Registry/Baseline. The production executor requires:
|
||||
Tag -> runtime ACTIVE -> Registry -> Baseline.
|
||||
|
||||
`apply` reuses TagPublisher._verify_publication() under the publisher's durable
|
||||
per-version lock. That method performs the existing fail-closed annotated-tag,
|
||||
network remote, Gitea API, peeled-target and listing verification, but stops
|
||||
before Registry/Baseline mutation.
|
||||
|
||||
`probe` is read-only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from gitea_tag_publication import (
|
||||
GiteaTagClient,
|
||||
GitTagBackend,
|
||||
PublicationConflict,
|
||||
PublicationManualOwnerReviewRequired,
|
||||
PublicationPending,
|
||||
PublicationReconciliationRequired,
|
||||
PublicationRequest,
|
||||
PublicationState,
|
||||
TagPublisher,
|
||||
)
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def load_request() -> dict[str, Any]:
|
||||
value = json.load(sys.stdin)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("request_object_required")
|
||||
if value.get("schema") != "aios.production-executor-request.v1":
|
||||
raise ValueError("request_schema_invalid")
|
||||
try:
|
||||
validate_release_version(value.get("version", ""))
|
||||
except ValueError as exc:
|
||||
raise ValueError("version_invalid") from exc
|
||||
for name in ("target_oid", "evidence_closure_oid"):
|
||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||
raise ValueError(f"{name}_invalid")
|
||||
if not PLAN_RE.fullmatch(str(value.get("approval_plan_id", ""))):
|
||||
raise ValueError("approval_plan_id_invalid")
|
||||
if value.get("repository_identity") != "aios/aios":
|
||||
raise ValueError("repository_identity_invalid")
|
||||
return value
|
||||
|
||||
|
||||
def publication_request(value: Mapping[str, Any]) -> PublicationRequest:
|
||||
return PublicationRequest(
|
||||
str(value["version"]),
|
||||
str(value["target_oid"]),
|
||||
str(value["evidence_closure_oid"]),
|
||||
str(value["approval_plan_id"]),
|
||||
)
|
||||
|
||||
|
||||
def response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"version": value["version"],
|
||||
"target_oid": value["target_oid"],
|
||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
||||
"artifact_sha256": value["artifact_sha256"],
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def build(args: argparse.Namespace) -> tuple[TagPublisher, GitTagBackend, GiteaTagClient]:
|
||||
git = GitTagBackend(args.workspace, authoritative_repo=args.repository)
|
||||
gitea = GiteaTagClient(
|
||||
args.gitea_api,
|
||||
args.gitea_repo,
|
||||
args.gitea_token_file,
|
||||
token_line=args.gitea_token_line,
|
||||
)
|
||||
publisher = TagPublisher(
|
||||
args.workspace,
|
||||
git,
|
||||
gitea,
|
||||
args.remote,
|
||||
state_root=args.state_root,
|
||||
)
|
||||
return publisher, git, gitea
|
||||
|
||||
|
||||
def _gitea_lookup(gitea: GiteaTagClient, version: str) -> tuple[str, str] | None:
|
||||
try:
|
||||
return gitea.lookup_tag(version)
|
||||
except RuntimeError as exc:
|
||||
if "HTTP 404" in str(exc):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def probe(
|
||||
args: argparse.Namespace,
|
||||
value: Mapping[str, Any],
|
||||
publisher: TagPublisher,
|
||||
git: GitTagBackend,
|
||||
gitea: GiteaTagClient,
|
||||
) -> dict[str, Any]:
|
||||
request = publication_request(value)
|
||||
request.validate()
|
||||
git.validate_evidence_lineage(request)
|
||||
|
||||
local_oid = git._rev(f"refs/tags/{request.version}")
|
||||
local_target = git._rev(f"refs/tags/{request.version}^{{commit}}")
|
||||
remote = git.remote_tag(args.remote, request.version)
|
||||
api = _gitea_lookup(gitea, request.version)
|
||||
listing = False
|
||||
if api is not None:
|
||||
listing = gitea.tag_visible_in_listing(request.version, request.target_oid)
|
||||
|
||||
if local_oid is None and remote is None and api is None:
|
||||
return response(value, "NOT_APPLIED")
|
||||
|
||||
if local_oid is not None:
|
||||
if local_target is None or local_oid == local_target:
|
||||
return response(value, "CONFLICT", reason="local_tag_not_annotated")
|
||||
if local_target != request.target_oid:
|
||||
return response(
|
||||
value,
|
||||
"CONFLICT",
|
||||
reason="local_peeled_target_mismatch",
|
||||
tag_oid=local_oid,
|
||||
peeled_target_oid=local_target,
|
||||
)
|
||||
|
||||
# A remote/Gitea tag without the Authority tag object is not safe to
|
||||
# recreate, because a new annotated tag would have a different object OID.
|
||||
if local_oid is None and (remote is not None or api is not None):
|
||||
return response(
|
||||
value,
|
||||
"INDETERMINATE",
|
||||
reason="remote_or_gitea_tag_exists_without_authority_tag_object",
|
||||
)
|
||||
|
||||
if remote is not None:
|
||||
if remote[0] != local_oid or remote[1] != request.target_oid:
|
||||
return response(
|
||||
value,
|
||||
"CONFLICT",
|
||||
reason="network_remote_tag_mismatch",
|
||||
tag_oid=local_oid,
|
||||
peeled_target_oid=local_target,
|
||||
remote_tag_oid=remote[0],
|
||||
remote_peeled_target_oid=remote[1],
|
||||
)
|
||||
|
||||
if api is not None:
|
||||
if api[0] != local_oid or api[1] != request.target_oid:
|
||||
return response(
|
||||
value,
|
||||
"CONFLICT",
|
||||
reason="gitea_api_tag_mismatch",
|
||||
tag_oid=local_oid,
|
||||
peeled_target_oid=local_target,
|
||||
gitea_tag_oid=api[0],
|
||||
gitea_peeled_target_oid=api[1],
|
||||
)
|
||||
|
||||
if (
|
||||
local_oid is not None
|
||||
and remote is not None
|
||||
and api is not None
|
||||
and listing
|
||||
):
|
||||
return response(
|
||||
value,
|
||||
"APPLIED_VERIFIED",
|
||||
tag_oid=local_oid,
|
||||
peeled_target_oid=local_target,
|
||||
remote_tag_oid=remote[0],
|
||||
remote_peeled_target_oid=remote[1],
|
||||
gitea_tag_oid=api[0],
|
||||
gitea_peeled_target_oid=api[1],
|
||||
gitea_listing_visible=True,
|
||||
)
|
||||
|
||||
if local_oid is not None and remote is None and api is None:
|
||||
return response(
|
||||
value,
|
||||
"PARTIAL_LOCAL",
|
||||
tag_oid=local_oid,
|
||||
peeled_target_oid=local_target,
|
||||
)
|
||||
|
||||
return response(
|
||||
value,
|
||||
"INDETERMINATE",
|
||||
reason="tag_visibility_partial",
|
||||
tag_oid=local_oid,
|
||||
peeled_target_oid=local_target,
|
||||
)
|
||||
|
||||
|
||||
def apply(
|
||||
args: argparse.Namespace,
|
||||
value: Mapping[str, Any],
|
||||
publisher: TagPublisher,
|
||||
git: GitTagBackend,
|
||||
gitea: GiteaTagClient,
|
||||
) -> dict[str, Any]:
|
||||
before = probe(args, value, publisher, git, gitea)
|
||||
if before["status"] in {"APPLIED_VERIFIED", "CONFLICT", "INDETERMINATE"}:
|
||||
return before
|
||||
|
||||
request = publication_request(value)
|
||||
with publisher.store.lock(request.version):
|
||||
request.validate()
|
||||
publisher._assert_fingerprint(request)
|
||||
publisher.store.record(request, PublicationState.PENDING)
|
||||
publisher._verify_publication(
|
||||
request,
|
||||
allow_push=True,
|
||||
previously_deployed=False,
|
||||
)
|
||||
return probe(args, value, publisher, git, gitea)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--workspace", type=Path, default=Path(os.environ["AIOS_GITOPS_CONTROLLER_WORKSPACE"]))
|
||||
parser.add_argument("--repository", type=Path, default=Path(os.environ["AIOS_GITOPS_AUTHORITATIVE_GIT_DIR"]))
|
||||
parser.add_argument("--remote", default=os.getenv("AIOS_GITOPS_GITEA_REMOTE", "gitea"))
|
||||
parser.add_argument("--gitea-api", default=os.getenv("AIOS_GITOPS_GITEA_API_URL", ""))
|
||||
parser.add_argument("--gitea-repo", default=os.getenv("AIOS_GITOPS_GITEA_REPOSITORY", ""))
|
||||
parser.add_argument(
|
||||
"--gitea-token-file",
|
||||
type=Path,
|
||||
default=Path(os.getenv("AIOS_GITOPS_GITEA_TOKEN_FILE", "")),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gitea-token-line",
|
||||
type=int,
|
||||
default=int(os.getenv("AIOS_GITOPS_GITEA_TOKEN_LINE", "1")),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--state-root",
|
||||
type=Path,
|
||||
default=Path(
|
||||
os.getenv(
|
||||
"AIOS_GITOPS_PUBLICATION_STATE_DIR",
|
||||
"/var/lib/aios-gitops/tag-publications",
|
||||
)
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
value = load_request()
|
||||
publisher, git, gitea = build(args)
|
||||
operation = value.get("operation")
|
||||
if operation == "probe":
|
||||
result = probe(args, value, publisher, git, gitea)
|
||||
elif operation == "apply":
|
||||
result = apply(args, value, publisher, git, gitea)
|
||||
else:
|
||||
raise ValueError("unsupported_operation")
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except PublicationManualOwnerReviewRequired as exc:
|
||||
print(json.dumps({"status": "CONFLICT", "reason": str(exc)}, sort_keys=True))
|
||||
raise SystemExit(20)
|
||||
except PublicationConflict as exc:
|
||||
print(json.dumps({"status": "CONFLICT", "reason": str(exc)}, sort_keys=True))
|
||||
raise SystemExit(20)
|
||||
except (PublicationPending, PublicationReconciliationRequired) as exc:
|
||||
print(json.dumps({"status": "INDETERMINATE", "reason": str(exc)}, sort_keys=True))
|
||||
raise SystemExit(21)
|
||||
except Exception as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "INDETERMINATE", "error": type(exc).__name__, "reason": str(exc)},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
raise SystemExit(22)
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot local Owner authentication ingress for AIOS.
|
||||
|
||||
This module deliberately does not mint a reusable Owner token.
|
||||
|
||||
Authentication is based on a real local login session:
|
||||
- a root-run one-shot server owns a Unix socket;
|
||||
- the client must run as the configured Owner OS account;
|
||||
- the server validates SO_PEERCRED, Linux audit loginuid and the controlling TTY;
|
||||
- the Owner must interactively confirm the exact task/branch/base request;
|
||||
- the server constructs a narrow OwnerPrincipal and invokes the existing
|
||||
OwnerAuthorizationBroker;
|
||||
- one successful authorization consumes the service lifetime.
|
||||
|
||||
The resulting principal is fixed to one non-wildcard intake scope
|
||||
(`commit:create` for the current deployment) and cannot carry production,
|
||||
tag, baseline, main or history-rewrite capability.
|
||||
|
||||
This is a trust-root ingress, not a Git writer and not a production executor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
BINDING_SCHEMA = "aios.local-owner-auth-ingress.v1"
|
||||
CLIENT_BEGIN_SCHEMA = "aios.local-owner-auth-client-begin.v1"
|
||||
CHALLENGE_SCHEMA = "aios.local-owner-auth-challenge.v1"
|
||||
CLIENT_CONFIRM_SCHEMA = "aios.local-owner-auth-client-confirm.v1"
|
||||
RESULT_SCHEMA = "aios.local-owner-auth-result.v1"
|
||||
|
||||
ACTION = "AUTHORIZE_TASK_WRITER_PROVISIONING"
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@:+/-]{1,254}$")
|
||||
SCOPE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,255}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
||||
|
||||
MAX_FRAME = 64 * 1024
|
||||
CHALLENGE_TTL_SECONDS = 120
|
||||
|
||||
EXPECTED_ALLOWED_OPERATIONS = frozenset(
|
||||
{
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch",
|
||||
}
|
||||
)
|
||||
EXPECTED_DENIED_OPERATIONS = frozenset(
|
||||
{
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite",
|
||||
}
|
||||
)
|
||||
|
||||
SENSITIVE_FRAGMENTS = (
|
||||
"authorization_header",
|
||||
"bearer",
|
||||
"client_secret",
|
||||
"password",
|
||||
"private_key",
|
||||
"raw_secret",
|
||||
"raw_token",
|
||||
"secret_value",
|
||||
)
|
||||
|
||||
|
||||
class BootstrapError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 1) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.parent.is_symlink() or path.is_symlink():
|
||||
raise BootstrapError("STATE_SYMLINK_REJECTED")
|
||||
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(canonical_json(dict(value)))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
os.chmod(path, mode)
|
||||
dfd = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def secure_root_file(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise BootstrapError("BINDING_FILE_INVALID")
|
||||
st = path.lstat()
|
||||
if st.st_uid != 0:
|
||||
raise BootstrapError("BINDING_FILE_ROOT_OWNER_REQUIRED")
|
||||
if stat.S_IMODE(st.st_mode) & 0o077:
|
||||
raise BootstrapError("BINDING_FILE_PERMISSIONS_INVALID")
|
||||
if st.st_size <= 0 or st.st_size > 1_048_576:
|
||||
raise BootstrapError("BINDING_FILE_SIZE_INVALID")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BootstrapError("BINDING_JSON_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise BootstrapError("BINDING_JSON_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def validate_binding(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if set(value) != {
|
||||
"schema",
|
||||
"owner_identity",
|
||||
"owner_role",
|
||||
"authenticated_scope",
|
||||
"request",
|
||||
}:
|
||||
raise BootstrapError("BINDING_FIELDS_REJECTED")
|
||||
if value["schema"] != BINDING_SCHEMA:
|
||||
raise BootstrapError("BINDING_SCHEMA_REJECTED")
|
||||
identity = value["owner_identity"]
|
||||
if not isinstance(identity, str) or not IDENTITY_RE.fullmatch(identity):
|
||||
raise BootstrapError("OWNER_IDENTITY_INVALID")
|
||||
if value["owner_role"] != "owner":
|
||||
raise BootstrapError("OWNER_ROLE_INVALID")
|
||||
scope = value["authenticated_scope"]
|
||||
if (
|
||||
not isinstance(scope, str)
|
||||
or not SCOPE_RE.fullmatch(scope)
|
||||
or scope == "writer:provision"
|
||||
or scope == "*"
|
||||
or scope.endswith(":*")
|
||||
):
|
||||
raise BootstrapError("OWNER_SCOPE_INVALID")
|
||||
|
||||
request = value["request"]
|
||||
if not isinstance(request, dict):
|
||||
raise BootstrapError("REQUEST_INVALID")
|
||||
expected_fields = {
|
||||
"action",
|
||||
"task_id",
|
||||
"repository",
|
||||
"base_ref",
|
||||
"base_oid",
|
||||
"target_branch",
|
||||
"one_time_use",
|
||||
"expires_in_seconds",
|
||||
"production_change_authorized",
|
||||
"tag_change_authorized",
|
||||
"baseline_change_authorized",
|
||||
"main_write_authorized",
|
||||
"allowed_operations",
|
||||
"denied_operations",
|
||||
}
|
||||
if set(request) != expected_fields:
|
||||
raise BootstrapError("REQUEST_FIELDS_REJECTED")
|
||||
if request["action"] != ACTION:
|
||||
raise BootstrapError("REQUEST_ACTION_REJECTED")
|
||||
if request["repository"] != "aios/aios":
|
||||
raise BootstrapError("REPOSITORY_REJECTED")
|
||||
if request["base_ref"] != "refs/heads/main":
|
||||
raise BootstrapError("BASE_REF_REJECTED")
|
||||
if not isinstance(request["base_oid"], str) or not OID_RE.fullmatch(request["base_oid"]):
|
||||
raise BootstrapError("BASE_OID_REJECTED")
|
||||
if (
|
||||
not isinstance(request["target_branch"], str)
|
||||
or not BRANCH_RE.fullmatch(request["target_branch"])
|
||||
):
|
||||
raise BootstrapError("TARGET_BRANCH_REJECTED")
|
||||
if (
|
||||
not isinstance(request["task_id"], str)
|
||||
or not TASK_RE.fullmatch(request["task_id"])
|
||||
):
|
||||
raise BootstrapError("TASK_ID_REJECTED")
|
||||
if request["one_time_use"] is not True:
|
||||
raise BootstrapError("ONE_TIME_USE_REQUIRED")
|
||||
expiry = request["expires_in_seconds"]
|
||||
if (
|
||||
not isinstance(expiry, int)
|
||||
or isinstance(expiry, bool)
|
||||
or not 60 <= expiry <= 900
|
||||
):
|
||||
raise BootstrapError("EXPIRY_REJECTED")
|
||||
for field in (
|
||||
"production_change_authorized",
|
||||
"tag_change_authorized",
|
||||
"baseline_change_authorized",
|
||||
"main_write_authorized",
|
||||
):
|
||||
if request[field] is not False:
|
||||
raise BootstrapError("CONSEQUENTIAL_CAPABILITY_REJECTED")
|
||||
if (
|
||||
not isinstance(request["allowed_operations"], list)
|
||||
or frozenset(request["allowed_operations"]) != EXPECTED_ALLOWED_OPERATIONS
|
||||
):
|
||||
raise BootstrapError("ALLOWED_OPERATIONS_MISMATCH")
|
||||
if (
|
||||
not isinstance(request["denied_operations"], list)
|
||||
or frozenset(request["denied_operations"]) != EXPECTED_DENIED_OPERATIONS
|
||||
):
|
||||
raise BootstrapError("DENIED_OPERATIONS_MISMATCH")
|
||||
return {
|
||||
"schema": BINDING_SCHEMA,
|
||||
"owner_identity": identity,
|
||||
"owner_role": "owner",
|
||||
"authenticated_scope": scope,
|
||||
"request": dict(request),
|
||||
}
|
||||
|
||||
|
||||
def _status_uids(pid: int) -> tuple[int, int, int, int]:
|
||||
try:
|
||||
lines = Path(f"/proc/{pid}/status").read_text(encoding="utf-8").splitlines()
|
||||
except OSError as exc:
|
||||
raise BootstrapError("PEER_STATUS_UNAVAILABLE") from exc
|
||||
for line in lines:
|
||||
if line.startswith("Uid:"):
|
||||
fields = line.split()[1:]
|
||||
if len(fields) != 4:
|
||||
break
|
||||
return tuple(int(item) for item in fields) # type: ignore[return-value]
|
||||
raise BootstrapError("PEER_UID_STATUS_INVALID")
|
||||
|
||||
|
||||
def _loginuid(pid: int) -> int:
|
||||
try:
|
||||
raw = Path(f"/proc/{pid}/loginuid").read_text(encoding="ascii").strip()
|
||||
value = int(raw)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise BootstrapError("PEER_LOGINUID_UNAVAILABLE") from exc
|
||||
if value == 4294967295:
|
||||
raise BootstrapError("PEER_LOGIN_SESSION_REQUIRED")
|
||||
return value
|
||||
|
||||
|
||||
def _tty_path(pid: int) -> Path:
|
||||
try:
|
||||
value = os.readlink(f"/proc/{pid}/fd/0")
|
||||
except OSError as exc:
|
||||
raise BootstrapError("PEER_INTERACTIVE_TTY_REQUIRED") from exc
|
||||
if not (value.startswith("/dev/pts/") or value.startswith("/dev/tty")):
|
||||
raise BootstrapError("PEER_INTERACTIVE_TTY_REQUIRED")
|
||||
path = Path(value)
|
||||
try:
|
||||
if not stat.S_ISCHR(path.stat().st_mode):
|
||||
raise BootstrapError("PEER_INTERACTIVE_TTY_REQUIRED")
|
||||
except OSError as exc:
|
||||
raise BootstrapError("PEER_INTERACTIVE_TTY_REQUIRED") from exc
|
||||
return path
|
||||
|
||||
|
||||
def verify_peer_session(pid: int, uid: int, gid: int, owner_identity: str) -> dict[str, Any]:
|
||||
try:
|
||||
owner = pwd.getpwnam(owner_identity)
|
||||
except KeyError as exc:
|
||||
raise BootstrapError("OWNER_OS_ACCOUNT_MISSING") from exc
|
||||
if uid != owner.pw_uid:
|
||||
raise BootstrapError("PEER_OWNER_UID_MISMATCH")
|
||||
uids = _status_uids(pid)
|
||||
if any(item != owner.pw_uid for item in uids):
|
||||
raise BootstrapError("PEER_UID_TRANSITION_REJECTED")
|
||||
loginuid = _loginuid(pid)
|
||||
if loginuid != owner.pw_uid:
|
||||
# This deliberately rejects sudo/runuser from another authenticated
|
||||
# account; the session itself must have logged in as the Owner.
|
||||
raise BootstrapError("PEER_LOGINUID_OWNER_MISMATCH")
|
||||
tty = _tty_path(pid)
|
||||
if tty.stat().st_uid != owner.pw_uid:
|
||||
raise BootstrapError("PEER_TTY_OWNER_MISMATCH")
|
||||
return {
|
||||
"pid": pid,
|
||||
"uid": uid,
|
||||
"gid": gid,
|
||||
"loginuid": loginuid,
|
||||
"tty": str(tty),
|
||||
"owner_identity": owner_identity,
|
||||
}
|
||||
|
||||
|
||||
def read_frame(connection: socket.socket) -> dict[str, Any]:
|
||||
data = bytearray()
|
||||
while len(data) <= MAX_FRAME:
|
||||
block = connection.recv(4096)
|
||||
if not block:
|
||||
break
|
||||
data.extend(block)
|
||||
if b"\n" in block:
|
||||
break
|
||||
if not data or len(data) > MAX_FRAME:
|
||||
raise BootstrapError("PROTOCOL_FRAME_INVALID")
|
||||
raw = bytes(data).split(b"\n", 1)[0]
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BootstrapError("PROTOCOL_JSON_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise BootstrapError("PROTOCOL_JSON_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def write_frame(connection: socket.socket, value: Mapping[str, Any]) -> None:
|
||||
connection.sendall(canonical_json(dict(value)))
|
||||
|
||||
|
||||
def safe_result(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
for key in value:
|
||||
lowered = str(key).lower()
|
||||
if any(fragment in lowered for fragment in SENSITIVE_FRAGMENTS):
|
||||
raise BootstrapError("BROKER_RESPONSE_SENSITIVE_DATA_REJECTED")
|
||||
allowed = {
|
||||
"status",
|
||||
"approval_plan_id",
|
||||
"grant_id",
|
||||
"grant_digest_sha256",
|
||||
"branch_oid",
|
||||
"branch_ref_created",
|
||||
"grant_consumed",
|
||||
"owner_authorization_event_id",
|
||||
"owner_authorization_reference",
|
||||
"owner_authorization_digest_sha256",
|
||||
"owner_authorization_consumed",
|
||||
"owner_authorization_idempotent",
|
||||
}
|
||||
return {key: value.get(key) for key in sorted(allowed) if key in value}
|
||||
|
||||
|
||||
def request_summary(binding: Mapping[str, Any]) -> dict[str, Any]:
|
||||
request = binding["request"]
|
||||
return {
|
||||
"owner_identity": binding["owner_identity"],
|
||||
"authenticated_scope": binding["authenticated_scope"],
|
||||
"task_id": request["task_id"],
|
||||
"repository": request["repository"],
|
||||
"base_ref": request["base_ref"],
|
||||
"base_oid": request["base_oid"],
|
||||
"target_branch": request["target_branch"],
|
||||
"expires_in_seconds": request["expires_in_seconds"],
|
||||
"allowed_operations": sorted(request["allowed_operations"]),
|
||||
"denied_operations": sorted(request["denied_operations"]),
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"main_write_authorized": False,
|
||||
}
|
||||
|
||||
|
||||
def _ensure_runtime_paths(socket_path: Path, state_root: Path, owner_identity: str) -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise BootstrapError("SERVER_ROOT_REQUIRED")
|
||||
owner = pwd.getpwnam(owner_identity)
|
||||
for directory, mode, uid, gid in (
|
||||
(socket_path.parent, 0o750, 0, owner.pw_gid),
|
||||
(state_root, 0o700, 0, 0),
|
||||
):
|
||||
if directory.is_symlink():
|
||||
raise BootstrapError("RUNTIME_DIRECTORY_SYMLINK_REJECTED")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
os.chown(directory, uid, gid)
|
||||
os.chmod(directory, mode)
|
||||
if socket_path.exists() or socket_path.is_symlink():
|
||||
raise BootstrapError("SOCKET_ALREADY_EXISTS")
|
||||
|
||||
|
||||
def _lock(path: Path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
||||
os.fchmod(fd, 0o600)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return fd
|
||||
|
||||
|
||||
def invoke_broker(binding: Mapping[str, Any]) -> dict[str, Any]:
|
||||
# Import only on the root server side. The bootstrap package does not carry
|
||||
# a second broker/provisioner implementation; it reuses current main.
|
||||
from owner_authorization_broker import ( # type: ignore
|
||||
OwnerAuthorizationBroker,
|
||||
OwnerAuthorizationBrokerConfig,
|
||||
OwnerPrincipal,
|
||||
)
|
||||
|
||||
config = OwnerAuthorizationBrokerConfig.from_environment()
|
||||
identity = str(binding["owner_identity"])
|
||||
scope = str(binding["authenticated_scope"])
|
||||
if identity not in config.allowed_owner_identities:
|
||||
raise BootstrapError("OWNER_IDENTITY_NOT_ALLOWED_BY_LIVE_BROKER")
|
||||
if scope not in config.allowed_intake_scopes:
|
||||
raise BootstrapError("OWNER_SCOPE_NOT_ALLOWED_BY_LIVE_BROKER")
|
||||
if config.max_expiry_seconds < int(binding["request"]["expires_in_seconds"]):
|
||||
raise BootstrapError("LIVE_BROKER_EXPIRY_TOO_SMALL")
|
||||
principal = OwnerPrincipal(
|
||||
identity=identity,
|
||||
role="owner",
|
||||
authenticated_scope=scope,
|
||||
token_scopes=frozenset({scope}),
|
||||
credential_id="",
|
||||
)
|
||||
return OwnerAuthorizationBroker(config=config).authorize_and_provision(
|
||||
binding["request"], principal
|
||||
)
|
||||
|
||||
|
||||
def serve(binding_path: Path, socket_path: Path, state_root: Path, timeout: int) -> int:
|
||||
binding = validate_binding(secure_root_file(binding_path))
|
||||
identity = str(binding["owner_identity"])
|
||||
_ensure_runtime_paths(socket_path, state_root, identity)
|
||||
lock_fd = _lock(state_root / "bootstrap.lock")
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
listener.bind(str(socket_path))
|
||||
owner = pwd.getpwnam(identity)
|
||||
os.chown(socket_path, 0, owner.pw_gid)
|
||||
os.chmod(socket_path, 0o660)
|
||||
listener.listen(1)
|
||||
listener.settimeout(timeout)
|
||||
|
||||
connection, _ = listener.accept()
|
||||
with connection:
|
||||
raw_peer = connection.getsockopt(
|
||||
socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")
|
||||
)
|
||||
pid, uid, gid = struct.unpack("3i", raw_peer)
|
||||
session = verify_peer_session(pid, uid, gid, identity)
|
||||
|
||||
begin = read_frame(connection)
|
||||
if begin != {"schema": CLIENT_BEGIN_SCHEMA, "action": "BEGIN"}:
|
||||
raise BootstrapError("CLIENT_BEGIN_REJECTED")
|
||||
|
||||
challenge = secrets.token_hex(12)
|
||||
challenge_issued = time.monotonic()
|
||||
summary = request_summary(binding)
|
||||
write_frame(
|
||||
connection,
|
||||
{
|
||||
"schema": CHALLENGE_SCHEMA,
|
||||
"challenge": challenge,
|
||||
"expires_in_seconds": CHALLENGE_TTL_SECONDS,
|
||||
"request_digest_sha256": sha256_json(binding["request"]),
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
|
||||
confirm = read_frame(connection)
|
||||
expected_confirmation = f"AUTHORIZE {challenge}"
|
||||
if (
|
||||
confirm.get("schema") != CLIENT_CONFIRM_SCHEMA
|
||||
or confirm.get("challenge") != challenge
|
||||
or confirm.get("confirmation") != expected_confirmation
|
||||
):
|
||||
raise BootstrapError("OWNER_INTERACTIVE_CONFIRMATION_REJECTED")
|
||||
if time.monotonic() - challenge_issued > CHALLENGE_TTL_SECONDS:
|
||||
raise BootstrapError("OWNER_CONFIRMATION_EXPIRED")
|
||||
|
||||
result = safe_result(invoke_broker(binding))
|
||||
receipt = {
|
||||
"schema": "aios.local-owner-auth-ingress-receipt.v1",
|
||||
"status": "CONSUMED",
|
||||
"owner_identity": identity,
|
||||
"owner_role": "owner",
|
||||
"authenticated_scope": binding["authenticated_scope"],
|
||||
"peer": session,
|
||||
"request_digest_sha256": sha256_json(binding["request"]),
|
||||
"binding_digest_sha256": sha256_json(binding),
|
||||
"challenge_digest_sha256": hashlib.sha256(
|
||||
challenge.encode("ascii")
|
||||
).hexdigest(),
|
||||
"broker": result,
|
||||
"completed_at_unix": int(time.time()),
|
||||
}
|
||||
event_id = str(
|
||||
result.get("owner_authorization_event_id")
|
||||
or hashlib.sha256(
|
||||
canonical_json(
|
||||
{
|
||||
"peer": session,
|
||||
"request": sha256_json(binding["request"]),
|
||||
}
|
||||
)
|
||||
).hexdigest()[:32]
|
||||
)
|
||||
atomic_json(state_root / "events" / f"{event_id}.json", receipt)
|
||||
write_frame(
|
||||
connection,
|
||||
{
|
||||
"schema": RESULT_SCHEMA,
|
||||
"ok": True,
|
||||
"result": result,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
except (BootstrapError, TimeoutError, socket.timeout) as exc:
|
||||
code = exc.code if isinstance(exc, BootstrapError) else "OWNER_BOOTSTRAP_TIMEOUT"
|
||||
try:
|
||||
if "connection" in locals():
|
||||
write_frame(
|
||||
connection,
|
||||
{
|
||||
"schema": RESULT_SCHEMA,
|
||||
"ok": False,
|
||||
"error": code,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
atomic_json(
|
||||
state_root / "last-failure.json",
|
||||
{
|
||||
"schema": "aios.local-owner-auth-ingress-failure.v1",
|
||||
"status": "FAILED",
|
||||
"error": code,
|
||||
"binding_digest_sha256": sha256_json(binding),
|
||||
"failed_at_unix": int(time.time()),
|
||||
},
|
||||
)
|
||||
return 2
|
||||
finally:
|
||||
listener.close()
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
def client(socket_path: Path) -> int:
|
||||
# The server performs the authoritative peer/session checks. The client
|
||||
# also refuses non-interactive execution to make accidental automation hard.
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
raise BootstrapError("CLIENT_INTERACTIVE_TTY_REQUIRED")
|
||||
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
connection.connect(str(socket_path))
|
||||
with connection:
|
||||
write_frame(connection, {"schema": CLIENT_BEGIN_SCHEMA, "action": "BEGIN"})
|
||||
challenge = read_frame(connection)
|
||||
if challenge.get("schema") != CHALLENGE_SCHEMA:
|
||||
raise BootstrapError("CHALLENGE_INVALID")
|
||||
summary = challenge.get("summary")
|
||||
if not isinstance(summary, dict):
|
||||
raise BootstrapError("CHALLENGE_INVALID")
|
||||
|
||||
print("\nAIOS one-shot Owner authorization request:")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
print("\nThis authorizes only the task writer shown above.")
|
||||
phrase = f"AUTHORIZE {challenge['challenge']}"
|
||||
typed = input(f"Type exactly:\n{phrase}\n> ").strip()
|
||||
if typed != phrase:
|
||||
raise BootstrapError("OWNER_CONFIRMATION_MISMATCH")
|
||||
write_frame(
|
||||
connection,
|
||||
{
|
||||
"schema": CLIENT_CONFIRM_SCHEMA,
|
||||
"challenge": challenge["challenge"],
|
||||
"confirmation": typed,
|
||||
},
|
||||
)
|
||||
result = read_frame(connection)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0 if result.get("ok") is True else 3
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser()
|
||||
mode = value.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--serve", action="store_true")
|
||||
mode.add_argument("--client", action="store_true")
|
||||
value.add_argument("--binding", type=Path)
|
||||
value.add_argument(
|
||||
"--socket",
|
||||
type=Path,
|
||||
default=Path("/run/aios-owner-bootstrap/owner.sock"),
|
||||
)
|
||||
value.add_argument(
|
||||
"--state-root",
|
||||
type=Path,
|
||||
default=Path("/var/lib/aios-gitops/owner-local-auth-ingress"),
|
||||
)
|
||||
value.add_argument("--timeout", type=int, default=300)
|
||||
return value
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parser().parse_args(argv)
|
||||
if args.serve:
|
||||
if args.binding is None:
|
||||
raise SystemExit("--binding is required with --serve")
|
||||
return serve(args.binding, args.socket, args.state_root, args.timeout)
|
||||
return client(args.socket)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except BootstrapError as exc:
|
||||
print(json.dumps({"status": "BLOCKED", "error": exc.code}), file=sys.stderr)
|
||||
raise SystemExit(exc.status)
|
||||
@@ -25,8 +25,17 @@ class ReconciliationController:
|
||||
def reconcile(self, actual: Mapping[str, Any]) -> ReconciliationReport:
|
||||
drift: list[str] = []
|
||||
for left, right, label in self.CHECKS:
|
||||
if actual.get(left) != actual.get(right):
|
||||
left_value = actual.get(left)
|
||||
right_value = actual.get(right)
|
||||
expected_type = bool if left == "declared_production_state" else str
|
||||
complete = (
|
||||
left in actual
|
||||
and right in actual
|
||||
and isinstance(left_value, expected_type)
|
||||
and isinstance(right_value, expected_type)
|
||||
and (expected_type is bool or bool(left_value.strip()) and bool(right_value.strip()))
|
||||
)
|
||||
if not complete or left_value != right_value:
|
||||
drift.append(label)
|
||||
actions = () if not drift else ("open_reconciliation_task", "open_reconciliation_pr", "attach_evidence", "notify_owner")
|
||||
return ReconciliationReport(not drift, tuple(drift), actions)
|
||||
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed, append-only reopen of one expired Owner approval cycle."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
REQUEST_SCHEMA = "aios.owner-approval-reopen-request.v1"
|
||||
EVENT_NAME = "owner_approval_reopen_authorized"
|
||||
_IDEMPOTENCY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ApprovalReopenError(RuntimeError):
|
||||
def __init__(self, code: str, detail: str | None = None):
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
super().__init__(code if not detail else f"{code}:{detail}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReopenRequest:
|
||||
candidate_id: str
|
||||
expected_approval_fingerprint: str
|
||||
expected_source_commit: str
|
||||
expected_tree_oid: str
|
||||
expected_artifact_sha256: str
|
||||
expected_manifest_sha256: str
|
||||
expected_release_plan_sha256: str
|
||||
expected_policy_sha256: str
|
||||
reason: str
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: Mapping[str, Any]) -> "ReopenRequest":
|
||||
if raw.get("schema") != REQUEST_SCHEMA:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SCHEMA_INVALID")
|
||||
allowed = {
|
||||
"schema", "candidate_id", "expected_approval_fingerprint",
|
||||
"expected_source_commit", "expected_tree_oid",
|
||||
"expected_artifact_sha256", "expected_manifest_sha256",
|
||||
"expected_release_plan_sha256", "expected_policy_sha256",
|
||||
"reason", "idempotency_key",
|
||||
}
|
||||
if set(raw) != allowed:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_REQUEST_FIELDS_INVALID")
|
||||
req = cls(
|
||||
candidate_id=str(raw["candidate_id"]),
|
||||
expected_approval_fingerprint=str(raw["expected_approval_fingerprint"]),
|
||||
expected_source_commit=str(raw["expected_source_commit"]),
|
||||
expected_tree_oid=str(raw["expected_tree_oid"]),
|
||||
expected_artifact_sha256=str(raw["expected_artifact_sha256"]),
|
||||
expected_manifest_sha256=str(raw["expected_manifest_sha256"]),
|
||||
expected_release_plan_sha256=str(raw["expected_release_plan_sha256"]),
|
||||
expected_policy_sha256=str(raw["expected_policy_sha256"]),
|
||||
reason=str(raw["reason"]),
|
||||
idempotency_key=str(raw["idempotency_key"]),
|
||||
)
|
||||
if not req.candidate_id or "/" in req.candidate_id or ".." in req.candidate_id:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_ID_INVALID")
|
||||
if not _HEX64.fullmatch(req.expected_approval_fingerprint):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_FINGERPRINT_INVALID")
|
||||
if not _HEX40.fullmatch(req.expected_source_commit):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SOURCE_INVALID")
|
||||
if not _HEX40.fullmatch(req.expected_tree_oid):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_TREE_INVALID")
|
||||
for value, code in (
|
||||
(req.expected_artifact_sha256, "APPROVAL_REOPEN_ARTIFACT_INVALID"),
|
||||
(req.expected_manifest_sha256, "APPROVAL_REOPEN_MANIFEST_INVALID"),
|
||||
(req.expected_release_plan_sha256, "APPROVAL_REOPEN_RELEASE_PLAN_INVALID"),
|
||||
(req.expected_policy_sha256, "APPROVAL_REOPEN_POLICY_INVALID"),
|
||||
):
|
||||
if not _HEX64.fullmatch(value):
|
||||
raise ApprovalReopenError(code)
|
||||
if not (1 <= len(req.reason.strip()) <= 512):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_REASON_INVALID")
|
||||
if not _IDEMPOTENCY_RE.fullmatch(req.idempotency_key):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_IDEMPOTENCY_KEY_INVALID")
|
||||
return req
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _time(value: Any, code: str) -> datetime:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ApprovalReopenError(code)
|
||||
try:
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ApprovalReopenError(code) from exc
|
||||
if dt.tzinfo is None:
|
||||
raise ApprovalReopenError(code)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
||||
st = path.stat()
|
||||
if st.st_mode & 0o022:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_WRITABLE")
|
||||
if st.st_uid not in {0, os.geteuid()}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_OWNER_INVALID")
|
||||
try:
|
||||
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID") from exc
|
||||
if not isinstance(obj, dict):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_FILE_INVALID")
|
||||
return obj
|
||||
|
||||
|
||||
def _fsync_dir(path: Path) -> None:
|
||||
fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _paths(root: Path, cid: str) -> dict[str, Path]:
|
||||
return {
|
||||
"candidate": root / "candidates" / f"{cid}.json",
|
||||
"saga": root / "sagas" / f"{cid}.json",
|
||||
"plan": root / "release-plans" / f"{cid}.json",
|
||||
"acceptance": root / "acceptance" / f"{cid}.json",
|
||||
"approval": root / "approvals" / f"{cid}.json",
|
||||
}
|
||||
|
||||
|
||||
def _history(root: Path, cid: str, fp: str) -> Path:
|
||||
return root / "approval-history" / cid / f"{fp}.json"
|
||||
|
||||
|
||||
def _archive(active: Path, history: Path) -> None:
|
||||
history.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
try:
|
||||
os.chmod(history.parent, 0o750)
|
||||
except PermissionError:
|
||||
pass
|
||||
original = active.read_bytes()
|
||||
if history.exists():
|
||||
if history.is_symlink() or not history.is_file() or history.read_bytes() != original:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COLLISION")
|
||||
return
|
||||
try:
|
||||
os.link(active, history)
|
||||
except OSError:
|
||||
fd = os.open(history, os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
active.stat().st_mode & 0o777)
|
||||
try:
|
||||
view = memoryview(original)
|
||||
while view:
|
||||
n = os.write(fd, view)
|
||||
view = view[n:]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
if history.read_bytes() != original:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_COPY_FAILED")
|
||||
_fsync_dir(history.parent)
|
||||
|
||||
|
||||
def _details(event: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
val = event.get("details")
|
||||
return val if isinstance(val, Mapping) else {}
|
||||
|
||||
|
||||
def _cycle_event(saga: Mapping[str, Any], cycle_id: str) -> Mapping[str, Any] | None:
|
||||
for event in saga.get("events") or []:
|
||||
if isinstance(event, Mapping) and event.get("event") == EVENT_NAME \
|
||||
and _details(event).get("approval_cycle_id") == cycle_id:
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def _state(name: str) -> Any:
|
||||
from silent_release_hardening import SagaState # lazy: avoids import cycle
|
||||
return getattr(SagaState, name)
|
||||
|
||||
|
||||
def _cycle_id(req: ReopenRequest) -> str:
|
||||
return "approval-reopen-" + _sha(_canon({
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"candidate_id": req.candidate_id,
|
||||
"old_fingerprint": req.expected_approval_fingerprint,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
"artifact_sha256": req.expected_artifact_sha256,
|
||||
"manifest_sha256": req.expected_manifest_sha256,
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"policy_sha256": req.expected_policy_sha256,
|
||||
"idempotency_key": req.idempotency_key,
|
||||
}))[:24]
|
||||
|
||||
|
||||
def _validate(req: ReopenRequest, candidate: Mapping[str, Any],
|
||||
saga: Mapping[str, Any], plan: Mapping[str, Any],
|
||||
acceptance: Mapping[str, Any], approval: Mapping[str, Any],
|
||||
now: datetime) -> None:
|
||||
if saga.get("journal_integrity") is False:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_JOURNAL_INTEGRITY_INVALID")
|
||||
if saga.get("state") != "APPROVED":
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_STATE_INVALID")
|
||||
if candidate.get("candidate_id") != req.candidate_id:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_MISMATCH")
|
||||
if candidate.get("state") not in {"READY_FOR_APPROVAL", "APPROVED"}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_STATE_INVALID")
|
||||
if candidate.get("approval_state") not in {None, "PENDING"}:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CANDIDATE_APPROVAL_STATE_INVALID")
|
||||
# ReleaseCandidate v1 durable records do not persist side_effects_executed.
|
||||
# Compatibility rule is intentionally fail-closed:
|
||||
# - an absent key is accepted only together with the independent Saga
|
||||
# invariant below that the accepted Owner-approval event is the final
|
||||
# Saga event;
|
||||
# - if a future durable schema persists the key, it must be exactly False;
|
||||
# - explicit True/None/number/string values are rejected.
|
||||
if "side_effects_executed" in candidate and candidate.get("side_effects_executed") is not False:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SIDE_EFFECTS_ALREADY_EXECUTED")
|
||||
|
||||
checks = {
|
||||
"source": candidate.get("source_commit") == req.expected_source_commit,
|
||||
"tree": candidate.get("tree_oid") == req.expected_tree_oid,
|
||||
"artifact": candidate.get("artifact_sha256") == req.expected_artifact_sha256,
|
||||
"manifest": candidate.get("manifest_sha256") == req.expected_manifest_sha256,
|
||||
"approval_fingerprint": approval.get("fingerprint") == req.expected_approval_fingerprint,
|
||||
"approval_source": approval.get("source_commit") == req.expected_source_commit,
|
||||
"approval_tree": approval.get("tree_oid") == req.expected_tree_oid,
|
||||
"approval_artifact": approval.get("artifact_sha256") == req.expected_artifact_sha256,
|
||||
"approval_manifest": approval.get("manifest_sha256") == req.expected_manifest_sha256,
|
||||
"approval_release_plan": approval.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
||||
"approval_policy": approval.get("policy_sha256") == req.expected_policy_sha256,
|
||||
"acceptance": acceptance.get("status") == "ACCEPTED",
|
||||
"acceptance_release_plan": acceptance.get("release_plan_sha256") == req.expected_release_plan_sha256,
|
||||
"plan_sha": (plan.get("sha256") or plan.get("release_plan_sha256")) == req.expected_release_plan_sha256,
|
||||
"plan_policy": plan.get("policy_sha256") == req.expected_policy_sha256,
|
||||
}
|
||||
bad = sorted(k for k, ok in checks.items() if not ok)
|
||||
if bad:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_BINDING_MISMATCH", ",".join(bad))
|
||||
|
||||
if now <= _time(approval.get("expires_at"), "APPROVAL_REOPEN_APPROVAL_EXPIRY_INVALID"):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_APPROVAL_NOT_EXPIRED")
|
||||
|
||||
events = [e for e in saga.get("events") or [] if isinstance(e, Mapping)]
|
||||
accepted = None
|
||||
for i, event in enumerate(events):
|
||||
if event.get("event") == "signed_owner_approval_accepted" \
|
||||
and _details(event).get("approval_fingerprint") == req.expected_approval_fingerprint:
|
||||
accepted = i
|
||||
if accepted is None:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACCEPTED_EVENT_MISSING")
|
||||
if accepted != len(events) - 1:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_ALREADY_ADVANCED")
|
||||
|
||||
|
||||
def reopen_owner_approval(service: Any, raw: Mapping[str, Any], *,
|
||||
state_root: Path | None = None,
|
||||
now: datetime | None = None) -> dict[str, Any]:
|
||||
req = ReopenRequest.from_mapping(raw)
|
||||
raw_root = state_root or os.environ.get("AIOS_SILENT_RELEASE_STATE_ROOT", "")
|
||||
if not str(raw_root):
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_STATE_ROOT_MISSING")
|
||||
root = Path(raw_root).expanduser().resolve()
|
||||
paths = _paths(root, req.candidate_id)
|
||||
cycle = _cycle_id(req)
|
||||
history = _history(root, req.candidate_id, req.expected_approval_fingerprint)
|
||||
saga = _secure_json(paths["saga"])
|
||||
|
||||
prior = _cycle_event(saga, cycle)
|
||||
if prior is not None:
|
||||
if saga.get("state") != "READY_FOR_APPROVAL":
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_CYCLE_ALREADY_CONSUMED")
|
||||
if not history.is_file():
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_HISTORY_MISSING")
|
||||
if paths["approval"].exists():
|
||||
active = _secure_json(paths["approval"])
|
||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
||||
paths["approval"].unlink()
|
||||
_fsync_dir(paths["approval"].parent)
|
||||
return {
|
||||
"schema": "aios.owner-approval-reopen-result.v1",
|
||||
"status": "IDEMPOTENT",
|
||||
"candidate_id": req.candidate_id,
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
||||
"saga_state": "READY_FOR_APPROVAL",
|
||||
"history_ref": str(history.relative_to(root)),
|
||||
}
|
||||
|
||||
candidate = _secure_json(paths["candidate"])
|
||||
plan = _secure_json(paths["plan"])
|
||||
acceptance = _secure_json(paths["acceptance"])
|
||||
approval = _secure_json(paths["approval"])
|
||||
_validate(req, candidate, saga, plan, acceptance, approval,
|
||||
(now or datetime.now(timezone.utc)).astimezone(timezone.utc))
|
||||
|
||||
_archive(paths["approval"], history)
|
||||
event_details = {
|
||||
"schema": "aios.owner-approval-reopen-event.v1",
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_approval_fingerprint": req.expected_approval_fingerprint,
|
||||
"expired_at": approval.get("expires_at"),
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
"artifact_sha256": req.expected_artifact_sha256,
|
||||
"reason": req.reason.strip(),
|
||||
"idempotency_digest": _sha(req.idempotency_key.encode()),
|
||||
}
|
||||
try:
|
||||
service.sagas.compare_and_set(
|
||||
req.candidate_id,
|
||||
expected_revision=saga.get("revision"),
|
||||
expected_states={_state("APPROVED")},
|
||||
target=_state("READY_FOR_APPROVAL"),
|
||||
event=EVENT_NAME,
|
||||
details=event_details,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_SAGA_CAS_FAILED") from exc
|
||||
|
||||
try:
|
||||
active = _secure_json(paths["approval"])
|
||||
if active.get("fingerprint") != req.expected_approval_fingerprint:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_CHANGED")
|
||||
paths["approval"].unlink()
|
||||
_fsync_dir(paths["approval"].parent)
|
||||
except ApprovalReopenError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise ApprovalReopenError("APPROVAL_REOPEN_ACTIVE_APPROVAL_REMOVE_FAILED") from exc
|
||||
|
||||
return {
|
||||
"schema": "aios.owner-approval-reopen-result.v1",
|
||||
"status": "REOPENED",
|
||||
"candidate_id": req.candidate_id,
|
||||
"approval_cycle_id": cycle,
|
||||
"supersedes_fingerprint": req.expected_approval_fingerprint,
|
||||
"saga_state": "READY_FOR_APPROVAL",
|
||||
"history_ref": str(history.relative_to(root)),
|
||||
"release_plan_sha256": req.expected_release_plan_sha256,
|
||||
"source_commit": req.expected_source_commit,
|
||||
"tree_oid": req.expected_tree_oid,
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AIOS durable autonomous release-campaign authorization.
|
||||
|
||||
The campaign receipt represents one explicit Owner policy delegation. It is not
|
||||
a reusable Owner credential and it is not an unrestricted Git/production token.
|
||||
|
||||
Normal release operations are allowed only when all machine gates in the
|
||||
campaign policy succeed. Formal tag conflicts, non-fast-forward main divergence,
|
||||
history rewrite, private-signing-material exposure, repository identity conflict
|
||||
or campaign revocation remain fail-closed/manual-review conditions.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
CAMPAIGN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{3,127}$")
|
||||
SCHEMA = "aios.release-campaign-authorization.v1"
|
||||
|
||||
ALLOWED_ENGINEERING = frozenset({
|
||||
"create_task_branch",
|
||||
"create_worktree",
|
||||
"append_commit",
|
||||
"push_task_branch",
|
||||
"repair_task_branch",
|
||||
"create_pr",
|
||||
"update_pr",
|
||||
"run_required_checks",
|
||||
"publish_truthful_status",
|
||||
"protected_merge",
|
||||
})
|
||||
ALLOWED_RECONCILIATION = frozenset({
|
||||
"fetch_gitea_main",
|
||||
"verify_ancestor",
|
||||
"authority_main_ff_cas",
|
||||
"refresh_runtime_context",
|
||||
})
|
||||
ALLOWED_RELEASE = frozenset({
|
||||
"deterministic_build",
|
||||
"retain_artifact",
|
||||
"materialize_approved_artifact",
|
||||
"staging_install",
|
||||
"health_check",
|
||||
"restart_test",
|
||||
"rollback_readiness_test",
|
||||
"create_evidence_commit",
|
||||
"generate_exact_binding",
|
||||
"derive_campaign_acceptance",
|
||||
})
|
||||
ALLOWED_PRODUCTION = frozenset({
|
||||
"create_annotated_tag",
|
||||
"publish_tag",
|
||||
"verify_tag",
|
||||
"deploy_exact_artifact",
|
||||
"verify_production_health",
|
||||
"update_runtime_registry",
|
||||
"update_production_baseline",
|
||||
"update_baseline_sidecar",
|
||||
})
|
||||
DENIED = frozenset({
|
||||
"force_push",
|
||||
"non_fast_forward_main_write",
|
||||
"direct_main_push",
|
||||
"published_history_rewrite",
|
||||
"formal_tag_rewrite",
|
||||
"formal_tag_delete",
|
||||
"overwrite_conflicting_same_name_tag",
|
||||
"expose_private_signing_material",
|
||||
"commit_plaintext_secret",
|
||||
"repository_identity_change",
|
||||
"weaken_branch_protection",
|
||||
})
|
||||
|
||||
|
||||
class CampaignError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise CampaignError(f"invalid_file:{path}")
|
||||
st = path.lstat()
|
||||
if st.st_uid != 0:
|
||||
raise CampaignError(f"root_owner_required:{path}")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise CampaignError(f"group_world_writable_rejected:{path}")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise CampaignError(f"invalid_json:{path}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise CampaignError(f"json_object_required:{path}")
|
||||
return value
|
||||
|
||||
|
||||
def _exact_set(value: Any, expected: frozenset[str], name: str) -> None:
|
||||
if not isinstance(value, list) or frozenset(value) != expected:
|
||||
raise CampaignError(f"{name}_mismatch")
|
||||
|
||||
|
||||
def validate_policy(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
required = {
|
||||
"schema",
|
||||
"campaign_id",
|
||||
"generation",
|
||||
"owner_identity",
|
||||
"owner_role",
|
||||
"repository",
|
||||
"lineage_anchor_oid",
|
||||
"control_plane_branch",
|
||||
"activation_state",
|
||||
"active",
|
||||
"revoked",
|
||||
"valid_until_revoked",
|
||||
"allowed_engineering",
|
||||
"allowed_reconciliation",
|
||||
"allowed_release",
|
||||
"allowed_production",
|
||||
"denied",
|
||||
"normal_owner_interaction_required",
|
||||
}
|
||||
if set(value) != required:
|
||||
raise CampaignError("campaign_fields_invalid")
|
||||
if value["schema"] != SCHEMA:
|
||||
raise CampaignError("campaign_schema_invalid")
|
||||
if not isinstance(value["campaign_id"], str) or not CAMPAIGN_RE.fullmatch(value["campaign_id"]):
|
||||
raise CampaignError("campaign_id_invalid")
|
||||
if value["generation"] != 1:
|
||||
raise CampaignError("campaign_generation_invalid")
|
||||
if value["owner_identity"] != "aiman" or value["owner_role"] != "owner":
|
||||
raise CampaignError("campaign_owner_invalid")
|
||||
if value["repository"] != "aios/aios":
|
||||
raise CampaignError("campaign_repository_invalid")
|
||||
if not isinstance(value["lineage_anchor_oid"], str) or not OID_RE.fullmatch(value["lineage_anchor_oid"]):
|
||||
raise CampaignError("campaign_anchor_invalid")
|
||||
branch = value["control_plane_branch"]
|
||||
if branch != "agent/autonomous-release-campaign-v1-20260807":
|
||||
raise CampaignError("campaign_branch_invalid")
|
||||
if value["activation_state"] not in {"PENDING_CONTROL_PLANE_MERGE", "ACTIVE"}:
|
||||
raise CampaignError("campaign_activation_state_invalid")
|
||||
if value["revoked"] is not False or value["valid_until_revoked"] is not True:
|
||||
raise CampaignError("campaign_lifecycle_invalid")
|
||||
if value["normal_owner_interaction_required"] is not False:
|
||||
raise CampaignError("campaign_owner_interaction_policy_invalid")
|
||||
if value["activation_state"] == "ACTIVE" and value["active"] is not True:
|
||||
raise CampaignError("campaign_active_state_invalid")
|
||||
if value["activation_state"] == "PENDING_CONTROL_PLANE_MERGE" and value["active"] is not False:
|
||||
raise CampaignError("campaign_pending_state_invalid")
|
||||
_exact_set(value["allowed_engineering"], ALLOWED_ENGINEERING, "allowed_engineering")
|
||||
_exact_set(value["allowed_reconciliation"], ALLOWED_RECONCILIATION, "allowed_reconciliation")
|
||||
_exact_set(value["allowed_release"], ALLOWED_RELEASE, "allowed_release")
|
||||
_exact_set(value["allowed_production"], ALLOWED_PRODUCTION, "allowed_production")
|
||||
_exact_set(value["denied"], DENIED, "denied")
|
||||
return dict(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CampaignAuthorization:
|
||||
path: Path
|
||||
data: dict[str, Any]
|
||||
digest: str
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
path: Path,
|
||||
*,
|
||||
sidecar: Path | None = None,
|
||||
revocation: Path | None = None,
|
||||
require_active: bool = True,
|
||||
) -> "CampaignAuthorization":
|
||||
data = validate_policy(secure_json(path))
|
||||
raw = path.read_bytes()
|
||||
digest = sha256_bytes(raw)
|
||||
sidecar = sidecar or path.with_suffix(path.suffix + ".sha256")
|
||||
if not sidecar.is_file() or sidecar.is_symlink():
|
||||
raise CampaignError("campaign_digest_sidecar_missing")
|
||||
token = sidecar.read_text(encoding="ascii").strip().split()[0]
|
||||
if token != digest:
|
||||
raise CampaignError("campaign_digest_sidecar_mismatch")
|
||||
if revocation and revocation.exists():
|
||||
revoked = secure_json(revocation)
|
||||
if revoked.get("campaign_id") == data["campaign_id"] and revoked.get("revoked") is True:
|
||||
raise CampaignError("campaign_revoked")
|
||||
if require_active and (data["activation_state"] != "ACTIVE" or data["active"] is not True):
|
||||
raise CampaignError("campaign_not_active")
|
||||
return cls(path=path, data=data, digest=digest)
|
||||
|
||||
@property
|
||||
def campaign_id(self) -> str:
|
||||
return str(self.data["campaign_id"])
|
||||
|
||||
@property
|
||||
def anchor_oid(self) -> str:
|
||||
return str(self.data["lineage_anchor_oid"])
|
||||
|
||||
def assert_operation(self, operation: str) -> None:
|
||||
if operation in DENIED:
|
||||
raise CampaignError(f"campaign_operation_denied:{operation}")
|
||||
universe = (
|
||||
ALLOWED_ENGINEERING
|
||||
| ALLOWED_RECONCILIATION
|
||||
| ALLOWED_RELEASE
|
||||
| ALLOWED_PRODUCTION
|
||||
)
|
||||
if operation not in universe:
|
||||
raise CampaignError(f"campaign_operation_out_of_scope:{operation}")
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-interaction bootstrap for the AIOS autonomous release campaign.
|
||||
|
||||
A single real local `aiman` login confirmation does two things atomically at the
|
||||
policy layer:
|
||||
|
||||
1. Writes a root-owned, digest-sidecar protected campaign authorization receipt
|
||||
in PENDING_CONTROL_PLANE_MERGE state. It grants no immediate production side
|
||||
effect; the campaign becomes ACTIVE only after the campaign control-plane PR
|
||||
is merged and machine activation verifies lineage and implementation.
|
||||
2. Invokes the existing OwnerAuthorizationBroker to issue only the narrow
|
||||
engineering writer needed to implement that control-plane PR.
|
||||
|
||||
No reusable Owner token is minted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
|
||||
from owner_local_auth_ingress import (
|
||||
BootstrapError,
|
||||
canonical_json,
|
||||
read_frame,
|
||||
verify_peer_session,
|
||||
write_frame,
|
||||
)
|
||||
from release_campaign_authorization import (
|
||||
ALLOWED_ENGINEERING,
|
||||
ALLOWED_PRODUCTION,
|
||||
ALLOWED_RECONCILIATION,
|
||||
ALLOWED_RELEASE,
|
||||
DENIED,
|
||||
SCHEMA,
|
||||
validate_policy,
|
||||
)
|
||||
|
||||
BEGIN = "aios.release-campaign-bootstrap.begin.v1"
|
||||
CHALLENGE = "aios.release-campaign-bootstrap.challenge.v1"
|
||||
CONFIRM = "aios.release-campaign-bootstrap.confirm.v1"
|
||||
RESULT = "aios.release-campaign-bootstrap.result.v1"
|
||||
|
||||
TASK_ID = "AIOS_AUTONOMOUS_RELEASE_CAMPAIGN_V1"
|
||||
TARGET_BRANCH = "agent/autonomous-release-campaign-v1-20260807"
|
||||
REPOSITORY = "aios/aios"
|
||||
|
||||
|
||||
class CampaignBootstrapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def atomic_json_new(path: Path, value: Mapping[str, Any]) -> None:
|
||||
if path.exists() or path.is_symlink():
|
||||
raise CampaignBootstrapError(f"target_exists:{path}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
if os.geteuid() == 0:
|
||||
os.fchown(fd, 0, 0)
|
||||
with os.fdopen(fd, "wb", closefd=True) as stream:
|
||||
stream.write(canonical_json(dict(value)))
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
os.chmod(path, 0o600)
|
||||
dfd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def atomic_sidecar(path: Path, digest: str, target_name: str) -> None:
|
||||
if path.exists() or path.is_symlink():
|
||||
raise CampaignBootstrapError(f"sidecar_exists:{path}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
if os.geteuid() == 0:
|
||||
os.fchown(fd, 0, 0)
|
||||
with os.fdopen(fd, "w", encoding="ascii", closefd=True) as stream:
|
||||
stream.write(f"{digest} {target_name}\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
os.chmod(path, 0o600)
|
||||
dfd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def writer_request(base_oid: str) -> dict[str, Any]:
|
||||
return {
|
||||
"action": "AUTHORIZE_TASK_WRITER_PROVISIONING",
|
||||
"task_id": TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": base_oid,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"one_time_use": True,
|
||||
"expires_in_seconds": 300,
|
||||
"production_change_authorized": False,
|
||||
"tag_change_authorized": False,
|
||||
"baseline_change_authorized": False,
|
||||
"main_write_authorized": False,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch",
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def campaign_policy(campaign_id: str, base_oid: str) -> dict[str, Any]:
|
||||
value = {
|
||||
"schema": SCHEMA,
|
||||
"campaign_id": campaign_id,
|
||||
"generation": 1,
|
||||
"owner_identity": "aiman",
|
||||
"owner_role": "owner",
|
||||
"repository": REPOSITORY,
|
||||
"lineage_anchor_oid": base_oid,
|
||||
"control_plane_branch": TARGET_BRANCH,
|
||||
"activation_state": "PENDING_CONTROL_PLANE_MERGE",
|
||||
"active": False,
|
||||
"revoked": False,
|
||||
"valid_until_revoked": True,
|
||||
"allowed_engineering": sorted(ALLOWED_ENGINEERING),
|
||||
"allowed_reconciliation": sorted(ALLOWED_RECONCILIATION),
|
||||
"allowed_release": sorted(ALLOWED_RELEASE),
|
||||
"allowed_production": sorted(ALLOWED_PRODUCTION),
|
||||
"denied": sorted(DENIED),
|
||||
"normal_owner_interaction_required": False,
|
||||
}
|
||||
return validate_policy(value)
|
||||
|
||||
|
||||
def invoke_writer_broker(base_oid: str) -> dict[str, Any]:
|
||||
from owner_authorization_broker import (
|
||||
OwnerAuthorizationBroker,
|
||||
OwnerAuthorizationBrokerConfig,
|
||||
OwnerPrincipal,
|
||||
)
|
||||
|
||||
config = OwnerAuthorizationBrokerConfig.from_environment()
|
||||
if "aiman" not in config.allowed_owner_identities:
|
||||
raise CampaignBootstrapError("aiman_not_allowed_by_live_broker")
|
||||
if "commit:create" not in config.allowed_intake_scopes:
|
||||
raise CampaignBootstrapError("commit_create_not_allowed_by_live_broker")
|
||||
principal = OwnerPrincipal(
|
||||
identity="aiman",
|
||||
role="owner",
|
||||
authenticated_scope="commit:create",
|
||||
token_scopes=frozenset({"commit:create"}),
|
||||
credential_id="",
|
||||
)
|
||||
return OwnerAuthorizationBroker(config=config).authorize_and_provision(
|
||||
writer_request(base_oid),
|
||||
principal,
|
||||
)
|
||||
|
||||
|
||||
def safe_broker_result(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
allowed = {
|
||||
"status",
|
||||
"approval_plan_id",
|
||||
"grant_id",
|
||||
"grant_digest_sha256",
|
||||
"branch_oid",
|
||||
"branch_ref_created",
|
||||
"grant_consumed",
|
||||
"owner_authorization_event_id",
|
||||
"owner_authorization_reference",
|
||||
"owner_authorization_digest_sha256",
|
||||
"owner_authorization_consumed",
|
||||
"owner_authorization_idempotent",
|
||||
}
|
||||
lowered = " ".join(str(k).lower() for k in value)
|
||||
for secret_word in ("token", "password", "private_key", "secret_value", "bearer"):
|
||||
if secret_word in lowered:
|
||||
raise CampaignBootstrapError("broker_response_sensitive_field_rejected")
|
||||
return {k: value[k] for k in sorted(allowed) if k in value}
|
||||
|
||||
|
||||
def serve(
|
||||
*,
|
||||
socket_path: Path,
|
||||
state_root: Path,
|
||||
base_oid: str,
|
||||
campaign_id: str,
|
||||
timeout: int,
|
||||
) -> int:
|
||||
if os.geteuid() != 0:
|
||||
raise CampaignBootstrapError("server_root_required")
|
||||
if len(base_oid) != 40 or any(c not in "0123456789abcdef" for c in base_oid):
|
||||
raise CampaignBootstrapError("base_oid_invalid")
|
||||
|
||||
policy = campaign_policy(campaign_id, base_oid)
|
||||
campaign_dir = state_root / campaign_id
|
||||
campaign_path = campaign_dir / "authorization.json"
|
||||
sidecar_path = campaign_dir / "authorization.json.sha256"
|
||||
|
||||
socket_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if socket_path.exists() or socket_path.is_symlink():
|
||||
raise CampaignBootstrapError("socket_already_exists")
|
||||
|
||||
import pwd
|
||||
owner = pwd.getpwnam("aiman")
|
||||
os.chown(socket_path.parent, 0, owner.pw_gid)
|
||||
os.chmod(socket_path.parent, 0o750)
|
||||
|
||||
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
listener.bind(str(socket_path))
|
||||
os.chown(socket_path, 0, owner.pw_gid)
|
||||
os.chmod(socket_path, 0o660)
|
||||
listener.listen(1)
|
||||
listener.settimeout(timeout)
|
||||
|
||||
conn, _ = listener.accept()
|
||||
with conn:
|
||||
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
||||
pid, uid, gid = struct.unpack("3i", raw)
|
||||
peer = verify_peer_session(pid, uid, gid, "aiman")
|
||||
if read_frame(conn) != {"schema": BEGIN, "action": "BEGIN"}:
|
||||
raise CampaignBootstrapError("client_begin_invalid")
|
||||
|
||||
challenge = secrets.token_hex(12)
|
||||
issued = time.monotonic()
|
||||
summary = {
|
||||
"campaign_id": campaign_id,
|
||||
"repository": REPOSITORY,
|
||||
"lineage_anchor_oid": base_oid,
|
||||
"control_plane_branch": TARGET_BRANCH,
|
||||
"normal_owner_interaction_after_activation": False,
|
||||
"allowed_engineering": sorted(ALLOWED_ENGINEERING),
|
||||
"allowed_reconciliation": sorted(ALLOWED_RECONCILIATION),
|
||||
"allowed_release": sorted(ALLOWED_RELEASE),
|
||||
"allowed_production": sorted(ALLOWED_PRODUCTION),
|
||||
"denied": sorted(DENIED),
|
||||
"writer_request": writer_request(base_oid),
|
||||
}
|
||||
write_frame(
|
||||
conn,
|
||||
{
|
||||
"schema": CHALLENGE,
|
||||
"challenge": challenge,
|
||||
"expires_in_seconds": 120,
|
||||
"summary": summary,
|
||||
},
|
||||
)
|
||||
confirm = read_frame(conn)
|
||||
phrase = f"AUTHORIZE CAMPAIGN {campaign_id} {challenge}"
|
||||
if (
|
||||
confirm.get("schema") != CONFIRM
|
||||
or confirm.get("challenge") != challenge
|
||||
or confirm.get("confirmation") != phrase
|
||||
):
|
||||
raise CampaignBootstrapError("campaign_confirmation_invalid")
|
||||
if time.monotonic() - issued > 120:
|
||||
raise CampaignBootstrapError("campaign_confirmation_expired")
|
||||
|
||||
# First issue the narrow engineering writer. If this fails, do not
|
||||
# persist a campaign authorization that cannot be implemented.
|
||||
broker = safe_broker_result(invoke_writer_broker(base_oid))
|
||||
if broker.get("status") != "READY_FOR_CODE_AUTHORING":
|
||||
raise CampaignBootstrapError("writer_not_ready")
|
||||
|
||||
atomic_json_new(campaign_path, policy)
|
||||
digest = sha256_file(campaign_path)
|
||||
atomic_sidecar(sidecar_path, digest, campaign_path.name)
|
||||
|
||||
receipt = {
|
||||
"schema": "aios.release-campaign-bootstrap-receipt.v1",
|
||||
"status": "CAMPAIGN_PENDING_CONTROL_PLANE_MERGE",
|
||||
"campaign_id": campaign_id,
|
||||
"campaign_authorization_path": str(campaign_path),
|
||||
"campaign_authorization_sha256": digest,
|
||||
"owner_identity": "aiman",
|
||||
"owner_role": "owner",
|
||||
"authentication": {
|
||||
"method": "local_so_peercred_loginuid_tty",
|
||||
"uid": peer["uid"],
|
||||
"loginuid": peer["loginuid"],
|
||||
"tty": peer["tty"],
|
||||
},
|
||||
"writer": broker,
|
||||
"reusable_owner_token_created": False,
|
||||
"production_side_effect_executed": False,
|
||||
"completed_at_unix": int(time.time()),
|
||||
}
|
||||
receipt_path = campaign_dir / "bootstrap-receipt.json"
|
||||
atomic_json_new(receipt_path, receipt)
|
||||
write_frame(
|
||||
conn,
|
||||
{
|
||||
"schema": RESULT,
|
||||
"ok": True,
|
||||
"campaign_id": campaign_id,
|
||||
"campaign_authorization_path": str(campaign_path),
|
||||
"campaign_authorization_sha256": digest,
|
||||
"writer": broker,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
listener.close()
|
||||
try:
|
||||
socket_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def client(socket_path: Path) -> int:
|
||||
import sys
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
raise CampaignBootstrapError("interactive_tty_required")
|
||||
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
conn.connect(str(socket_path))
|
||||
with conn:
|
||||
write_frame(conn, {"schema": BEGIN, "action": "BEGIN"})
|
||||
challenge = read_frame(conn)
|
||||
if challenge.get("schema") != CHALLENGE:
|
||||
raise CampaignBootstrapError("challenge_invalid")
|
||||
print("\nAIOS Autonomous Release Campaign authorization")
|
||||
print(json.dumps(challenge["summary"], indent=2, sort_keys=True))
|
||||
phrase = (
|
||||
f"AUTHORIZE CAMPAIGN "
|
||||
f"{challenge['summary']['campaign_id']} "
|
||||
f"{challenge['challenge']}"
|
||||
)
|
||||
typed = input(f"\nType exactly:\n{phrase}\n> ").strip()
|
||||
if typed != phrase:
|
||||
raise CampaignBootstrapError("confirmation_mismatch")
|
||||
write_frame(
|
||||
conn,
|
||||
{
|
||||
"schema": CONFIRM,
|
||||
"challenge": challenge["challenge"],
|
||||
"confirmation": typed,
|
||||
},
|
||||
)
|
||||
result = read_frame(conn)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0 if result.get("ok") is True else 3
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
mode = p.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--serve", action="store_true")
|
||||
mode.add_argument("--client", action="store_true")
|
||||
p.add_argument("--socket", type=Path, default=Path("/run/aios-release-campaign/owner.sock"))
|
||||
p.add_argument(
|
||||
"--state-root",
|
||||
type=Path,
|
||||
default=Path("/var/lib/aios-gitops/release-campaigns"),
|
||||
)
|
||||
p.add_argument("--base-oid")
|
||||
p.add_argument("--campaign-id")
|
||||
p.add_argument("--timeout", type=int, default=300)
|
||||
a = p.parse_args()
|
||||
if a.serve:
|
||||
if not a.base_oid or not a.campaign_id:
|
||||
raise CampaignBootstrapError("server_binding_required")
|
||||
return serve(
|
||||
socket_path=a.socket,
|
||||
state_root=a.state_root,
|
||||
base_oid=a.base_oid,
|
||||
campaign_id=a.campaign_id,
|
||||
timeout=a.timeout,
|
||||
)
|
||||
return client(a.socket)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (CampaignBootstrapError, BootstrapError) as exc:
|
||||
print(json.dumps({"status": "BLOCKED", "reason": str(exc)}, sort_keys=True))
|
||||
raise SystemExit(2)
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable state model for the AIOS autonomous release campaign."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any
|
||||
|
||||
STATES = (
|
||||
"DEVELOPMENT",
|
||||
"VALIDATING",
|
||||
"PR_READY",
|
||||
"MERGING",
|
||||
"MAIN_RECONCILING",
|
||||
"POSTMERGE_BUILDING",
|
||||
"STAGING",
|
||||
"EVIDENCE_CLOSING",
|
||||
"BINDING_ACCEPTING",
|
||||
"TAGGING",
|
||||
"DEPLOYING",
|
||||
"VERIFYING",
|
||||
"REGISTRY_FINALIZING",
|
||||
"BASELINE_FINALIZING",
|
||||
"PRODUCTION_DEPLOYED",
|
||||
)
|
||||
RECOVERABLE = "RECOVERABLE_FAILURE"
|
||||
RECONCILIATION = "RECONCILIATION_REQUIRED"
|
||||
MANUAL = "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
|
||||
NEXT = {STATES[i]: STATES[i + 1] for i in range(len(STATES) - 1)}
|
||||
|
||||
MANUAL_REASONS = frozenset({
|
||||
"formal_same_name_tag_conflict",
|
||||
"force_push_required",
|
||||
"published_history_rewrite_required",
|
||||
"non_fast_forward_main_divergence",
|
||||
"private_signing_material_exposure_required",
|
||||
"repository_identity_conflict",
|
||||
"campaign_revoked",
|
||||
"operation_outside_campaign_scope",
|
||||
})
|
||||
|
||||
|
||||
class OrchestratorError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseState:
|
||||
version: str
|
||||
state: str = "DEVELOPMENT"
|
||||
last_stable_state: str = "DEVELOPMENT"
|
||||
recoverable_reason: str | None = None
|
||||
|
||||
def advance(self) -> None:
|
||||
if self.state not in NEXT:
|
||||
raise OrchestratorError(f"cannot_advance:{self.state}")
|
||||
self.last_stable_state = self.state
|
||||
self.state = NEXT[self.state]
|
||||
self.recoverable_reason = None
|
||||
|
||||
def recoverable_failure(self, reason: str) -> None:
|
||||
self.last_stable_state = self.state
|
||||
self.state = RECOVERABLE
|
||||
self.recoverable_reason = reason
|
||||
|
||||
def reconciliation_required(self, reason: str) -> None:
|
||||
self.last_stable_state = self.state
|
||||
self.state = RECONCILIATION
|
||||
self.recoverable_reason = reason
|
||||
|
||||
def manual_review(self, reason: str) -> None:
|
||||
if reason not in MANUAL_REASONS:
|
||||
raise OrchestratorError("reason_not_manual_red_line")
|
||||
self.last_stable_state = self.state
|
||||
self.state = MANUAL
|
||||
self.recoverable_reason = reason
|
||||
|
||||
def resume(self) -> None:
|
||||
if self.state not in {RECOVERABLE, RECONCILIATION}:
|
||||
raise OrchestratorError("state_not_resumable")
|
||||
self.state = self.last_stable_state
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "aios.autonomous-release-state.v1",
|
||||
"version": self.version,
|
||||
"state": self.state,
|
||||
"last_stable_state": self.last_stable_state,
|
||||
"recoverable_reason": self.recoverable_reason,
|
||||
"owner_interaction_required": self.state == MANUAL,
|
||||
}
|
||||
|
||||
|
||||
def atomic_state(path: Path, state: ReleaseState) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8", closefd=True) as stream:
|
||||
json.dump(state.as_dict(), stream, sort_keys=True, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(name, path)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed, fail-closed projection of verified release evidence into Builder storage.
|
||||
|
||||
This module never invents candidate/release evidence. A separate governed release
|
||||
producer must first write a complete, root-owned evidence closure below
|
||||
``source_root/<version>``. The materializer re-validates that closure with the
|
||||
same ``ServerOwnedEvidenceSource``/``DeterministicEvidenceCollector`` code used by
|
||||
Trusted Candidate Builder and atomically projects the exact bytes into the
|
||||
Builder-owned evidence root.
|
||||
|
||||
Readiness is deliberately orthogonal: an empty Builder evidence root does not make
|
||||
Builder infrastructure globally unavailable, but every BuildTrustedCandidateV1
|
||||
request still requires a valid materialized closure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any, Callable, Mapping
|
||||
import subprocess
|
||||
|
||||
from aios_trusted_candidate_builder import (
|
||||
BuilderError,
|
||||
DeterministicEvidenceCollector,
|
||||
ServerOwnedEvidenceSource,
|
||||
TrustedCandidateBuilderConfig,
|
||||
load_builder_policy,
|
||||
)
|
||||
from release_migration_plan import FileMigrationPlanResolver
|
||||
from release_source_admission import (
|
||||
ReleaseSourceAdmissionStore,
|
||||
open_source_admission_store,
|
||||
source_admission_policy_sha256,
|
||||
)
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
MATERIALIZATION_SCHEMA = "aios.trusted-builder-evidence-materialization.v1"
|
||||
SOURCE_ADMISSION_CREDENTIAL = "source_admission_hmac"
|
||||
|
||||
|
||||
class EvidenceMaterializationError(RuntimeError):
|
||||
"""Stable, non-sensitive materialization failure."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with Path(path).open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _validate_oid(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
if (
|
||||
len(normalized) != 40
|
||||
or any(char not in "0123456789abcdef" for char in normalized)
|
||||
):
|
||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_identifier(value: object, name: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if (
|
||||
not normalized
|
||||
or len(normalized) > 255
|
||||
or normalized[0] in ".-"
|
||||
or any(
|
||||
char not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-"
|
||||
for char in normalized
|
||||
)
|
||||
):
|
||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_directory(path: Path, *, expected_owner_uid: int | None) -> Path:
|
||||
candidate = Path(path)
|
||||
try:
|
||||
metadata = candidate.stat(follow_symlinks=False)
|
||||
resolved = candidate.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_UNAVAILABLE") from exc
|
||||
if candidate.is_symlink() or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_INVALID")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_PERMISSIONS_INVALID")
|
||||
if (
|
||||
os.name != "nt"
|
||||
and expected_owner_uid is not None
|
||||
and metadata.st_uid != expected_owner_uid
|
||||
):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DIRECTORY_OWNER_INVALID")
|
||||
return resolved
|
||||
|
||||
|
||||
def _safe_regular(
|
||||
root: Path,
|
||||
relative: str,
|
||||
*,
|
||||
expected_owner_uid: int | None,
|
||||
) -> Path:
|
||||
root = _safe_directory(root, expected_owner_uid=expected_owner_uid)
|
||||
relative_path = Path(relative)
|
||||
if relative_path.is_absolute() or any(
|
||||
part in {"", ".", ".."} for part in relative_path.parts
|
||||
):
|
||||
raise EvidenceMaterializationError("EVIDENCE_PATH_INVALID")
|
||||
current = root
|
||||
for part in relative_path.parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise EvidenceMaterializationError("EVIDENCE_SYMLINK_DENIED")
|
||||
try:
|
||||
resolved = (root / relative_path).resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
metadata = resolved.stat(follow_symlinks=False)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise EvidenceMaterializationError("EVIDENCE_SPECIAL_FILE_DENIED")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_PERMISSIONS_INVALID")
|
||||
if (
|
||||
os.name != "nt"
|
||||
and expected_owner_uid is not None
|
||||
and metadata.st_uid != expected_owner_uid
|
||||
):
|
||||
raise EvidenceMaterializationError("EVIDENCE_INPUT_OWNER_INVALID")
|
||||
return resolved
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
if os.name == "nt":
|
||||
return
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _copy_exact(source: Path, target: Path, *, mode: int = 0o640) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
descriptor = os.open(
|
||||
target,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
mode,
|
||||
)
|
||||
try:
|
||||
with source.open("rb") as src:
|
||||
while True:
|
||||
chunk = src.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
os.write(descriptor, chunk)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(target, mode)
|
||||
|
||||
|
||||
def _write_json_exclusive(path: Path, payload: Mapping[str, Any]) -> str:
|
||||
raw = _canonical_json(payload) + b"\n"
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_BINARY", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, raw)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.chmod(path, 0o640)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
class TrustedBuilderEvidenceMaterializer:
|
||||
"""Verify a governed evidence closure and atomically project exact bytes."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
builder_policy: Path,
|
||||
authority_git_dir: Path,
|
||||
source_root: Path,
|
||||
output_root: Path,
|
||||
source_admission_root: Path | None = None,
|
||||
source_admission_hmac: bytes | None = None,
|
||||
migration_plan_root: Path | None = None,
|
||||
migration_profile_root: Path | None = None,
|
||||
expected_owner_uid: int | None = 0,
|
||||
git_runner: Callable[..., Any] = subprocess.run,
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None,
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None,
|
||||
) -> None:
|
||||
self.builder_policy = Path(builder_policy)
|
||||
self.authority_git_dir = Path(authority_git_dir)
|
||||
self.source_root = Path(source_root)
|
||||
self.output_root = Path(output_root)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
self.git_runner = git_runner
|
||||
|
||||
try:
|
||||
policy = load_builder_policy(self.builder_policy)
|
||||
config = TrustedCandidateBuilderConfig.from_policy(policy)
|
||||
except BuilderError as exc:
|
||||
raise EvidenceMaterializationError(str(exc)) from exc
|
||||
|
||||
self.source_admission_store: ReleaseSourceAdmissionStore | None = None
|
||||
if source_admission_resolver is not None:
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=source_admission_resolver,
|
||||
)
|
||||
elif source_admission_root is not None:
|
||||
if not source_admission_hmac or len(source_admission_hmac) < 32:
|
||||
raise EvidenceMaterializationError("SOURCE_ADMISSION_HMAC_UNAVAILABLE")
|
||||
self.source_admission_store = open_source_admission_store(
|
||||
Path(source_admission_root),
|
||||
repository=config.repository,
|
||||
policy=self.builder_policy,
|
||||
hmac_key=source_admission_hmac,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
writable=False,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=self.source_admission_store.resolve_recipe,
|
||||
)
|
||||
|
||||
if migration_plan_resolver is not None:
|
||||
config = replace(config, migration_plan_resolver=migration_plan_resolver)
|
||||
elif migration_plan_root is not None or migration_profile_root is not None:
|
||||
if migration_plan_root is None or migration_profile_root is None:
|
||||
raise EvidenceMaterializationError(
|
||||
"MIGRATION_PLAN_CONFIGURATION_INVALID"
|
||||
)
|
||||
resolver = FileMigrationPlanResolver(
|
||||
Path(migration_plan_root),
|
||||
profile_root=Path(migration_profile_root),
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
)
|
||||
config = replace(config, migration_plan_resolver=resolver.resolve)
|
||||
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def policy_file_sha256(self) -> str:
|
||||
return source_admission_policy_sha256(self.builder_policy)
|
||||
|
||||
def _collector_for(self, evidence_root: Path) -> DeterministicEvidenceCollector:
|
||||
source = ServerOwnedEvidenceSource(
|
||||
self.config,
|
||||
self.authority_git_dir,
|
||||
evidence_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
git_runner=self.git_runner,
|
||||
)
|
||||
return DeterministicEvidenceCollector(
|
||||
self.config,
|
||||
evidence_source=source,
|
||||
)
|
||||
|
||||
def _relative_files(self, version: str) -> tuple[str, ...]:
|
||||
relative = [f"{version}/receipt.json"]
|
||||
relative.extend(
|
||||
template.format(version=version)
|
||||
for template in self.config.evidence_layout.values()
|
||||
)
|
||||
return tuple(dict.fromkeys(relative))
|
||||
|
||||
def _validate_existing(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
candidate_id: str,
|
||||
source_commit: str,
|
||||
expected_bundle: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
observed = self._collector_for(self.output_root).collect(
|
||||
version,
|
||||
self.config.resolve_recipe(version, candidate_id, source_commit),
|
||||
)
|
||||
except (BuilderError, RuntimeError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT") from exc
|
||||
if _canonical_json(dict(observed)) != _canonical_json(dict(expected_bundle)):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
||||
record_path = self.output_root / version / "materialization.json"
|
||||
if not record_path.is_file():
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
||||
try:
|
||||
record = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT") from exc
|
||||
if not isinstance(record, Mapping):
|
||||
raise EvidenceMaterializationError("EVIDENCE_DESTINATION_CONFLICT")
|
||||
return dict(record)
|
||||
|
||||
def materialize(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
candidate_id: str,
|
||||
source_commit: str,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
version = validate_release_version(version)
|
||||
except ValueError as exc:
|
||||
raise EvidenceMaterializationError("VERSION_INVALID") from exc
|
||||
candidate_id = _validate_identifier(candidate_id, "candidate_id")
|
||||
source_commit = _validate_oid(source_commit, "source_commit")
|
||||
|
||||
_safe_directory(
|
||||
self.source_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
_safe_directory(
|
||||
self.output_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
|
||||
try:
|
||||
recipe = self.config.resolve_recipe(
|
||||
version,
|
||||
candidate_id,
|
||||
source_commit,
|
||||
)
|
||||
bundle = self._collector_for(self.source_root).collect(
|
||||
version,
|
||||
recipe,
|
||||
)
|
||||
except BuilderError as exc:
|
||||
raise EvidenceMaterializationError(str(exc)) from exc
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError("EVIDENCE_SOURCE_INVALID") from exc
|
||||
|
||||
admission_id = ""
|
||||
if self.source_admission_store is not None:
|
||||
try:
|
||||
admission_id = str(
|
||||
self.source_admission_store.lookup_source(source_commit)[
|
||||
"admission_id"
|
||||
]
|
||||
)
|
||||
except Exception as exc:
|
||||
raise EvidenceMaterializationError(
|
||||
"SOURCE_ADMISSION_INVALID"
|
||||
) from exc
|
||||
|
||||
receipt_path = _safe_regular(
|
||||
self.source_root,
|
||||
f"{version}/receipt.json",
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
receipt_sha256 = _file_sha256(receipt_path)
|
||||
release_plan = dict(bundle["release_plan"])
|
||||
migration = release_plan.get("migration")
|
||||
migration_sha256 = ""
|
||||
if isinstance(migration, Mapping):
|
||||
migration_sha256 = str(migration.get("sha256") or "")
|
||||
|
||||
materialization_payload = {
|
||||
"schema": MATERIALIZATION_SCHEMA,
|
||||
"repository": self.config.repository,
|
||||
"version": version,
|
||||
"candidate_id": candidate_id,
|
||||
"source_commit": source_commit,
|
||||
"tree_oid": str(bundle["candidate"]["tree_oid"]),
|
||||
"source_admission_id": admission_id,
|
||||
"builder_policy_file_sha256": self.policy_file_sha256,
|
||||
"receipt_sha256": receipt_sha256,
|
||||
"migration_sha256": migration_sha256,
|
||||
"evidence_digests": dict(bundle["evidence_digests"]),
|
||||
}
|
||||
materialization_id = hashlib.sha256(
|
||||
_canonical_json(materialization_payload)
|
||||
).hexdigest()
|
||||
materialization_payload["materialization_id"] = (
|
||||
"evidence-materialization-" + materialization_id[:24]
|
||||
)
|
||||
|
||||
target = self.output_root / version
|
||||
if target.exists() or target.is_symlink():
|
||||
existing = self._validate_existing(
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
source_commit=source_commit,
|
||||
expected_bundle=bundle,
|
||||
)
|
||||
if (
|
||||
existing.get("materialization_id")
|
||||
!= materialization_payload["materialization_id"]
|
||||
):
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_DESTINATION_CONFLICT"
|
||||
)
|
||||
return {
|
||||
**materialization_payload,
|
||||
"status": "EVIDENCE_MATERIALIZED",
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
temporary = self.output_root / (
|
||||
f".{version}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
||||
)
|
||||
if temporary.exists() or temporary.is_symlink():
|
||||
raise EvidenceMaterializationError("EVIDENCE_TEMP_CONFLICT")
|
||||
temporary.mkdir(mode=0o750)
|
||||
try:
|
||||
for relative in self._relative_files(version):
|
||||
source = _safe_regular(
|
||||
self.source_root,
|
||||
relative,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
)
|
||||
destination_relative = Path(relative).relative_to(version)
|
||||
destination = temporary / destination_relative
|
||||
_copy_exact(source, destination)
|
||||
if _file_sha256(destination) != _file_sha256(source):
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_COPY_DIGEST_MISMATCH"
|
||||
)
|
||||
materialization_sha256 = _write_json_exclusive(
|
||||
temporary / "materialization.json",
|
||||
materialization_payload,
|
||||
)
|
||||
_fsync_directory(temporary)
|
||||
os.replace(temporary, target)
|
||||
_fsync_directory(self.output_root)
|
||||
except Exception:
|
||||
shutil.rmtree(temporary, ignore_errors=True)
|
||||
raise
|
||||
|
||||
try:
|
||||
observed = self._collector_for(self.output_root).collect(
|
||||
version,
|
||||
recipe,
|
||||
)
|
||||
except (BuilderError, RuntimeError, ValueError) as exc:
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_POST_WRITE_VERIFY_FAILED"
|
||||
) from exc
|
||||
if _canonical_json(dict(observed)) != _canonical_json(dict(bundle)):
|
||||
raise EvidenceMaterializationError(
|
||||
"EVIDENCE_POST_WRITE_VERIFY_FAILED"
|
||||
)
|
||||
|
||||
return {
|
||||
**materialization_payload,
|
||||
"materialization_sha256": materialization_sha256,
|
||||
"status": "EVIDENCE_MATERIALIZED",
|
||||
"idempotent": False,
|
||||
}
|
||||
|
||||
|
||||
def _credential(name: str) -> bytes:
|
||||
root = os.environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
||||
if not root:
|
||||
raise EvidenceMaterializationError("CREDENTIALS_DIRECTORY_REQUIRED")
|
||||
path = Path(root) / name
|
||||
try:
|
||||
value = path.read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise EvidenceMaterializationError(
|
||||
f"{name.upper()}_UNAVAILABLE"
|
||||
) from exc
|
||||
if len(value) < 32:
|
||||
raise EvidenceMaterializationError(f"{name.upper()}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("operation", choices=("materialize",))
|
||||
parser.add_argument("--version", required=True)
|
||||
parser.add_argument("--candidate-id", required=True)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
||||
parser.add_argument("--authority-git-dir", required=True, type=Path)
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--output-root", required=True, type=Path)
|
||||
parser.add_argument("--source-admission-root", required=True, type=Path)
|
||||
parser.add_argument("--migration-plan-root", required=True, type=Path)
|
||||
parser.add_argument("--migration-profile-root", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = TrustedBuilderEvidenceMaterializer(
|
||||
builder_policy=arguments.builder_policy,
|
||||
authority_git_dir=arguments.authority_git_dir,
|
||||
source_root=arguments.source_root,
|
||||
output_root=arguments.output_root,
|
||||
source_admission_root=arguments.source_admission_root,
|
||||
source_admission_hmac=_credential(SOURCE_ADMISSION_CREDENTIAL),
|
||||
migration_plan_root=arguments.migration_plan_root,
|
||||
migration_profile_root=arguments.migration_profile_root,
|
||||
).materialize(
|
||||
version=arguments.version,
|
||||
candidate_id=arguments.candidate_id,
|
||||
source_commit=arguments.source_commit,
|
||||
)
|
||||
except EvidenceMaterializationError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Atomic Registry/Baseline finalizer for an already ACTIVE exact release.
|
||||
|
||||
This adapter never writes Git refs. It is intended for the AIOS runtime
|
||||
Registry and authoritative runtime-anchor Baseline only.
|
||||
|
||||
Registry is finalized before Baseline. Baseline apply independently verifies:
|
||||
- exact Authority annotated tag and peeled target
|
||||
- exact ACTIVE production health
|
||||
- exact V2R1C7 Registry record
|
||||
- exact previous Baseline file SHA-256 (CAS)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
PLAN_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, Any], mode: int = 0o640) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Existing runtime-authority files may be root:gitops and read-only to
|
||||
# non-root readers. Preserve their owner/group/mode across os.replace().
|
||||
if path.exists():
|
||||
metadata = path.lstat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
|
||||
raise RuntimeError("ATOMIC_TARGET_NOT_REGULAR")
|
||||
target_mode = stat.S_IMODE(metadata.st_mode)
|
||||
target_uid = metadata.st_uid
|
||||
target_gid = metadata.st_gid
|
||||
else:
|
||||
parent = path.parent.stat()
|
||||
target_mode = mode
|
||||
target_uid = 0 if os.geteuid() == 0 else os.geteuid()
|
||||
target_gid = parent.st_gid
|
||||
|
||||
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as output:
|
||||
json.dump(dict(value), output, sort_keys=True, indent=2)
|
||||
output.write("\n")
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
os.chmod(name, target_mode)
|
||||
if os.geteuid() == 0:
|
||||
os.chown(name, target_uid, target_gid)
|
||||
os.replace(name, path)
|
||||
dfd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(dfd)
|
||||
finally:
|
||||
os.close(dfd)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(name)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def load_request() -> dict[str, Any]:
|
||||
value = json.load(sys.stdin)
|
||||
if not isinstance(value, dict) or value.get("schema") != "aios.production-executor-request.v1":
|
||||
raise ValueError("request_schema_invalid")
|
||||
if value.get("repository_identity") != "aios/aios":
|
||||
raise ValueError("repository_identity_invalid")
|
||||
try:
|
||||
validate_release_version(value.get("version", ""))
|
||||
except ValueError as exc:
|
||||
raise ValueError("version_invalid") from exc
|
||||
for name in ("target_oid", "evidence_closure_oid"):
|
||||
if not OID_RE.fullmatch(str(value.get(name, ""))):
|
||||
raise ValueError(f"{name}_invalid")
|
||||
if not SHA_RE.fullmatch(str(value.get("artifact_sha256", ""))):
|
||||
raise ValueError("artifact_sha256_invalid")
|
||||
if not PLAN_RE.fullmatch(str(value.get("approval_plan_id", ""))):
|
||||
raise ValueError("approval_plan_id_invalid")
|
||||
if "tag_oid" in value and not OID_RE.fullmatch(str(value["tag_oid"])):
|
||||
raise ValueError("tag_oid_invalid")
|
||||
return value
|
||||
|
||||
|
||||
def exact_response(value: Mapping[str, Any], status: str, **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"status": status,
|
||||
"version": value["version"],
|
||||
"target_oid": value["target_oid"],
|
||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
||||
"artifact_sha256": value["artifact_sha256"],
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def git(repository: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", f"--git-dir={repository}", *args],
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
|
||||
|
||||
def verify_local_tag(repository: Path, value: Mapping[str, Any]) -> tuple[str, str]:
|
||||
tag_oid = git(repository, "rev-parse", "--verify", f"refs/tags/{value['version']}").stdout.strip()
|
||||
peeled = git(repository, "rev-parse", "--verify", f"refs/tags/{value['version']}^{{commit}}").stdout.strip()
|
||||
if tag_oid == peeled or peeled != value["target_oid"]:
|
||||
raise RuntimeError("FORMAL_TAG_BINDING_MISMATCH")
|
||||
requested = value.get("tag_oid")
|
||||
if requested and requested != tag_oid:
|
||||
raise RuntimeError("FORMAL_TAG_OBJECT_MISMATCH")
|
||||
return tag_oid, peeled
|
||||
|
||||
|
||||
def health(url: str, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
|
||||
raise RuntimeError("PRODUCTION_HEALTH_UNAVAILABLE") from exc
|
||||
if not (
|
||||
status == 200
|
||||
and body.get("version") == value["version"]
|
||||
and body.get("commit_oid") == value["target_oid"]
|
||||
and body.get("artifact_sha256") == value["artifact_sha256"]
|
||||
and body.get("deployment_state") == "ACTIVE"
|
||||
and body.get("production_deployed") is True
|
||||
):
|
||||
raise RuntimeError("PRODUCTION_HEALTH_BINDING_MISMATCH")
|
||||
return body
|
||||
|
||||
|
||||
def registry_record(value: Mapping[str, Any], tag_oid: str, production_health: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "aios.production-registry.v2",
|
||||
"version": value["version"],
|
||||
"repository": "aios/aios",
|
||||
"target_oid": value["target_oid"],
|
||||
"tag": value["version"],
|
||||
"tag_oid": tag_oid,
|
||||
"tag_object": tag_oid,
|
||||
"peeled_target_oid": value["target_oid"],
|
||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
||||
"artifact_sha256": value["artifact_sha256"],
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
"production_service": value["production_service"],
|
||||
"production_port": value["production_port"],
|
||||
"registered": True,
|
||||
"enabled": True,
|
||||
"production_deployed": True,
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"deployment_state": production_health["deployment_state"],
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
|
||||
|
||||
def registry_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||
return all(
|
||||
record.get(key) == expected
|
||||
for key, expected in {
|
||||
"version": value["version"],
|
||||
"target_oid": value["target_oid"],
|
||||
"tag_oid": tag_oid,
|
||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
||||
"artifact_sha256": value["artifact_sha256"],
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
"production_service": value["production_service"],
|
||||
"production_port": value["production_port"],
|
||||
"production_deployed": True,
|
||||
}.items()
|
||||
) and record.get("registered") is True and record.get("enabled") is True
|
||||
|
||||
|
||||
def baseline_matches(record: Mapping[str, Any], value: Mapping[str, Any], tag_oid: str) -> bool:
|
||||
current = record.get("current_baseline") or record.get("version")
|
||||
return current == value["version"] and all(
|
||||
record.get(key) == expected
|
||||
for key, expected in {
|
||||
"target_oid": value["target_oid"],
|
||||
"tag_oid": tag_oid,
|
||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
||||
"artifact_sha256": value["artifact_sha256"],
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
"production_service": value["production_service"],
|
||||
"production_port": value["production_port"],
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
}.items()
|
||||
)
|
||||
|
||||
|
||||
def registry_probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
tag_oid, _ = verify_local_tag(args.repository, value)
|
||||
path = args.registry_root / f"{value['version']}.json"
|
||||
if not path.exists():
|
||||
return exact_response(value, "NOT_APPLIED", tag_oid=tag_oid)
|
||||
record = json.loads(path.read_text(encoding="utf-8"))
|
||||
if registry_matches(record, value, tag_oid):
|
||||
return exact_response(
|
||||
value,
|
||||
"VERIFIED",
|
||||
tag_oid=tag_oid,
|
||||
registry_path=str(path),
|
||||
registry_sha256=sha256_file(path),
|
||||
)
|
||||
return exact_response(value, "CONFLICT", tag_oid=tag_oid, reason="registry_binding_mismatch")
|
||||
|
||||
|
||||
def registry_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
existing = registry_probe(args, value)
|
||||
if existing["status"] in {"VERIFIED", "CONFLICT"}:
|
||||
return existing
|
||||
tag_oid, _ = verify_local_tag(args.repository, value)
|
||||
production_health = health(args.health_url, value)
|
||||
path = args.registry_root / f"{value['version']}.json"
|
||||
atomic_json(path, registry_record(value, tag_oid, production_health))
|
||||
return registry_probe(args, value)
|
||||
|
||||
|
||||
def secure_baseline_authority_file(path: Path) -> None:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise RuntimeError("BASELINE_AUTHORITY_FILE_INVALID")
|
||||
baseline_stat = path.lstat()
|
||||
if baseline_stat.st_uid != 0 or baseline_stat.st_mode & 0o022:
|
||||
raise RuntimeError("BASELINE_AUTHORITY_PERMISSIONS_INVALID")
|
||||
|
||||
|
||||
def baseline_probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
tag_oid, _ = verify_local_tag(args.repository, value)
|
||||
secure_baseline_authority_file(args.baseline_path)
|
||||
record = json.loads(args.baseline_path.read_text(encoding="utf-8"))
|
||||
if baseline_matches(record, value, tag_oid):
|
||||
return exact_response(
|
||||
value,
|
||||
"VERIFIED",
|
||||
tag_oid=tag_oid,
|
||||
baseline_path=str(args.baseline_path),
|
||||
baseline_sha256=sha256_file(args.baseline_path),
|
||||
)
|
||||
current = record.get("current_baseline") or record.get("version")
|
||||
current_sha = sha256_file(args.baseline_path)
|
||||
if (
|
||||
current == args.expected_previous_baseline
|
||||
and current_sha == args.expected_previous_baseline_sha256
|
||||
):
|
||||
return exact_response(
|
||||
value,
|
||||
"NOT_APPLIED",
|
||||
tag_oid=tag_oid,
|
||||
previous_baseline=current,
|
||||
previous_baseline_sha256=current_sha,
|
||||
)
|
||||
return exact_response(
|
||||
value,
|
||||
"CONFLICT",
|
||||
tag_oid=tag_oid,
|
||||
reason="baseline_precondition_or_binding_mismatch",
|
||||
observed_baseline=current,
|
||||
observed_sha256=current_sha,
|
||||
)
|
||||
|
||||
|
||||
def baseline_apply(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
existing = baseline_probe(args, value)
|
||||
if existing["status"] in {"VERIFIED", "CONFLICT"}:
|
||||
return existing
|
||||
|
||||
tag_oid, _ = verify_local_tag(args.repository, value)
|
||||
production_health = health(args.health_url, value)
|
||||
|
||||
registry_path = args.registry_root / f"{value['version']}.json"
|
||||
if not registry_path.is_file():
|
||||
raise RuntimeError("REGISTRY_NOT_FINALIZED")
|
||||
registry = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
if not registry_matches(registry, value, tag_oid):
|
||||
raise RuntimeError("REGISTRY_BINDING_NOT_VERIFIED")
|
||||
|
||||
previous = json.loads(args.baseline_path.read_text(encoding="utf-8"))
|
||||
if sha256_file(args.baseline_path) != args.expected_previous_baseline_sha256:
|
||||
raise RuntimeError("BASELINE_CAS_MISMATCH")
|
||||
|
||||
generation = max(
|
||||
int(previous.get("generation", 0) or 0),
|
||||
int(previous.get("authority_generation", 0) or 0),
|
||||
) + 1
|
||||
record = {
|
||||
"schema": "aios.production-baseline.v2",
|
||||
"source_identity": "aios-gitops-runtime-anchor-baseline-authority-v1",
|
||||
"current_baseline": value["version"],
|
||||
"version": value["version"],
|
||||
"previous_baseline": args.expected_previous_baseline,
|
||||
"previous_version": args.expected_previous_baseline,
|
||||
"status": "PRODUCTION_DEPLOYED",
|
||||
"target_oid": value["target_oid"],
|
||||
"peeled_target_oid": value["target_oid"],
|
||||
"tag_oid": tag_oid,
|
||||
"evidence_closure_oid": value["evidence_closure_oid"],
|
||||
"approval_plan_id": value["approval_plan_id"],
|
||||
"artifact_sha256": value["artifact_sha256"],
|
||||
"production_artifact_sha256": value["artifact_sha256"],
|
||||
"production_service": value["production_service"],
|
||||
"production_port": value["production_port"],
|
||||
"production_deployed": True,
|
||||
"deployment_state": production_health["deployment_state"],
|
||||
"registry_digest": sha256_file(registry_path),
|
||||
"registry_source": str(registry_path),
|
||||
"generation": generation,
|
||||
"authority_generation": generation,
|
||||
# The actual previously deployed production baseline is the rollback
|
||||
# target. V2R1C6 has a formal tag but was never production-deployed.
|
||||
"rollback_version": args.expected_previous_baseline,
|
||||
"rollback_tag_oid": previous.get("tag_oid"),
|
||||
"rollback_target_oid": previous.get("target_oid"),
|
||||
"promoted_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"promoted_by": "aios-approved-production-release-executor",
|
||||
}
|
||||
atomic_json(args.baseline_path, record)
|
||||
return baseline_probe(args, value)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=("registry", "baseline"),
|
||||
default=os.environ.get("AIOS_RELEASE_METADATA_MODE"),
|
||||
required=os.environ.get("AIOS_RELEASE_METADATA_MODE") is None,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repository",
|
||||
type=Path,
|
||||
default=Path(os.environ["AIOS_GITOPS_AUTHORITATIVE_GIT_DIR"]),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--registry-root",
|
||||
type=Path,
|
||||
default=Path(os.environ["AIOS_RUNTIME_REGISTRY_ROOT"]),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline-path",
|
||||
type=Path,
|
||||
default=Path(os.environ["AIOS_RUNTIME_BASELINE_PATH"]),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-previous-baseline",
|
||||
default=os.environ.get("AIOS_PREVIOUS_BASELINE", "V2R1C5"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expected-previous-baseline-sha256",
|
||||
default=os.environ.get("AIOS_PREVIOUS_BASELINE_SHA256"),
|
||||
required=os.environ.get("AIOS_PREVIOUS_BASELINE_SHA256") is None,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--health-url",
|
||||
default=os.environ.get("AIOS_RELEASE_HEALTH_URL", "http://127.0.0.1:18927/health"),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
value = load_request()
|
||||
operation = value.get("operation")
|
||||
if args.mode == "registry":
|
||||
if operation == "probe":
|
||||
result = registry_probe(args, value)
|
||||
elif operation == "apply":
|
||||
result = registry_apply(args, value)
|
||||
else:
|
||||
raise ValueError("unsupported_operation")
|
||||
else:
|
||||
if operation == "probe":
|
||||
result = baseline_probe(args, value)
|
||||
elif operation == "apply":
|
||||
result = baseline_apply(args, value)
|
||||
else:
|
||||
raise ValueError("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)
|
||||
@@ -0,0 +1,989 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed cross-code-line release migration planning.
|
||||
|
||||
The migration plan is data, not a shell script. It binds an admitted source to
|
||||
the exact currently observed staging code lines, the compatible state contract,
|
||||
and the rollback/verification contract. The plan can then be embedded in the
|
||||
existing signed ReleasePlan so Owner approval covers migration semantics without
|
||||
creating a second release authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:@/-]{2,255}$")
|
||||
PLAN_SCHEMA = "aios.release-migration-plan.v1"
|
||||
REQUIREMENT_SCHEMA = "aios.release-migration-requirement.v1"
|
||||
PROFILE_SCHEMA = "aios.release-migration-profile.v1"
|
||||
ALLOWED_STATE_MODES = frozenset({"REUSE_IN_PLACE_WITH_SNAPSHOT", "PRESERVE_UNUSED"})
|
||||
REQUIRED_PRECONDITIONS = (
|
||||
"writer_disabled",
|
||||
"task_quiescent",
|
||||
"state_snapshot_verified",
|
||||
"credentials_ready",
|
||||
"rollback_ready",
|
||||
)
|
||||
REQUIRED_VERIFICATION_CONTRACTS = (
|
||||
"runtime_identity_exact",
|
||||
"task_principal_no_bearer_e2e",
|
||||
"restart_recovery",
|
||||
"credential_projection",
|
||||
"release_consistency_no_unknown",
|
||||
)
|
||||
|
||||
|
||||
class MigrationPlanError(RuntimeError):
|
||||
"""Migration evidence is missing, malformed, stale, or unsafe."""
|
||||
|
||||
|
||||
class MigrationPlanRequired(MigrationPlanError):
|
||||
"""A cross-code-line release cannot proceed without an exact migration plan."""
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def _sha(value: object, label: str) -> str:
|
||||
text = str(value or "").lower()
|
||||
if not SHA_RE.fullmatch(text):
|
||||
raise MigrationPlanError(f"{label}_invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _oid(value: object, label: str) -> str:
|
||||
text = str(value or "").lower()
|
||||
if not OID_RE.fullmatch(text):
|
||||
raise MigrationPlanError(f"{label}_invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _ident(value: object, label: str) -> str:
|
||||
text = str(value or "")
|
||||
if not IDENT_RE.fullmatch(text):
|
||||
raise MigrationPlanError(f"{label}_invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _secure_json(path: Path, *, expected_owner_uid: int | None = 0) -> dict[str, Any]:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise MigrationPlanError(f"secure_json_unavailable:{path}") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
||||
raise MigrationPlanError("secure_json_regular_file_required")
|
||||
if expected_owner_uid is not None and os.name != "nt" and metadata.st_uid != expected_owner_uid:
|
||||
raise MigrationPlanError("secure_json_owner_invalid")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise MigrationPlanError("secure_json_mode_invalid")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise MigrationPlanError("secure_json_invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise MigrationPlanError("secure_json_object_required")
|
||||
return value
|
||||
|
||||
|
||||
def _durable_json(path: Path, value: Mapping[str, Any], *, mode: int = 0o640) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
encoded = _canonical_json(dict(value))
|
||||
if path.exists():
|
||||
current = path.read_bytes()
|
||||
if current != encoded:
|
||||
raise MigrationPlanError("migration_record_conflict")
|
||||
return
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(encoded)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
if os.name != "nt":
|
||||
os.chmod(path, mode)
|
||||
directory_fd = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _hash_files(root: Path, files: Sequence[str]) -> str:
|
||||
payload: dict[str, str] = {}
|
||||
resolved_root = root.resolve(strict=True)
|
||||
for relative in files:
|
||||
rel = Path(relative)
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
raise MigrationPlanError("interface_path_invalid")
|
||||
path = (resolved_root / rel).resolve(strict=True)
|
||||
try:
|
||||
path.relative_to(resolved_root)
|
||||
except ValueError as exc:
|
||||
raise MigrationPlanError("interface_path_escape") from exc
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise MigrationPlanError("interface_file_invalid")
|
||||
payload[relative] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
||||
|
||||
|
||||
def _git_blob(git_dir: Path, source_oid: str, relative: str) -> bytes:
|
||||
rel = Path(relative)
|
||||
if rel.is_absolute() or ".." in rel.parts:
|
||||
raise MigrationPlanError("target_interface_path_invalid")
|
||||
completed = subprocess.run(
|
||||
["git", f"--git-dir={git_dir}", "show", f"{source_oid}:{relative}"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise MigrationPlanError("target_interface_unavailable")
|
||||
return bytes(completed.stdout)
|
||||
|
||||
|
||||
def _hash_git_files(git_dir: Path, source_oid: str, files: Sequence[str]) -> str:
|
||||
payload = {
|
||||
relative: hashlib.sha256(_git_blob(git_dir, source_oid, relative)).hexdigest()
|
||||
for relative in files
|
||||
}
|
||||
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
||||
|
||||
|
||||
def _systemd_properties(service: str) -> dict[str, Any]:
|
||||
_ident(service, "service")
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"systemctl",
|
||||
"show",
|
||||
service,
|
||||
"--property=WorkingDirectory",
|
||||
"--property=Environment",
|
||||
"--property=FragmentPath",
|
||||
"--property=ActiveState",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=20,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise MigrationPlanError("systemd_observation_unavailable")
|
||||
result: dict[str, Any] = {}
|
||||
for line in completed.stdout.splitlines():
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
result[key] = value
|
||||
if result.get("ActiveState") != "active":
|
||||
raise MigrationPlanError("component_not_active")
|
||||
environment: dict[str, str] = {}
|
||||
for token in str(result.get("Environment") or "").split():
|
||||
if "=" in token:
|
||||
key, value = token.split("=", 1)
|
||||
environment[key] = value.strip('"')
|
||||
result["environment"] = environment
|
||||
return result
|
||||
|
||||
|
||||
def _sqlite_schema(connection: sqlite3.Connection) -> dict[str, Any]:
|
||||
tables = [
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
]
|
||||
result: dict[str, Any] = {}
|
||||
for table in tables:
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", table):
|
||||
raise MigrationPlanError("sqlite_table_name_invalid")
|
||||
columns = []
|
||||
for row in connection.execute(f"PRAGMA table_info({table})"):
|
||||
columns.append(
|
||||
{
|
||||
"cid": int(row[0]),
|
||||
"name": str(row[1]),
|
||||
"type": str(row[2]),
|
||||
"notnull": int(row[3]),
|
||||
"default": row[4],
|
||||
"pk": int(row[5]),
|
||||
}
|
||||
)
|
||||
result[table] = {
|
||||
"columns": columns,
|
||||
"row_count": int(connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _schema_digest(schema: Mapping[str, Any]) -> str:
|
||||
shape = {
|
||||
table: {"columns": value["columns"]}
|
||||
for table, value in sorted(schema.items())
|
||||
}
|
||||
return hashlib.sha256(_canonical_json(shape)).hexdigest()
|
||||
|
||||
|
||||
def _assert_additive_sqlite(before: Mapping[str, Any], after: Mapping[str, Any]) -> None:
|
||||
for table, old in before.items():
|
||||
if table not in after:
|
||||
raise MigrationPlanError("sqlite_table_removed")
|
||||
old_columns = {column["name"]: column for column in old["columns"]}
|
||||
new_columns = {column["name"]: column for column in after[table]["columns"]}
|
||||
for name, column in old_columns.items():
|
||||
if new_columns.get(name) != column:
|
||||
raise MigrationPlanError("sqlite_column_changed")
|
||||
for name, column in new_columns.items():
|
||||
if name in old_columns:
|
||||
continue
|
||||
# A new NOT NULL column is backward compatible only with a default.
|
||||
if column["notnull"] and column["default"] is None:
|
||||
raise MigrationPlanError("sqlite_new_column_unsafe")
|
||||
if int(after[table]["row_count"]) != int(old["row_count"]):
|
||||
raise MigrationPlanError("sqlite_row_count_changed_during_probe")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeComponentBinding:
|
||||
component_id: str
|
||||
service: str
|
||||
code_line_id: str
|
||||
source_oid: str
|
||||
artifact_sha256: str | None
|
||||
interface_sha256: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_ident(self.component_id, "component_id")
|
||||
_ident(self.service, "service")
|
||||
_ident(self.code_line_id, "code_line_id")
|
||||
_oid(self.source_oid, "source_oid")
|
||||
if self.artifact_sha256 is not None:
|
||||
_sha(self.artifact_sha256, "artifact_sha256")
|
||||
_sha(self.interface_sha256, "interface_sha256")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "RuntimeComponentBinding":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateMigrationContract:
|
||||
state_id: str
|
||||
state_ref: str
|
||||
mode: str
|
||||
before_schema_sha256: str
|
||||
after_schema_sha256: str
|
||||
snapshot_name: str
|
||||
snapshot_sha256: str
|
||||
snapshot_required: bool
|
||||
rollback_compatible: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_ident(self.state_id, "state_id")
|
||||
_ident(self.state_ref, "state_ref")
|
||||
if self.mode not in ALLOWED_STATE_MODES:
|
||||
raise MigrationPlanError("state_mode_invalid")
|
||||
_sha(self.before_schema_sha256, "before_schema_sha256")
|
||||
_sha(self.after_schema_sha256, "after_schema_sha256")
|
||||
_sha(self.snapshot_sha256, "snapshot_sha256")
|
||||
snapshot = Path(self.snapshot_name)
|
||||
if (
|
||||
snapshot.is_absolute()
|
||||
or ".." in snapshot.parts
|
||||
or not snapshot.parts
|
||||
or str(snapshot).startswith(".")
|
||||
):
|
||||
raise MigrationPlanError("snapshot_name_invalid")
|
||||
if self.snapshot_required is not True or self.rollback_compatible is not True:
|
||||
raise MigrationPlanError("state_recovery_contract_invalid")
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "StateMigrationContract":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationPlan:
|
||||
migration_id: str
|
||||
version: str
|
||||
candidate_id: str
|
||||
repository_identity: str
|
||||
source_admission_id: str
|
||||
source_commit: str
|
||||
tree_oid: str
|
||||
profile_id: str
|
||||
profile_sha256: str
|
||||
from_components: tuple[RuntimeComponentBinding, ...]
|
||||
to_components: tuple[RuntimeComponentBinding, ...]
|
||||
state_contracts: tuple[StateMigrationContract, ...]
|
||||
activation_schema: str
|
||||
activation_sha256: str
|
||||
rollback_window_seconds: int
|
||||
preconditions: tuple[str, ...] = REQUIRED_PRECONDITIONS
|
||||
verification_contracts: tuple[str, ...] = REQUIRED_VERIFICATION_CONTRACTS
|
||||
runtime_switch_capability: str = "runtime_switch"
|
||||
rollback_contract: str = "restore_previous_runtime_and_state_snapshot.v1"
|
||||
forward_recovery_contract: str = "forward_recovery_after_verified_rollback_block.v1"
|
||||
writer_expected: bool = False
|
||||
direct_db_allowed: bool = False
|
||||
direct_runtime_overwrite_allowed: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_ident(self.migration_id, "migration_id")
|
||||
_ident(self.version, "version")
|
||||
_ident(self.candidate_id, "candidate_id")
|
||||
if self.repository_identity != "aios/aios":
|
||||
raise MigrationPlanError("repository_identity_invalid")
|
||||
_ident(self.source_admission_id, "source_admission_id")
|
||||
_oid(self.source_commit, "source_commit")
|
||||
_oid(self.tree_oid, "tree_oid")
|
||||
_ident(self.profile_id, "profile_id")
|
||||
_sha(self.profile_sha256, "profile_sha256")
|
||||
_ident(self.activation_schema, "activation_schema")
|
||||
_sha(self.activation_sha256, "activation_sha256")
|
||||
if not 60 <= int(self.rollback_window_seconds) <= 86400:
|
||||
raise MigrationPlanError("rollback_window_invalid")
|
||||
if tuple(self.preconditions) != REQUIRED_PRECONDITIONS:
|
||||
raise MigrationPlanError("migration_preconditions_invalid")
|
||||
if tuple(self.verification_contracts) != REQUIRED_VERIFICATION_CONTRACTS:
|
||||
raise MigrationPlanError("migration_verification_contracts_invalid")
|
||||
if self.runtime_switch_capability != "runtime_switch":
|
||||
raise MigrationPlanError("migration_capability_invalid")
|
||||
if self.writer_expected or self.direct_db_allowed or self.direct_runtime_overwrite_allowed:
|
||||
raise MigrationPlanError("migration_unsafe_authority")
|
||||
if not self.from_components or not self.to_components or not self.state_contracts:
|
||||
raise MigrationPlanError("migration_contract_incomplete")
|
||||
from_ids = {item.component_id for item in self.from_components}
|
||||
to_ids = {item.component_id for item in self.to_components}
|
||||
if from_ids != to_ids or len(from_ids) != len(self.from_components) or len(to_ids) != len(self.to_components):
|
||||
raise MigrationPlanError("migration_component_binding_invalid")
|
||||
if all(
|
||||
next(item.code_line_id for item in self.from_components if item.component_id == target.component_id)
|
||||
== target.code_line_id
|
||||
for target in self.to_components
|
||||
):
|
||||
raise MigrationPlanError("migration_not_required")
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": PLAN_SCHEMA,
|
||||
"migration_id": self.migration_id,
|
||||
"version": self.version,
|
||||
"candidate_id": self.candidate_id,
|
||||
"repository_identity": self.repository_identity,
|
||||
"source_admission_id": self.source_admission_id,
|
||||
"source_commit": self.source_commit,
|
||||
"tree_oid": self.tree_oid,
|
||||
"profile_id": self.profile_id,
|
||||
"profile_sha256": self.profile_sha256,
|
||||
"from_components": [item.to_dict() for item in self.from_components],
|
||||
"to_components": [item.to_dict() for item in self.to_components],
|
||||
"state_contracts": [item.to_dict() for item in self.state_contracts],
|
||||
"activation_schema": self.activation_schema,
|
||||
"activation_sha256": self.activation_sha256,
|
||||
"rollback_window_seconds": self.rollback_window_seconds,
|
||||
"preconditions": list(self.preconditions),
|
||||
"verification_contracts": list(self.verification_contracts),
|
||||
"runtime_switch_capability": self.runtime_switch_capability,
|
||||
"rollback_contract": self.rollback_contract,
|
||||
"forward_recovery_contract": self.forward_recovery_contract,
|
||||
"writer_expected": self.writer_expected,
|
||||
"direct_db_allowed": self.direct_db_allowed,
|
||||
"direct_runtime_overwrite_allowed": self.direct_runtime_overwrite_allowed,
|
||||
}
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {**self.canonical_payload, "sha256": self.sha256}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "MigrationPlan":
|
||||
raw = dict(value)
|
||||
if raw.pop("schema", None) != PLAN_SCHEMA:
|
||||
raise MigrationPlanError("migration_plan_schema_invalid")
|
||||
claimed = raw.pop("sha256", None)
|
||||
raw["from_components"] = tuple(RuntimeComponentBinding.from_dict(item) for item in raw["from_components"])
|
||||
raw["to_components"] = tuple(RuntimeComponentBinding.from_dict(item) for item in raw["to_components"])
|
||||
raw["state_contracts"] = tuple(StateMigrationContract.from_dict(item) for item in raw["state_contracts"])
|
||||
raw["preconditions"] = tuple(raw.get("preconditions") or ())
|
||||
raw["verification_contracts"] = tuple(raw.get("verification_contracts") or ())
|
||||
plan = cls(**raw)
|
||||
if claimed is not None and claimed != plan.sha256:
|
||||
raise MigrationPlanError("migration_plan_sha256_mismatch")
|
||||
return plan
|
||||
|
||||
def assert_release_binding(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
candidate_id: str,
|
||||
source_commit: str,
|
||||
tree_oid: str,
|
||||
repository_identity: str,
|
||||
) -> None:
|
||||
expected = (
|
||||
self.version,
|
||||
self.candidate_id,
|
||||
self.source_commit,
|
||||
self.tree_oid,
|
||||
self.repository_identity,
|
||||
)
|
||||
actual = (version, candidate_id, source_commit, tree_oid, repository_identity)
|
||||
if actual != expected:
|
||||
raise MigrationPlanError("migration_release_binding_mismatch")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MigrationRequirement:
|
||||
version: str
|
||||
candidate_id: str
|
||||
source_commit: str
|
||||
repository_identity: str
|
||||
required: bool
|
||||
reason_code: str
|
||||
profile_id: str
|
||||
profile_sha256: str
|
||||
migration_plan_sha256: str | None
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": REQUIREMENT_SCHEMA,
|
||||
**asdict(self),
|
||||
}
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(_canonical_json(self.canonical_payload)).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {**self.canonical_payload, "sha256": self.sha256}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "MigrationRequirement":
|
||||
raw = dict(value)
|
||||
if raw.pop("schema", None) != REQUIREMENT_SCHEMA:
|
||||
raise MigrationPlanError("migration_requirement_schema_invalid")
|
||||
claimed = raw.pop("sha256", None)
|
||||
requirement = cls(**raw)
|
||||
_ident(requirement.version, "version")
|
||||
_ident(requirement.candidate_id, "candidate_id")
|
||||
_oid(requirement.source_commit, "source_commit")
|
||||
if requirement.repository_identity != "aios/aios":
|
||||
raise MigrationPlanError("migration_requirement_repository_invalid")
|
||||
_ident(requirement.reason_code, "reason_code")
|
||||
_ident(requirement.profile_id, "profile_id")
|
||||
_sha(requirement.profile_sha256, "profile_sha256")
|
||||
if requirement.required:
|
||||
_sha(requirement.migration_plan_sha256, "migration_plan_sha256")
|
||||
elif requirement.migration_plan_sha256 is not None:
|
||||
raise MigrationPlanError("unexpected_migration_plan")
|
||||
if claimed is not None and claimed != requirement.sha256:
|
||||
raise MigrationPlanError("migration_requirement_sha256_mismatch")
|
||||
return requirement
|
||||
|
||||
|
||||
class FileMigrationPlanResolver:
|
||||
"""Read-only resolver used by the trusted builder.
|
||||
|
||||
A profile declares that a release version is cross-code-line. Once a
|
||||
version is declared, absence of requirement/plan state is fail-closed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Path,
|
||||
*,
|
||||
profile_root: Path,
|
||||
expected_owner_uid: int | None = 0,
|
||||
):
|
||||
self.root = Path(root)
|
||||
self.profile_root = Path(profile_root)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
|
||||
def _profile_for_version(self, version: str) -> dict[str, Any] | None:
|
||||
matches = []
|
||||
if not self.profile_root.is_dir():
|
||||
return None
|
||||
for path in sorted(self.profile_root.glob("*.json")):
|
||||
profile = _secure_json(path, expected_owner_uid=self.expected_owner_uid)
|
||||
if profile.get("schema") != PROFILE_SCHEMA:
|
||||
raise MigrationPlanError("migration_profile_schema_invalid")
|
||||
if profile.get("version") == version:
|
||||
matches.append(profile)
|
||||
if len(matches) > 1:
|
||||
raise MigrationPlanError("migration_profile_ambiguous")
|
||||
return matches[0] if matches else None
|
||||
|
||||
def resolve(self, version: str, candidate_id: str, source_commit: str) -> MigrationPlan | None:
|
||||
profile = self._profile_for_version(version)
|
||||
if profile is None:
|
||||
return None
|
||||
base = self.root / source_commit
|
||||
requirement_path = base / "requirement.json"
|
||||
plan_path = base / "plan.json"
|
||||
if not requirement_path.is_file():
|
||||
raise MigrationPlanRequired("MIGRATION_PLAN_REQUIRED")
|
||||
requirement = MigrationRequirement.from_dict(
|
||||
_secure_json(requirement_path, expected_owner_uid=self.expected_owner_uid)
|
||||
)
|
||||
expected = (version, candidate_id, source_commit, "aios/aios")
|
||||
actual = (
|
||||
requirement.version,
|
||||
requirement.candidate_id,
|
||||
requirement.source_commit,
|
||||
requirement.repository_identity,
|
||||
)
|
||||
if actual != expected:
|
||||
raise MigrationPlanError("migration_requirement_binding_mismatch")
|
||||
if requirement.profile_id != profile.get("profile_id"):
|
||||
raise MigrationPlanError("migration_profile_binding_mismatch")
|
||||
profile_sha = hashlib.sha256(_canonical_json(profile)).hexdigest()
|
||||
if requirement.profile_sha256 != profile_sha:
|
||||
raise MigrationPlanError("migration_profile_digest_mismatch")
|
||||
if not requirement.required:
|
||||
return None
|
||||
if not plan_path.is_file():
|
||||
raise MigrationPlanRequired("MIGRATION_PLAN_REQUIRED")
|
||||
plan = MigrationPlan.from_dict(
|
||||
_secure_json(plan_path, expected_owner_uid=self.expected_owner_uid)
|
||||
)
|
||||
plan.assert_release_binding(
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
source_commit=source_commit,
|
||||
tree_oid=plan.tree_oid,
|
||||
repository_identity="aios/aios",
|
||||
)
|
||||
if requirement.migration_plan_sha256 != plan.sha256:
|
||||
raise MigrationPlanError("migration_requirement_plan_mismatch")
|
||||
return plan
|
||||
|
||||
|
||||
class MigrationPreSwitchValidator:
|
||||
"""Validates a root-owned live proof against the signed migration plan."""
|
||||
|
||||
def __init__(self, proof_resolver: Callable[[MigrationPlan], Mapping[str, Any]]):
|
||||
self.proof_resolver = proof_resolver
|
||||
|
||||
def validate(self, migration_plan: MigrationPlan | None) -> dict[str, Any]:
|
||||
if migration_plan is None:
|
||||
return {"required": False, "verified": True}
|
||||
proof = dict(self.proof_resolver(migration_plan))
|
||||
snapshot_digests = sorted(
|
||||
contract.snapshot_sha256 for contract in migration_plan.state_contracts
|
||||
)
|
||||
required = {
|
||||
"migration_plan_sha256": migration_plan.sha256,
|
||||
"migration_id": migration_plan.migration_id,
|
||||
"source_commit": migration_plan.source_commit,
|
||||
"tree_oid": migration_plan.tree_oid,
|
||||
"activation_sha256": migration_plan.activation_sha256,
|
||||
"state_snapshot_sha256s": snapshot_digests,
|
||||
"writer_disabled": True,
|
||||
"task_quiescent": True,
|
||||
"state_snapshot_verified": True,
|
||||
"credentials_ready": True,
|
||||
"rollback_ready": True,
|
||||
}
|
||||
failed = [key for key, expected in required.items() if proof.get(key) != expected]
|
||||
if failed:
|
||||
raise MigrationPlanError(
|
||||
"MIGRATION_PRE_SWITCH_PROOF_INVALID:" + ",".join(sorted(failed))
|
||||
)
|
||||
return {"required": True, "verified": True, **proof}
|
||||
|
||||
|
||||
class HostMigrationCompiler:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
authority_git_dir: Path,
|
||||
profile_path: Path,
|
||||
output_root: Path,
|
||||
expected_owner_uid: int | None = 0,
|
||||
):
|
||||
self.authority_git_dir = Path(authority_git_dir)
|
||||
self.profile_path = Path(profile_path)
|
||||
self.output_root = Path(output_root)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
|
||||
def _profile(self) -> dict[str, Any]:
|
||||
profile = _secure_json(self.profile_path, expected_owner_uid=self.expected_owner_uid)
|
||||
required = {
|
||||
"schema",
|
||||
"profile_id",
|
||||
"version",
|
||||
"repository",
|
||||
"current_components",
|
||||
"target_components",
|
||||
"activation_file",
|
||||
"state_ref",
|
||||
"state_db",
|
||||
"state_mode",
|
||||
"rollback_window_seconds",
|
||||
}
|
||||
if set(profile) != required or profile.get("schema") != PROFILE_SCHEMA:
|
||||
raise MigrationPlanError("migration_profile_fields_invalid")
|
||||
if profile.get("repository") != "aios/aios":
|
||||
raise MigrationPlanError("migration_profile_repository_invalid")
|
||||
_ident(profile.get("profile_id"), "profile_id")
|
||||
_ident(profile.get("version"), "version")
|
||||
if profile.get("state_mode") not in ALLOWED_STATE_MODES:
|
||||
raise MigrationPlanError("migration_profile_state_mode_invalid")
|
||||
return profile
|
||||
|
||||
@staticmethod
|
||||
def _component_from_live(spec: Mapping[str, Any]) -> RuntimeComponentBinding:
|
||||
required = {
|
||||
"component_id",
|
||||
"service",
|
||||
"code_line_id",
|
||||
"expected_source_oid",
|
||||
"source_oid_env",
|
||||
"artifact_sha_env",
|
||||
"interface_files",
|
||||
"marker_files",
|
||||
}
|
||||
if set(spec) != required:
|
||||
raise MigrationPlanError("current_component_profile_invalid")
|
||||
properties = _systemd_properties(str(spec["service"]))
|
||||
environment = properties["environment"]
|
||||
source_oid = _oid(environment.get(str(spec["source_oid_env"])), "current_source_oid")
|
||||
expected_oid = _oid(spec["expected_source_oid"], "expected_source_oid")
|
||||
if source_oid != expected_oid:
|
||||
raise MigrationPlanError("CURRENT_RUNTIME_DRIFT")
|
||||
working = Path(str(properties.get("WorkingDirectory") or ""))
|
||||
if not working.is_absolute() or not working.is_dir():
|
||||
raise MigrationPlanError("current_working_directory_invalid")
|
||||
for marker in spec["marker_files"]:
|
||||
if not (working / str(marker)).is_file():
|
||||
raise MigrationPlanError("current_code_line_marker_missing")
|
||||
interface = _hash_files(working, tuple(str(item) for item in spec["interface_files"]))
|
||||
raw_artifact = environment.get(str(spec["artifact_sha_env"]))
|
||||
artifact = str(raw_artifact or "").lower()
|
||||
artifact_value = artifact if SHA_RE.fullmatch(artifact) else None
|
||||
return RuntimeComponentBinding(
|
||||
component_id=str(spec["component_id"]),
|
||||
service=str(spec["service"]),
|
||||
code_line_id=str(spec["code_line_id"]),
|
||||
source_oid=source_oid,
|
||||
artifact_sha256=artifact_value,
|
||||
interface_sha256=interface,
|
||||
)
|
||||
|
||||
def _component_from_target(
|
||||
self, spec: Mapping[str, Any], *, source_commit: str
|
||||
) -> RuntimeComponentBinding:
|
||||
required = {"component_id", "service", "code_line_id", "interface_files"}
|
||||
if set(spec) != required:
|
||||
raise MigrationPlanError("target_component_profile_invalid")
|
||||
interface_files = tuple(str(item) for item in spec["interface_files"])
|
||||
interface = _hash_git_files(self.authority_git_dir, source_commit, interface_files)
|
||||
return RuntimeComponentBinding(
|
||||
component_id=str(spec["component_id"]),
|
||||
service=str(spec["service"]),
|
||||
code_line_id=str(spec["code_line_id"]),
|
||||
source_oid=source_commit,
|
||||
artifact_sha256=None,
|
||||
interface_sha256=interface,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _state_compatibility(
|
||||
state_db: Path,
|
||||
state_ref: str,
|
||||
*,
|
||||
snapshot_path: Path,
|
||||
snapshot_name: str,
|
||||
) -> StateMigrationContract:
|
||||
from aios_task_admission_bootstrap import TaskAdmissionStore
|
||||
|
||||
if state_db.is_symlink() or not state_db.is_file():
|
||||
raise MigrationPlanError("state_db_invalid")
|
||||
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="aios-migration-probe-") as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
consistent_snapshot = temp_root / "pre-migration.sqlite3"
|
||||
probe_path = temp_root / "schema-probe.sqlite3"
|
||||
with sqlite3.connect(
|
||||
f"file:{state_db}?mode=ro", uri=True
|
||||
) as live, sqlite3.connect(consistent_snapshot) as copy:
|
||||
live.backup(copy)
|
||||
with sqlite3.connect(consistent_snapshot) as frozen:
|
||||
before = _sqlite_schema(frozen)
|
||||
# Probe target initialization only on a second private copy. The
|
||||
# rollback snapshot remains byte-for-byte pre-migration state.
|
||||
with sqlite3.connect(consistent_snapshot) as source, sqlite3.connect(
|
||||
probe_path
|
||||
) as probe:
|
||||
source.backup(probe)
|
||||
TaskAdmissionStore(probe_path)
|
||||
with sqlite3.connect(probe_path) as migrated:
|
||||
after = _sqlite_schema(migrated)
|
||||
required_tables = {
|
||||
"task_admission_envelopes",
|
||||
"task_admission_grants",
|
||||
"task_admission_receipts",
|
||||
}
|
||||
if not required_tables.issubset(after):
|
||||
raise MigrationPlanError("INCOMPATIBLE_STATE_SCHEMA")
|
||||
_assert_additive_sqlite(before, after)
|
||||
snapshot_bytes = consistent_snapshot.read_bytes()
|
||||
snapshot_sha = hashlib.sha256(snapshot_bytes).hexdigest()
|
||||
if snapshot_path.exists():
|
||||
if snapshot_path.is_symlink() or not snapshot_path.is_file():
|
||||
raise MigrationPlanError("migration_snapshot_invalid")
|
||||
if hashlib.sha256(snapshot_path.read_bytes()).hexdigest() != snapshot_sha:
|
||||
raise MigrationPlanError("migration_snapshot_conflict")
|
||||
else:
|
||||
temporary = snapshot_path.with_name(snapshot_path.name + ".tmp")
|
||||
descriptor = os.open(
|
||||
temporary,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(snapshot_bytes)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, snapshot_path)
|
||||
if os.name != "nt":
|
||||
os.chmod(snapshot_path, 0o640)
|
||||
directory_fd = os.open(snapshot_path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return StateMigrationContract(
|
||||
state_id="task-admission",
|
||||
state_ref=state_ref,
|
||||
mode="REUSE_IN_PLACE_WITH_SNAPSHOT",
|
||||
before_schema_sha256=_schema_digest(before),
|
||||
after_schema_sha256=_schema_digest(after),
|
||||
snapshot_name=snapshot_name,
|
||||
snapshot_sha256=snapshot_sha,
|
||||
snapshot_required=True,
|
||||
rollback_compatible=True,
|
||||
)
|
||||
|
||||
def compile(
|
||||
self,
|
||||
*,
|
||||
source_admission: Mapping[str, Any],
|
||||
candidate_id: str,
|
||||
) -> tuple[MigrationRequirement, MigrationPlan]:
|
||||
profile = self._profile()
|
||||
version = str(source_admission.get("version") or "")
|
||||
source_commit = _oid(source_admission.get("source_commit"), "source_commit")
|
||||
tree_oid = _oid(source_admission.get("tree_oid"), "tree_oid")
|
||||
admission_id = _ident(source_admission.get("admission_id"), "source_admission_id")
|
||||
if (
|
||||
version != profile["version"]
|
||||
or source_admission.get("repository") != "aios/aios"
|
||||
):
|
||||
raise MigrationPlanError("source_admission_profile_mismatch")
|
||||
_ident(candidate_id, "candidate_id")
|
||||
profile_sha = hashlib.sha256(_canonical_json(profile)).hexdigest()
|
||||
from_components = tuple(
|
||||
self._component_from_live(item) for item in profile["current_components"]
|
||||
)
|
||||
to_components = tuple(
|
||||
self._component_from_target(item, source_commit=source_commit)
|
||||
for item in profile["target_components"]
|
||||
)
|
||||
if {item.component_id for item in from_components} != {item.component_id for item in to_components}:
|
||||
raise MigrationPlanError("migration_component_profile_mismatch")
|
||||
required = any(
|
||||
next(old.code_line_id for old in from_components if old.component_id == new.component_id)
|
||||
!= new.code_line_id
|
||||
for new in to_components
|
||||
)
|
||||
if not required:
|
||||
raise MigrationPlanError("migration_profile_does_not_require_transition")
|
||||
activation_path = Path(str(profile["activation_file"]))
|
||||
activation = _secure_json(activation_path, expected_owner_uid=self.expected_owner_uid)
|
||||
if activation.get("schema") != "aios.task-admission-host-activation.v1":
|
||||
raise MigrationPlanError("activation_schema_invalid")
|
||||
state_path = Path(str(profile["state_db"]))
|
||||
if str(activation.get("state_db") or "") != str(state_path):
|
||||
raise MigrationPlanError("activation_state_db_mismatch")
|
||||
root = self.output_root / source_commit
|
||||
snapshot_name = "snapshots/task-admission.sqlite3"
|
||||
state_contract = self._state_compatibility(
|
||||
state_path,
|
||||
str(profile["state_ref"]),
|
||||
snapshot_path=root / snapshot_name,
|
||||
snapshot_name=snapshot_name,
|
||||
)
|
||||
migration_id = (
|
||||
"migration-"
|
||||
+ hashlib.sha256(
|
||||
f"{version}\0{candidate_id}\0{source_commit}\0{profile_sha}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
)
|
||||
plan = MigrationPlan(
|
||||
migration_id=migration_id,
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
repository_identity="aios/aios",
|
||||
source_admission_id=admission_id,
|
||||
source_commit=source_commit,
|
||||
tree_oid=tree_oid,
|
||||
profile_id=str(profile["profile_id"]),
|
||||
profile_sha256=profile_sha,
|
||||
from_components=from_components,
|
||||
to_components=to_components,
|
||||
state_contracts=(state_contract,),
|
||||
activation_schema=str(activation["schema"]),
|
||||
activation_sha256=hashlib.sha256(_canonical_json(activation)).hexdigest(),
|
||||
rollback_window_seconds=int(profile["rollback_window_seconds"]),
|
||||
)
|
||||
requirement = MigrationRequirement(
|
||||
version=version,
|
||||
candidate_id=candidate_id,
|
||||
source_commit=source_commit,
|
||||
repository_identity="aios/aios",
|
||||
required=True,
|
||||
reason_code="CROSS_CODE_LINE",
|
||||
profile_id=str(profile["profile_id"]),
|
||||
profile_sha256=profile_sha,
|
||||
migration_plan_sha256=plan.sha256,
|
||||
)
|
||||
_durable_json(root / "requirement.json", requirement.to_dict())
|
||||
_durable_json(root / "plan.json", plan.to_dict())
|
||||
return requirement, plan
|
||||
|
||||
|
||||
def _verified_source_admission(
|
||||
*,
|
||||
source_admission_root: Path,
|
||||
source_commit: str,
|
||||
builder_policy: Path,
|
||||
credential_name: str = "source_admission_hmac",
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve one source admission through its existing HMAC verifier.
|
||||
|
||||
The migration planner never trusts a caller-selected admission JSON path.
|
||||
It consumes only a root-owned admission store and the independent
|
||||
``source_admission_hmac`` credential projected by systemd.
|
||||
"""
|
||||
|
||||
from release_source_admission import open_source_admission_store
|
||||
|
||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY", "").strip()
|
||||
if not credentials:
|
||||
raise MigrationPlanError("source_admission_credentials_unavailable")
|
||||
credential_path = Path(credentials) / credential_name
|
||||
try:
|
||||
hmac_key = credential_path.read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise MigrationPlanError("source_admission_hmac_unavailable") from exc
|
||||
try:
|
||||
store = open_source_admission_store(
|
||||
source_admission_root,
|
||||
repository="aios/aios",
|
||||
policy=builder_policy,
|
||||
hmac_key=hmac_key,
|
||||
expected_owner_uid=0 if os.name != "nt" else None,
|
||||
writable=False,
|
||||
)
|
||||
record = store.lookup_source(source_commit)
|
||||
except Exception as exc:
|
||||
raise MigrationPlanError("source_admission_verification_failed") from exc
|
||||
if record is None:
|
||||
raise MigrationPlanRequired("SOURCE_ADMISSION_REQUIRED")
|
||||
return dict(record)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--authority-git-dir", required=True, type=Path)
|
||||
parser.add_argument("--profile", required=True, type=Path)
|
||||
parser.add_argument("--output-root", required=True, type=Path)
|
||||
parser.add_argument("--source-admission-root", required=True, type=Path)
|
||||
parser.add_argument("--builder-policy", required=True, type=Path)
|
||||
parser.add_argument("--source-commit", required=True)
|
||||
parser.add_argument("--candidate-id", required=True)
|
||||
args = parser.parse_args()
|
||||
source_commit = _oid(args.source_commit, "source_commit")
|
||||
compiler = HostMigrationCompiler(
|
||||
authority_git_dir=args.authority_git_dir,
|
||||
profile_path=args.profile,
|
||||
output_root=args.output_root,
|
||||
expected_owner_uid=0 if os.name != "nt" else None,
|
||||
)
|
||||
requirement, plan = compiler.compile(
|
||||
source_admission=_verified_source_admission(
|
||||
source_admission_root=args.source_admission_root,
|
||||
source_commit=source_commit,
|
||||
builder_policy=args.builder_policy,
|
||||
),
|
||||
candidate_id=args.candidate_id,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "MIGRATION_PLAN_READY",
|
||||
"migration_plan_sha256": plan.sha256,
|
||||
"requirement_sha256": requirement.sha256,
|
||||
"source_commit": plan.source_commit,
|
||||
"version": plan.version,
|
||||
},
|
||||
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)
|
||||
@@ -0,0 +1,894 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Governed admission of an exact remote Git commit into the release source closure.
|
||||
|
||||
The admitter is intentionally separate from the trusted candidate builder:
|
||||
it may use a narrowly-scoped Gitea read/fetch credential, while the builder
|
||||
remains PrivateNetwork=true and reads only the authoritative Git object store
|
||||
plus immutable admission records.
|
||||
|
||||
An admission never moves ``main`` and never treats an arbitrary caller-supplied
|
||||
OID as trusted. It binds:
|
||||
remote branch ref -> exact full commit OID -> authoritative Git object/tree
|
||||
-> current authoritative main ancestry -> immutable release recipe metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from authoritative_git import (
|
||||
AuthoritativeGitError,
|
||||
authoritative_head_oid,
|
||||
authoritative_lineage,
|
||||
run_authoritative_git,
|
||||
)
|
||||
from aios_gitea_machine_transport import GiteaMachineTransport, GiteaTransportError
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
SCHEMA = "aios.release-source-admission.v1"
|
||||
RECIPE_MODE = "governed-source-admission"
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,199}$")
|
||||
_BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
||||
_REF_PREFIX = "refs/heads/"
|
||||
|
||||
|
||||
class ReleaseSourceAdmissionError(RuntimeError):
|
||||
"""A stable fail-closed source-admission error code."""
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
dict(value),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _oid(value: object, field: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not _OID_RE.fullmatch(text):
|
||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _digest(value: object, field: str) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not _DIGEST_RE.fullmatch(text):
|
||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _identifier(value: object, field: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not _IDENTIFIER_RE.fullmatch(text) or ".." in text or "\x00" in text:
|
||||
raise ReleaseSourceAdmissionError(f"{field.upper()}_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _source_ref(value: object) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text.startswith(_REF_PREFIX):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_INVALID")
|
||||
branch = text[len(_REF_PREFIX):]
|
||||
if (
|
||||
not _BRANCH_RE.fullmatch(branch)
|
||||
or ".." in branch
|
||||
or "//" in branch
|
||||
or "@{" in branch
|
||||
or branch.endswith(("/", ".", ".lock"))
|
||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
||||
):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_INVALID")
|
||||
return text
|
||||
|
||||
|
||||
def _version(value: object) -> str:
|
||||
try:
|
||||
return validate_release_version(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ReleaseSourceAdmissionError("VERSION_INVALID") from exc
|
||||
|
||||
|
||||
def _version_slug(version: str) -> str:
|
||||
value = re.sub(r"[^A-Za-z0-9]+", "-", version.lower()).strip("-")
|
||||
if not value:
|
||||
raise ReleaseSourceAdmissionError("VERSION_INVALID")
|
||||
return value[:80]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _secure_stat(path: Path, *, expected_owner_uid: int | None, directory: bool) -> None:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNAVAILABLE") from exc
|
||||
if path.is_symlink():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
||||
if directory and not path.is_dir():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
||||
if not directory and not path.is_file():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNSAFE")
|
||||
if expected_owner_uid is not None and info.st_uid != expected_owner_uid:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_OWNER_MISMATCH")
|
||||
# Group/other write would allow an untrusted process to forge release input.
|
||||
if info.st_mode & 0o022:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_PERMISSIONS_INVALID")
|
||||
|
||||
|
||||
def _validate_record(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
repository: str,
|
||||
policy_sha256: str,
|
||||
hmac_key: bytes,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
||||
required = {
|
||||
"schema",
|
||||
"admission_id",
|
||||
"repository",
|
||||
"task_id",
|
||||
"version",
|
||||
"candidate_id",
|
||||
"base_oid",
|
||||
"source_ref",
|
||||
"source_commit",
|
||||
"remote_ref_oid",
|
||||
"tree_oid",
|
||||
"purpose",
|
||||
"policy_sha256",
|
||||
"admitted_at",
|
||||
"mac",
|
||||
}
|
||||
if set(value) != required or value.get("schema") != SCHEMA:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
||||
record = dict(value)
|
||||
if _identifier(record["repository"], "repository") != repository:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REPOSITORY_MISMATCH")
|
||||
_identifier(record["task_id"], "task_id")
|
||||
record["version"] = _version(record["version"])
|
||||
_identifier(record["candidate_id"], "candidate_id")
|
||||
_identifier(record["purpose"], "purpose")
|
||||
_identifier(record["admission_id"], "admission_id")
|
||||
record["base_oid"] = _oid(record["base_oid"], "base_oid")
|
||||
record["source_commit"] = _oid(record["source_commit"], "source_commit")
|
||||
record["remote_ref_oid"] = _oid(record["remote_ref_oid"], "remote_ref_oid")
|
||||
record["tree_oid"] = _oid(record["tree_oid"], "tree_oid")
|
||||
record["source_ref"] = _source_ref(record["source_ref"])
|
||||
record["policy_sha256"] = _digest(record["policy_sha256"], "policy_sha256")
|
||||
if record["policy_sha256"] != policy_sha256:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_POLICY_MISMATCH")
|
||||
if record["remote_ref_oid"] != record["source_commit"]:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REMOTE_MISMATCH")
|
||||
admitted_at = record.get("admitted_at")
|
||||
if not isinstance(admitted_at, str) or not admitted_at.endswith("Z") or len(admitted_at) < 20:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_TIME_INVALID")
|
||||
supplied_mac = record.get("mac")
|
||||
if (
|
||||
not isinstance(supplied_mac, str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", supplied_mac)
|
||||
):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_MAC_INVALID")
|
||||
unsigned = {key: value for key, value in record.items() if key != "mac"}
|
||||
expected_mac = hmac.new(hmac_key, _canonical(unsigned), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(supplied_mac, expected_mac):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_MAC_INVALID")
|
||||
return record
|
||||
|
||||
|
||||
class ReleaseSourceBackend(Protocol):
|
||||
def authoritative_main_oid(self) -> str: ...
|
||||
def remote_ref_oid(self, source_ref: str) -> str: ...
|
||||
def fetch_exact_ref(self, source_ref: str, source_commit: str) -> str: ...
|
||||
def tree_oid(self, source_commit: str) -> str: ...
|
||||
def require_lineage(self, base_oid: str, source_commit: str) -> None: ...
|
||||
|
||||
|
||||
class ReleaseSourceAdmissionStore:
|
||||
"""Durable immutable admission records keyed by exact source commit."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: str | os.PathLike[str],
|
||||
*,
|
||||
repository: str,
|
||||
policy_sha256: str,
|
||||
hmac_key: bytes,
|
||||
expected_owner_uid: int | None = None,
|
||||
writable: bool = False,
|
||||
) -> None:
|
||||
self.root = Path(root)
|
||||
self.repository = _identifier(repository, "repository")
|
||||
self.policy_sha256 = _digest(policy_sha256, "policy_sha256")
|
||||
if not isinstance(hmac_key, (bytes, bytearray)) or len(hmac_key) < 32:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_HMAC_INVALID")
|
||||
self.hmac_key = bytes(hmac_key)
|
||||
self.expected_owner_uid = expected_owner_uid
|
||||
self.writable = bool(writable)
|
||||
if self.writable and not self.root.exists():
|
||||
self.root.mkdir(parents=True, mode=0o750)
|
||||
if self.root.exists():
|
||||
_secure_stat(
|
||||
self.root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=True,
|
||||
)
|
||||
|
||||
def _path(self, source_commit: str) -> Path:
|
||||
return self.root / f"{_oid(source_commit, 'source_commit')}.json"
|
||||
|
||||
def register(self, record: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not self.writable:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
||||
supplied = dict(record)
|
||||
if "mac" not in supplied:
|
||||
supplied["mac"] = hmac.new(
|
||||
self.hmac_key,
|
||||
_canonical(supplied),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
checked = _validate_record(
|
||||
supplied,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
path = self._path(checked["source_commit"])
|
||||
encoded = _canonical(checked) + b"\n"
|
||||
if path.exists():
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
try:
|
||||
existing = path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_UNAVAILABLE") from exc
|
||||
if existing == encoded:
|
||||
return dict(checked)
|
||||
try:
|
||||
previous = _validate_record(
|
||||
json.loads(existing),
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID") from exc
|
||||
comparable_previous = {
|
||||
key: value
|
||||
for key, value in previous.items()
|
||||
if key not in {"admitted_at", "mac"}
|
||||
}
|
||||
comparable_checked = {
|
||||
key: value
|
||||
for key, value in checked.items()
|
||||
if key not in {"admitted_at", "mac"}
|
||||
}
|
||||
if comparable_previous == comparable_checked:
|
||||
return dict(previous)
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CONFLICT")
|
||||
self.root.mkdir(parents=True, exist_ok=True, mode=0o750)
|
||||
_secure_stat(
|
||||
self.root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=True,
|
||||
)
|
||||
fd, raw_name = tempfile.mkstemp(
|
||||
prefix=f".{checked['source_commit']}.",
|
||||
suffix=".tmp",
|
||||
dir=self.root,
|
||||
)
|
||||
temp_path = Path(raw_name)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
with os.fdopen(fd, "wb", closefd=True) as handle:
|
||||
handle.write(encoded)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temp_path, path)
|
||||
os.chmod(path, 0o640)
|
||||
directory_fd = os.open(self.root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
return dict(checked)
|
||||
|
||||
def rebind_policy(
|
||||
self,
|
||||
source_commit: str,
|
||||
*,
|
||||
previous_policy_sha256: str,
|
||||
reason: str,
|
||||
expected_admission_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Rebind one immutable source identity to an explicitly authorized policy.
|
||||
|
||||
Only ``policy_sha256`` and ``mac`` change in the admission record.
|
||||
``admission_id`` and every source/candidate/tree binding remain immutable.
|
||||
A separate HMAC-authenticated history record makes the temporary §8.1
|
||||
trust expansion and its restoration auditable and idempotent.
|
||||
"""
|
||||
|
||||
if not self.writable:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
||||
previous_policy = _digest(
|
||||
previous_policy_sha256,
|
||||
"previous_policy_sha256",
|
||||
)
|
||||
reason_value = _identifier(reason, "reason")
|
||||
path = self._path(source_commit)
|
||||
if not path.exists():
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_REQUIRED")
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
decoded = json.loads(raw)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_RECORD_INVALID"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
previous = _validate_record(
|
||||
decoded,
|
||||
repository=self.repository,
|
||||
policy_sha256=previous_policy,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
except ReleaseSourceAdmissionError:
|
||||
# Idempotent replay after a successful rebind validates under the
|
||||
# current policy instead of silently weakening the old-policy gate.
|
||||
current = _validate_record(
|
||||
decoded,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
if expected_admission_id and current["admission_id"] != expected_admission_id:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_ID_MISMATCH"
|
||||
)
|
||||
return dict(current)
|
||||
|
||||
if expected_admission_id and previous["admission_id"] != expected_admission_id:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_ID_MISMATCH")
|
||||
rebound = {
|
||||
key: value
|
||||
for key, value in previous.items()
|
||||
if key != "mac"
|
||||
}
|
||||
rebound["policy_sha256"] = self.policy_sha256
|
||||
rebound["mac"] = hmac.new(
|
||||
self.hmac_key,
|
||||
_canonical(rebound),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
checked = _validate_record(
|
||||
rebound,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
if checked["admission_id"] != previous["admission_id"]:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_ID_CHANGED"
|
||||
)
|
||||
for field in (
|
||||
"repository",
|
||||
"version",
|
||||
"candidate_id",
|
||||
"base_oid",
|
||||
"source_ref",
|
||||
"source_commit",
|
||||
"remote_ref_oid",
|
||||
"tree_oid",
|
||||
"purpose",
|
||||
"task_id",
|
||||
):
|
||||
if checked[field] != previous[field]:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_REBIND_SCOPE_CHANGED"
|
||||
)
|
||||
|
||||
encoded = _canonical(checked) + b"\n"
|
||||
history_payload = {
|
||||
"schema": "aios.release-source-admission-policy-rebind.v1",
|
||||
"admission_id": checked["admission_id"],
|
||||
"source_commit": checked["source_commit"],
|
||||
"previous_policy_sha256": previous_policy,
|
||||
"policy_sha256": self.policy_sha256,
|
||||
"reason": reason_value,
|
||||
"record_sha256": hashlib.sha256(encoded).hexdigest(),
|
||||
"rebound_at": _now(),
|
||||
}
|
||||
history_payload["mac"] = hmac.new(
|
||||
self.hmac_key,
|
||||
_canonical(history_payload),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
history_root = self.root / "rebind-history"
|
||||
if not history_root.exists():
|
||||
history_root.mkdir(mode=0o750)
|
||||
_secure_stat(
|
||||
history_root,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=True,
|
||||
)
|
||||
history_name = (
|
||||
f"{checked['source_commit']}."
|
||||
f"{previous_policy[:12]}-{self.policy_sha256[:12]}."
|
||||
f"{hashlib.sha256(reason_value.encode()).hexdigest()[:12]}.json"
|
||||
)
|
||||
history_path = history_root / history_name
|
||||
history_bytes = _canonical(history_payload) + b"\n"
|
||||
|
||||
fd, raw_name = tempfile.mkstemp(
|
||||
prefix=f".{checked['source_commit']}.rebind.",
|
||||
suffix=".tmp",
|
||||
dir=self.root,
|
||||
)
|
||||
temp_path = Path(raw_name)
|
||||
try:
|
||||
os.fchmod(fd, 0o640)
|
||||
with os.fdopen(fd, "wb", closefd=True) as handle:
|
||||
handle.write(encoded)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if history_path.exists():
|
||||
_secure_stat(
|
||||
history_path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
if history_path.read_bytes() != history_bytes:
|
||||
raise ReleaseSourceAdmissionError(
|
||||
"SOURCE_ADMISSION_REBIND_HISTORY_CONFLICT"
|
||||
)
|
||||
else:
|
||||
history_fd = os.open(
|
||||
history_path,
|
||||
os.O_WRONLY
|
||||
| os.O_CREAT
|
||||
| os.O_EXCL
|
||||
| getattr(os, "O_NOFOLLOW", 0),
|
||||
0o640,
|
||||
)
|
||||
try:
|
||||
os.write(history_fd, history_bytes)
|
||||
os.fsync(history_fd)
|
||||
finally:
|
||||
os.close(history_fd)
|
||||
os.replace(temp_path, path)
|
||||
os.chmod(path, 0o640)
|
||||
directory_fd = os.open(
|
||||
self.root,
|
||||
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0),
|
||||
)
|
||||
try:
|
||||
os.fsync(directory_fd)
|
||||
finally:
|
||||
os.close(directory_fd)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
return dict(checked)
|
||||
|
||||
def lookup_source(self, source_commit: str) -> dict[str, Any] | None:
|
||||
path = self._path(source_commit)
|
||||
if not path.exists():
|
||||
return None
|
||||
_secure_stat(
|
||||
path,
|
||||
expected_owner_uid=self.expected_owner_uid,
|
||||
directory=False,
|
||||
)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
if len(raw) > 64 * 1024:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID")
|
||||
decoded = json.loads(raw)
|
||||
except ReleaseSourceAdmissionError:
|
||||
raise
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_RECORD_INVALID") from exc
|
||||
return _validate_record(
|
||||
decoded,
|
||||
repository=self.repository,
|
||||
policy_sha256=self.policy_sha256,
|
||||
hmac_key=self.hmac_key,
|
||||
)
|
||||
|
||||
def resolve_recipe(
|
||||
self,
|
||||
version: str,
|
||||
candidate_id: str | None,
|
||||
source_commit: str,
|
||||
) -> dict[str, str]:
|
||||
record = self.lookup_source(source_commit)
|
||||
if record is None:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_NOT_FOUND")
|
||||
if record["version"] != version:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_VERSION_MISMATCH")
|
||||
supplied_candidate = str(candidate_id or "")
|
||||
if supplied_candidate and supplied_candidate != record["candidate_id"]:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_CANDIDATE_MISMATCH")
|
||||
return {
|
||||
"mode": RECIPE_MODE,
|
||||
"task_id": record["task_id"],
|
||||
"candidate_id": record["candidate_id"],
|
||||
"base_oid": record["base_oid"],
|
||||
"source_commit": record["source_commit"],
|
||||
"version": record["version"],
|
||||
"purpose": record["purpose"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaReleaseSourceBackend:
|
||||
transport: GiteaMachineTransport
|
||||
git_dir: Path
|
||||
|
||||
def authoritative_main_oid(self) -> str:
|
||||
return authoritative_head_oid(git_dir=self.git_dir)
|
||||
|
||||
def remote_ref_oid(self, source_ref: str) -> str:
|
||||
ref = _source_ref(source_ref)
|
||||
branch = ref[len(_REF_PREFIX):]
|
||||
value = self.transport.read_branch(branch)
|
||||
if value is None:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_NOT_FOUND")
|
||||
return _oid(value, "remote_ref_oid")
|
||||
|
||||
def fetch_exact_ref(self, source_ref: str, source_commit: str) -> str:
|
||||
ref = _source_ref(source_ref)
|
||||
branch = ref[len(_REF_PREFIX):]
|
||||
try:
|
||||
value = self.transport.fetch_branch_exact(
|
||||
branch,
|
||||
_oid(source_commit, "source_commit"),
|
||||
)
|
||||
except GiteaTransportError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_FETCH_FAILED") from exc
|
||||
return _oid(value, "fetched_source_commit")
|
||||
|
||||
def tree_oid(self, source_commit: str) -> str:
|
||||
source = _oid(source_commit, "source_commit")
|
||||
try:
|
||||
result = run_authoritative_git(
|
||||
["rev-parse", "--verify", f"{source}^{{tree}}"],
|
||||
git_dir=self.git_dir,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_COMMIT_NOT_FOUND") from exc
|
||||
return _oid(result.stdout.strip().lower(), "tree_oid")
|
||||
|
||||
def require_lineage(self, base_oid: str, source_commit: str) -> None:
|
||||
try:
|
||||
authoritative_lineage(
|
||||
_oid(base_oid, "base_oid"),
|
||||
_oid(source_commit, "source_commit"),
|
||||
git_dir=self.git_dir,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_LINEAGE_MISMATCH") from exc
|
||||
|
||||
|
||||
class ReleaseSourceAdmitter:
|
||||
def __init__(
|
||||
self,
|
||||
store: ReleaseSourceAdmissionStore,
|
||||
backend: ReleaseSourceBackend,
|
||||
*,
|
||||
purpose: str = "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
) -> None:
|
||||
if not store.writable:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_ADMISSION_STORE_READ_ONLY")
|
||||
self.store = store
|
||||
self.backend = backend
|
||||
self.purpose = _identifier(purpose, "purpose")
|
||||
|
||||
def admit(
|
||||
self,
|
||||
*,
|
||||
version: str,
|
||||
task_id: str,
|
||||
source_ref: str,
|
||||
source_commit: str,
|
||||
) -> dict[str, Any]:
|
||||
version_value = _version(version)
|
||||
task_value = _identifier(task_id, "task_id")
|
||||
ref_value = _source_ref(source_ref)
|
||||
source_value = _oid(source_commit, "source_commit")
|
||||
|
||||
remote_before = self.backend.remote_ref_oid(ref_value)
|
||||
if remote_before != source_value:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_MOVED")
|
||||
base_oid = _oid(self.backend.authoritative_main_oid(), "base_oid")
|
||||
fetched = _oid(
|
||||
self.backend.fetch_exact_ref(ref_value, source_value),
|
||||
"fetched_source_commit",
|
||||
)
|
||||
if fetched != source_value:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_FETCH_MISMATCH")
|
||||
remote_after = self.backend.remote_ref_oid(ref_value)
|
||||
if remote_after != source_value:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_REF_MOVED")
|
||||
self.backend.require_lineage(base_oid, source_value)
|
||||
tree_oid = _oid(self.backend.tree_oid(source_value), "tree_oid")
|
||||
|
||||
candidate_id = f"rc-{_version_slug(version_value)}-{source_value[:12]}"
|
||||
admission_id = "source-admission-" + hashlib.sha256(
|
||||
f"{self.store.repository}\0{version_value}\0{source_value}\0{ref_value}".encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
record = {
|
||||
"schema": SCHEMA,
|
||||
"admission_id": admission_id,
|
||||
"repository": self.store.repository,
|
||||
"task_id": task_value,
|
||||
"version": version_value,
|
||||
"candidate_id": candidate_id,
|
||||
"base_oid": base_oid,
|
||||
"source_ref": ref_value,
|
||||
"source_commit": source_value,
|
||||
"remote_ref_oid": remote_after,
|
||||
"tree_oid": tree_oid,
|
||||
"purpose": self.purpose,
|
||||
"policy_sha256": self.store.policy_sha256,
|
||||
"admitted_at": _now(),
|
||||
}
|
||||
return self.store.register(record)
|
||||
|
||||
|
||||
def _sha256_file(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 source_admission_policy_sha256(path: Path) -> str:
|
||||
"""Canonical Source Admission policy binding: exact policy file bytes.
|
||||
|
||||
Builder/controller readiness may use a canonical JSON digest internally,
|
||||
but Source Admission records are intentionally bound to the immutable policy
|
||||
file bytes. Every admission producer/consumer must call this helper.
|
||||
"""
|
||||
|
||||
try:
|
||||
return _sha256_file(Path(path))
|
||||
except OSError as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def open_source_admission_store(
|
||||
root: str | os.PathLike[str],
|
||||
*,
|
||||
repository: str,
|
||||
policy: Path,
|
||||
hmac_key: bytes,
|
||||
expected_owner_uid: int | None,
|
||||
writable: bool,
|
||||
) -> ReleaseSourceAdmissionStore:
|
||||
"""The only supported constructor for policy-bound Source Admission stores."""
|
||||
|
||||
return ReleaseSourceAdmissionStore(
|
||||
root,
|
||||
repository=repository,
|
||||
policy_sha256=source_admission_policy_sha256(policy),
|
||||
hmac_key=hmac_key,
|
||||
expected_owner_uid=expected_owner_uid,
|
||||
writable=writable,
|
||||
)
|
||||
|
||||
|
||||
def validate_isolated_peer_policy_delta(
|
||||
previous_policy: Path,
|
||||
policy: Path,
|
||||
isolated_peer_cgroup: str,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Allow exactly one temporary isolated peer add/remove and no other drift."""
|
||||
|
||||
peer = str(isolated_peer_cgroup or "").strip()
|
||||
if (
|
||||
not peer.startswith("/system.slice/")
|
||||
or not peer.endswith(".service")
|
||||
or "*" in peer
|
||||
or ".." in peer
|
||||
):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
||||
try:
|
||||
from aios_trusted_candidate_builder import load_builder_policy
|
||||
|
||||
before = dict(load_builder_policy(Path(previous_policy)))
|
||||
after = dict(load_builder_policy(Path(policy)))
|
||||
except Exception as exc:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_INVALID") from exc
|
||||
|
||||
before_cgroups = tuple(before.get("allowed_peer_cgroups") or ())
|
||||
after_cgroups = tuple(after.get("allowed_peer_cgroups") or ())
|
||||
if len(set(before_cgroups)) != len(before_cgroups) or len(set(after_cgroups)) != len(after_cgroups):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
||||
if any("*" in value or ".." in value for value in (*before_cgroups, *after_cgroups)):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_INVALID")
|
||||
|
||||
before_without = dict(before)
|
||||
after_without = dict(after)
|
||||
before_without["allowed_peer_cgroups"] = []
|
||||
after_without["allowed_peer_cgroups"] = []
|
||||
if _canonical(before_without) != _canonical(after_without):
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_REBIND_SCOPE_CHANGED")
|
||||
|
||||
before_set, after_set = set(before_cgroups), set(after_cgroups)
|
||||
added = after_set - before_set
|
||||
removed = before_set - after_set
|
||||
if added == {peer} and not removed:
|
||||
direction = "ADD_ISOLATED_PEER"
|
||||
elif removed == {peer} and not added:
|
||||
direction = "REMOVE_ISOLATED_PEER"
|
||||
else:
|
||||
raise ReleaseSourceAdmissionError("SOURCE_POLICY_PEER_DELTA_INVALID")
|
||||
return (
|
||||
source_admission_policy_sha256(previous_policy),
|
||||
source_admission_policy_sha256(policy),
|
||||
direction,
|
||||
)
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
admit = subparsers.add_parser("admit")
|
||||
admit.add_argument("--version", required=True)
|
||||
admit.add_argument("--task-id", required=True)
|
||||
admit.add_argument("--source-ref", required=True)
|
||||
admit.add_argument("--source-commit", required=True)
|
||||
admit.add_argument("--policy", required=True, type=Path)
|
||||
admit.add_argument("--store-root", required=True, type=Path)
|
||||
|
||||
rebind = subparsers.add_parser("rebind-policy")
|
||||
rebind.add_argument("--source-commit", required=True)
|
||||
rebind.add_argument("--previous-policy", required=True, type=Path)
|
||||
rebind.add_argument("--policy", required=True, type=Path)
|
||||
rebind.add_argument("--isolated-peer-cgroup", required=True)
|
||||
rebind.add_argument("--expected-admission-id", required=True)
|
||||
rebind.add_argument("--reason", required=True)
|
||||
rebind.add_argument("--store-root", required=True, type=Path)
|
||||
|
||||
arguments = parser.parse_args()
|
||||
repository = os.environ.get("AIOS_GITOPS_REPOSITORY_IDENTITY", "")
|
||||
if not repository:
|
||||
raise SystemExit("AIOS_GITOPS_REPOSITORY_IDENTITY is required")
|
||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
||||
if not credentials:
|
||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
||||
try:
|
||||
admission_hmac = (
|
||||
Path(credentials) / "source_admission_hmac"
|
||||
).read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise SystemExit(
|
||||
"source_admission_hmac credential is required"
|
||||
) from exc
|
||||
|
||||
if arguments.command == "rebind-policy":
|
||||
try:
|
||||
previous_sha, policy_sha, direction = (
|
||||
validate_isolated_peer_policy_delta(
|
||||
arguments.previous_policy,
|
||||
arguments.policy,
|
||||
arguments.isolated_peer_cgroup,
|
||||
)
|
||||
)
|
||||
store = open_source_admission_store(
|
||||
arguments.store_root,
|
||||
repository=repository,
|
||||
policy=arguments.policy,
|
||||
hmac_key=admission_hmac,
|
||||
expected_owner_uid=(
|
||||
os.geteuid() if hasattr(os, "geteuid") else None
|
||||
),
|
||||
writable=True,
|
||||
)
|
||||
result = store.rebind_policy(
|
||||
arguments.source_commit,
|
||||
previous_policy_sha256=previous_sha,
|
||||
reason=f"{arguments.reason}:{direction}",
|
||||
expected_admission_id=arguments.expected_admission_id,
|
||||
)
|
||||
except ReleaseSourceAdmissionError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
**result,
|
||||
"rebind_direction": direction,
|
||||
"previous_policy_sha256": previous_sha,
|
||||
"policy_sha256": policy_sha,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
api_base = os.environ.get("AIOS_GITEA_API_BASE", "")
|
||||
remote_url = os.environ.get("AIOS_GITEA_REMOTE_URL", "")
|
||||
raw_git_dir = os.environ.get("AIOS_GITOPS_AUTHORITATIVE_GIT_DIR", "")
|
||||
if not api_base or not remote_url or not raw_git_dir:
|
||||
raise SystemExit("release source admission environment is incomplete")
|
||||
git_dir = Path(raw_git_dir)
|
||||
transport = GiteaMachineTransport(
|
||||
api_base=api_base,
|
||||
repository_id=repository,
|
||||
remote_url=remote_url,
|
||||
authority_git_dir=git_dir,
|
||||
credential_name=os.environ.get(
|
||||
"AIOS_GITEA_MACHINE_CREDENTIAL_NAME",
|
||||
"gitea-machine-token",
|
||||
),
|
||||
)
|
||||
store = open_source_admission_store(
|
||||
arguments.store_root,
|
||||
repository=repository,
|
||||
policy=arguments.policy,
|
||||
hmac_key=admission_hmac,
|
||||
expected_owner_uid=os.geteuid() if hasattr(os, "geteuid") else None,
|
||||
writable=True,
|
||||
)
|
||||
result = ReleaseSourceAdmitter(
|
||||
store,
|
||||
GiteaReleaseSourceBackend(transport=transport, git_dir=git_dir),
|
||||
).admit(
|
||||
version=arguments.version,
|
||||
task_id=arguments.task_id,
|
||||
source_ref=arguments.source_ref,
|
||||
source_commit=arguments.source_commit,
|
||||
)
|
||||
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -77,6 +77,87 @@ def _read(path: Path) -> dict[str, Any]:
|
||||
return value
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
"""Read a JSON file that may be root-written (0600 root:root) by the
|
||||
governed signer/validator. Mirrors `_read` but goes through
|
||||
`_root_file_bytes` so root-only result files are read via sudo."""
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise SignerContractError("contract_input_missing")
|
||||
try:
|
||||
raw = _root_file_bytes(path)
|
||||
except SignerContractError:
|
||||
raise
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SignerContractError("contract_input_invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SignerContractError("contract_object_required")
|
||||
return value
|
||||
|
||||
|
||||
def _root_is_readable(path: Path) -> bool:
|
||||
try:
|
||||
path.read_bytes()
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
def _read_root_config(config_path: Path) -> dict[str, Any]:
|
||||
"""Read the GrantRoot config. When the API service runs with governed sudo
|
||||
(signer/validator/provisioner invoke via `sudo -n --`), this config is
|
||||
root-only (0600); read it through sudo the same way instead of relaxing
|
||||
permissions or breaking the private-key isolation boundary."""
|
||||
if (
|
||||
os.getenv("AIOS_GITOPS_GOVERNED_USE_SUDO", "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
and not _root_is_readable(config_path)
|
||||
):
|
||||
sudo = os.getenv("AIOS_GITOPS_SUDO_PATH", "/usr/bin/sudo")
|
||||
result = subprocess.run(
|
||||
[sudo, "-n", "--", "cat", str(config_path)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SignerContractError(f"root_config_unreadable:{result.returncode}")
|
||||
try:
|
||||
value = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise SignerContractError("root_config_invalid") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SignerContractError("contract_object_required")
|
||||
return value
|
||||
return _read(config_path)
|
||||
|
||||
|
||||
def _root_file_bytes(path: Path) -> bytes:
|
||||
"""Read a raw file that may be root-only (0600 root:root). When the API
|
||||
service runs with governed sudo, signer/validator/provisioner write
|
||||
signature artifacts as root; read them through sudo the same way instead of
|
||||
relaxing permissions or breaking the private-key isolation boundary."""
|
||||
if (
|
||||
os.getenv("AIOS_GITOPS_GOVERNED_USE_SUDO", "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
and not _root_is_readable(path)
|
||||
):
|
||||
sudo = os.getenv("AIOS_GITOPS_SUDO_PATH", "/usr/bin/sudo")
|
||||
result = subprocess.run(
|
||||
[sudo, "-n", "--", "cat", str(path)],
|
||||
text=False,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SignerContractError(f"root_file_unreadable:{result.returncode}")
|
||||
return result.stdout
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def _write(path: Path, value: Any) -> None:
|
||||
if path.is_symlink():
|
||||
raise SignerContractError("contract_output_symlink")
|
||||
@@ -119,8 +200,10 @@ def _run(
|
||||
env=dict(env) if env is not None else None,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "").strip()[-400:]
|
||||
raise SignerContractError(
|
||||
f"deployed_tool_failed:{Path(argv[0]).name}:{result.returncode}"
|
||||
+ (f":{detail}" if detail else "")
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -162,7 +245,7 @@ def _manifest(grant_dir: Path, names: Sequence[str]) -> None:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise SignerContractError("manifest_input_missing")
|
||||
rows.append(
|
||||
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {name}"
|
||||
f"{hashlib.sha256(_root_file_bytes(path)).hexdigest()} {name}"
|
||||
)
|
||||
(grant_dir / "MANIFEST.sha256").write_text(
|
||||
"\n".join(rows) + "\n",
|
||||
@@ -321,7 +404,7 @@ def run_compatible_signer(
|
||||
config_path = Path(
|
||||
os.environ.get("AIOS_GRANT_ROOT_CONFIG", "/etc/aios/grant-root/config.json")
|
||||
)
|
||||
config = _read(config_path)
|
||||
config = _read_root_config(config_path)
|
||||
grant_namespace = str(config["grant_namespace"])
|
||||
approval_namespace = str(config["approval_namespace"])
|
||||
_sign(
|
||||
@@ -339,8 +422,10 @@ def run_compatible_signer(
|
||||
grant_dir / "signed-grant.key-id",
|
||||
)
|
||||
key_identity = (
|
||||
grant_dir / "signed-grant.key-id"
|
||||
).read_text(encoding="utf-8").strip()
|
||||
_root_file_bytes(grant_dir / "signed-grant.key-id")
|
||||
.decode("utf-8")
|
||||
.strip()
|
||||
)
|
||||
if not key_identity:
|
||||
raise SignerContractError("signer_identity_missing")
|
||||
_manifest(
|
||||
@@ -394,10 +479,9 @@ def _validator_config(
|
||||
root_config = Path(
|
||||
os.environ.get("AIOS_GRANT_ROOT_CONFIG", "/etc/aios/grant-root/config.json")
|
||||
)
|
||||
config = _read(root_config)
|
||||
config = _read_root_config(root_config)
|
||||
repo_path = (
|
||||
os.environ.get("AIOS_GITOPS_VALIDATOR_REPO_PATH")
|
||||
or os.environ.get("AIOS_GITOPS_WORKSPACE")
|
||||
or config.get("repo_path")
|
||||
)
|
||||
config.update(
|
||||
@@ -421,6 +505,42 @@ def _validator_config(
|
||||
return path
|
||||
|
||||
|
||||
def _validator_run_argv(
|
||||
validator_command: Sequence[str],
|
||||
config_path: Path,
|
||||
grant_dir: Path,
|
||||
result_path: Path,
|
||||
) -> list[str]:
|
||||
"""Build the validator argv. Production sudoers `Defaults env_reset` strips
|
||||
AIOS_GRANT_ROOT_CONFIG from sudo, so when the command already carries a
|
||||
sudo prefix we inject it explicitly via `sudo -n env VAR=... tool ...`.
|
||||
A direct (non-sudo) invocation keeps its original form."""
|
||||
validator_argv = list(validator_command)
|
||||
if Path(validator_argv[0]).name in {"sudo", "sudo.exe"}:
|
||||
rest = validator_argv[1:]
|
||||
tool_at = 0
|
||||
while tool_at < len(rest) and rest[tool_at].startswith("-"):
|
||||
tool_at += 1
|
||||
return [
|
||||
validator_argv[0],
|
||||
"-n",
|
||||
"env",
|
||||
f"AIOS_GRANT_ROOT_CONFIG={config_path}",
|
||||
*rest[tool_at:],
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
"--result-json",
|
||||
str(result_path),
|
||||
]
|
||||
return [
|
||||
*validator_command,
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
"--result-json",
|
||||
str(result_path),
|
||||
]
|
||||
|
||||
|
||||
def run_compatible_validator(
|
||||
validator_command: Sequence[str],
|
||||
grant_dir: Path,
|
||||
@@ -438,7 +558,7 @@ def run_compatible_validator(
|
||||
],
|
||||
timeout=60,
|
||||
)
|
||||
legacy = _read(result_path)
|
||||
legacy = _read_json(result_path)
|
||||
if legacy.get("status") not in {"PASS", "VALID", "COMPLETED"}:
|
||||
raise SignerContractError("legacy_validator_not_ready")
|
||||
return Path(
|
||||
@@ -460,19 +580,19 @@ def run_compatible_validator(
|
||||
config_path = _validator_config(grant_dir, request)
|
||||
env = os.environ.copy()
|
||||
env["AIOS_GRANT_ROOT_CONFIG"] = str(config_path)
|
||||
# sudo `Defaults env_reset` (production sudoers) strips AIOS_GRANT_ROOT_CONFIG
|
||||
# from sudo, so the validator would read the default root config and hash/link
|
||||
# the wrong approval-plan. Explicitly inject via `sudo -n env VAR=... tool ...`.
|
||||
run_argv = _validator_run_argv(
|
||||
validator_command, config_path, grant_dir, result_path
|
||||
)
|
||||
try:
|
||||
_run(
|
||||
[
|
||||
*validator_command,
|
||||
"--grant-dir",
|
||||
str(grant_dir),
|
||||
"--result-json",
|
||||
str(result_path),
|
||||
],
|
||||
run_argv,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
actual = _read(result_path)
|
||||
actual = _read_json(result_path)
|
||||
if actual.get("result") != "READY":
|
||||
raise SignerContractError("validator_not_ready")
|
||||
checks = {
|
||||
|
||||
@@ -22,11 +22,12 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
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}$")
|
||||
VERSION_RE = re.compile(r"^V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"(?i)(authorization\s*[:=]\s*)(?:bearer|token)\s+\S+"),
|
||||
re.compile(r"(?i)((?:access_)?token\s*[:=]\s*)\S+"),
|
||||
@@ -769,8 +770,10 @@ class TagApprovalEnvelope:
|
||||
def validate(self) -> None:
|
||||
if self.action != "production_tag":
|
||||
raise ApprovalError("approval does not authorize production tag")
|
||||
if not VERSION_RE.fullmatch(self.version):
|
||||
raise ApprovalError("invalid release version")
|
||||
try:
|
||||
validate_release_version(self.version)
|
||||
except ValueError as exc:
|
||||
raise ApprovalError("invalid release version") from exc
|
||||
_validate_oid("tag_oid", self.tag_oid)
|
||||
_validate_oid("target_oid", self.target_oid)
|
||||
if _parse_utc(self.expires_at) <= datetime.now(timezone.utc):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user