998 lines
39 KiB
Python
998 lines
39 KiB
Python
#!/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 _policy_sha256(path: Path) -> str:
|
|
try:
|
|
raw = path.read_bytes()
|
|
except OSError as exc:
|
|
raise MigrationPlanError("builder_policy_unavailable") from exc
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
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 ReleaseSourceAdmissionStore
|
|
|
|
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 = ReleaseSourceAdmissionStore(
|
|
source_admission_root,
|
|
repository="aios/aios",
|
|
policy_sha256=_policy_sha256(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)
|