492 lines
18 KiB
Python
492 lines
18 KiB
Python
"""Fail-closed, branch-bound repair leases for governed AIOS GitOps work."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import posixpath
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import tempfile
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Mapping, Protocol, Sequence
|
|
|
|
|
|
TASK_ID = "V2R1C6_GOVERNED_LOCAL_BOOTSTRAP"
|
|
MAX_LEASE_SECONDS = 86_400
|
|
OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
DENIED_BRANCHES = {"main", "master", "HEAD"}
|
|
PROTECTED_PREFIXES = (
|
|
".git",
|
|
"VERSION",
|
|
"versions/V2R1C5.json",
|
|
"artifacts/V2R1C5",
|
|
"baseline",
|
|
"config/gitops.tokens",
|
|
"gitops.tokens",
|
|
"secrets",
|
|
"credentials",
|
|
)
|
|
SECRET_PATTERNS = (
|
|
re.compile(r"(?i)-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
|
re.compile(r"(?i)(?:api[_-]?key|secret|password|token)\s*[:=]\s*\S+"),
|
|
re.compile(r"\b(?:sk|HPUA|SDC)-[A-Za-z0-9_-]{16,}\b"),
|
|
)
|
|
|
|
|
|
class LeaseError(Exception):
|
|
"""Raised for any invalid, stale, expired, or prohibited lease action."""
|
|
|
|
|
|
class GitProbeProtocol(Protocol):
|
|
"""Read-only Git seam used by the lease manager."""
|
|
|
|
def branch_head(self, branch: str) -> str: ...
|
|
|
|
def parent(self, commit: str) -> str: ...
|
|
|
|
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
|
|
|
def changed_paths(self, commit: str) -> tuple[str, ...]: ...
|
|
|
|
def commits_between(self, older: str, newer: str) -> tuple[str, ...]: ...
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _ts(value: datetime) -> str:
|
|
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _parse_ts(value: str) -> datetime:
|
|
if value.endswith("Z"):
|
|
value = value[:-1] + "+00:00"
|
|
parsed = datetime.fromisoformat(value)
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def _oid(value: str, field: str = "oid") -> str:
|
|
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
|
raise LeaseError(f"{field} must be a full commit OID")
|
|
return value.lower()
|
|
|
|
|
|
def normalize_path(path: str) -> str:
|
|
"""Normalize a repository-relative path without resolving through the FS."""
|
|
if not isinstance(path, str) or not path.strip():
|
|
raise LeaseError("path must be a non-empty string")
|
|
value = path.replace("\\", "/").strip()
|
|
if value.startswith("/") or "\x00" in value:
|
|
raise LeaseError("path must be relative")
|
|
parts = value.split("/")
|
|
if any(part in {"", ".", ".."} for part in parts):
|
|
raise LeaseError("path contains a prohibited component")
|
|
normalized = posixpath.normpath(value)
|
|
if normalized == "." or normalized.startswith("../"):
|
|
raise LeaseError("path is not normalized")
|
|
return normalized
|
|
|
|
|
|
def path_is_protected(path: str) -> bool:
|
|
normalized = normalize_path(path)
|
|
lower = normalized.lower()
|
|
return any(
|
|
lower == prefix.lower() or lower.startswith(prefix.lower().rstrip("/") + "/")
|
|
for prefix in PROTECTED_PREFIXES
|
|
) or lower.startswith("refs/")
|
|
|
|
|
|
def contains_secret_like_content(content: str) -> bool:
|
|
return any(pattern.search(content or "") for pattern in SECRET_PATTERNS)
|
|
|
|
|
|
def validate_paths(paths: Iterable[str], allowed_paths: Sequence[str]) -> tuple[str, ...]:
|
|
allowed = tuple(normalize_path(path) for path in allowed_paths)
|
|
if not allowed:
|
|
raise LeaseError("allowed_paths must not be empty")
|
|
result: list[str] = []
|
|
for raw in paths:
|
|
path = normalize_path(raw)
|
|
if path_is_protected(path):
|
|
raise LeaseError("protected path is not lease-writable")
|
|
if not any(path == prefix or path.startswith(prefix + "/") for prefix in allowed):
|
|
raise LeaseError("path is outside the lease allowlist")
|
|
if path not in result:
|
|
result.append(path)
|
|
if not result:
|
|
raise LeaseError("at least one changed path is required")
|
|
return tuple(result)
|
|
|
|
|
|
@dataclass
|
|
class CommitEntry:
|
|
commit_oid: str
|
|
parent_oid: str
|
|
paths: tuple[str, ...]
|
|
recorded_at: str
|
|
|
|
|
|
@dataclass
|
|
class RepairLease:
|
|
lease_id: str
|
|
task_id: str
|
|
owner_identity: str
|
|
branch: str
|
|
description: str
|
|
allowed_paths: tuple[str, ...]
|
|
initial_head_oid: str
|
|
current_head_oid: str
|
|
created_at: str
|
|
expires_at: str
|
|
max_expires_at: str
|
|
state: str = "ACTIVE"
|
|
revision: int = 0
|
|
commits: list[CommitEntry] = field(default_factory=list)
|
|
attached: bool = False
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
payload = asdict(self)
|
|
payload["allowed_paths"] = list(self.allowed_paths)
|
|
payload["commits"] = [asdict(entry) for entry in self.commits]
|
|
return payload
|
|
|
|
@classmethod
|
|
def from_json(cls, raw: dict[str, Any]) -> "RepairLease":
|
|
commits = tuple(
|
|
CommitEntry(
|
|
commit_oid=_oid(item["commit_oid"], "commit_oid"),
|
|
parent_oid=_oid(item["parent_oid"], "parent_oid"),
|
|
paths=tuple(normalize_path(path) for path in item["paths"]),
|
|
recorded_at=str(item["recorded_at"]),
|
|
)
|
|
for item in raw.get("commits", [])
|
|
)
|
|
return cls(
|
|
lease_id=str(raw["lease_id"]),
|
|
task_id=str(raw["task_id"]),
|
|
owner_identity=str(raw["owner_identity"]),
|
|
branch=str(raw["branch"]),
|
|
description=str(raw["description"]),
|
|
allowed_paths=tuple(normalize_path(path) for path in raw["allowed_paths"]),
|
|
initial_head_oid=_oid(raw["initial_head_oid"], "initial_head_oid"),
|
|
current_head_oid=_oid(raw["current_head_oid"], "current_head_oid"),
|
|
created_at=str(raw["created_at"]),
|
|
expires_at=str(raw["expires_at"]),
|
|
max_expires_at=str(raw["max_expires_at"]),
|
|
state=str(raw.get("state", "ACTIVE")),
|
|
revision=int(raw.get("revision", 0)),
|
|
commits=list(commits),
|
|
attached=bool(raw.get("attached", False)),
|
|
)
|
|
|
|
|
|
class LeaseStore:
|
|
"""Atomic JSON store for restart-recoverable lease state."""
|
|
|
|
def __init__(self, directory: str | os.PathLike[str]) -> None:
|
|
self.directory = Path(directory)
|
|
self.directory.mkdir(parents=True, exist_ok=True)
|
|
if self.directory.is_symlink():
|
|
raise LeaseError("lease store must not be a symlink")
|
|
os.chmod(self.directory, 0o700)
|
|
|
|
def _path(self, lease_id: str) -> Path:
|
|
if not re.fullmatch(r"lease-[0-9a-f]{32}", lease_id):
|
|
raise LeaseError("invalid lease id")
|
|
return self.directory / f"{lease_id}.json"
|
|
|
|
def create(self, lease: RepairLease) -> None:
|
|
path = self._path(lease.lease_id)
|
|
if path.exists() or path.is_symlink():
|
|
raise LeaseError("lease already exists")
|
|
self._write(path, lease.to_json(), exclusive=True)
|
|
|
|
def load(self, lease_id: str) -> RepairLease:
|
|
path = self._path(lease_id)
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError, TypeError) as exc:
|
|
raise LeaseError("lease state is unavailable or invalid") from exc
|
|
if not isinstance(raw, dict):
|
|
raise LeaseError("lease state must be an object")
|
|
return RepairLease.from_json(raw)
|
|
|
|
def save(self, lease: RepairLease) -> None:
|
|
self._write(self._path(lease.lease_id), lease.to_json())
|
|
|
|
@staticmethod
|
|
def _write(path: Path, payload: dict[str, Any], *, exclusive: bool = False) -> None:
|
|
flags = os.O_WRONLY | os.O_CREAT | (os.O_EXCL if exclusive else 0)
|
|
fd, temp_name = tempfile.mkstemp(prefix=".lease-", dir=path.parent)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
|
json.dump(payload, stream, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
stream.write("\n")
|
|
if exclusive:
|
|
os.link(temp_name, path)
|
|
os.unlink(temp_name)
|
|
else:
|
|
os.replace(temp_name, path)
|
|
os.chmod(path, 0o600)
|
|
except Exception:
|
|
try:
|
|
os.unlink(temp_name)
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
|
|
class GitProbe:
|
|
"""Read-only Git implementation; it never changes a ref or working tree."""
|
|
|
|
def __init__(self, bare_repo: str | os.PathLike[str]) -> None:
|
|
self.bare_repo = Path(bare_repo)
|
|
|
|
def _run(self, args: list[str]) -> str:
|
|
result = subprocess.run(
|
|
["git", "--git-dir", str(self.bare_repo), *args],
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
raise LeaseError("read-only Git probe failed")
|
|
return result.stdout.strip()
|
|
|
|
def branch_head(self, branch: str) -> str:
|
|
return _oid(self._run(["rev-parse", f"refs/heads/{branch}"]), "branch_head")
|
|
|
|
def parent(self, commit: str) -> str:
|
|
return _oid(self._run(["rev-parse", f"{_oid(commit)}^1"]), "parent")
|
|
|
|
def is_ancestor(self, older: str, newer: str) -> bool:
|
|
old, new = _oid(older), _oid(newer)
|
|
result = subprocess.run(
|
|
["git", "--git-dir", str(self.bare_repo), "merge-base", "--is-ancestor", old, new],
|
|
check=False,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
def changed_paths(self, commit: str) -> tuple[str, ...]:
|
|
raw = self._run(["diff-tree", "--no-commit-id", "--name-only", "-r", _oid(commit)])
|
|
return tuple(normalize_path(line) for line in raw.splitlines() if line.strip())
|
|
|
|
def commits_between(self, older: str, newer: str) -> tuple[str, ...]:
|
|
raw = self._run(["rev-list", "--reverse", f"{_oid(older)}..{_oid(newer)}"])
|
|
return tuple(_oid(line, "commit") for line in raw.splitlines() if line.strip())
|
|
|
|
|
|
class RepairLeaseManager:
|
|
"""Issue, recover, renew, and close branch-bound repair leases."""
|
|
|
|
def __init__(self, store: LeaseStore, git: GitProbeProtocol, *, clock=_now) -> None:
|
|
self.store = store
|
|
self.git = git
|
|
self.clock = clock
|
|
|
|
def _require_owner(self, lease: RepairLease, owner_identity: str) -> None:
|
|
if owner_identity != lease.owner_identity:
|
|
raise LeaseError("lease identity mismatch")
|
|
|
|
def _active(self, lease: RepairLease) -> RepairLease:
|
|
now = self.clock()
|
|
if lease.state != "ACTIVE":
|
|
raise LeaseError(f"lease is {lease.state.lower()}")
|
|
if now >= _parse_ts(lease.expires_at):
|
|
lease.state = "EXPIRED"
|
|
self.store.save(lease)
|
|
raise LeaseError("lease expired")
|
|
return lease
|
|
|
|
def issue(
|
|
self,
|
|
*,
|
|
task_id: str,
|
|
owner_identity: str,
|
|
branch: str,
|
|
description: str,
|
|
allowed_paths: Sequence[str],
|
|
ttl_seconds: int = 3600,
|
|
) -> RepairLease:
|
|
if task_id != TASK_ID:
|
|
raise LeaseError("unexpected task id")
|
|
if not owner_identity or not description or len(description) > 240:
|
|
raise LeaseError("owner identity and description are required")
|
|
if "\n" in description or "\r" in description:
|
|
raise LeaseError("description must be a single line")
|
|
if branch in DENIED_BRANCHES or branch.startswith(("main/", "refs/", "tags/")):
|
|
raise LeaseError("protected branch cannot receive a repair lease")
|
|
if not isinstance(ttl_seconds, int) or not 0 < ttl_seconds <= MAX_LEASE_SECONDS:
|
|
raise LeaseError("lease TTL exceeds the maximum")
|
|
allowed = tuple(normalize_path(path) for path in allowed_paths)
|
|
if any(path_is_protected(path) for path in allowed):
|
|
raise LeaseError("protected path in lease allowlist")
|
|
head = self.git.branch_head(branch)
|
|
created = self.clock()
|
|
expires = created + timedelta(seconds=ttl_seconds)
|
|
max_expires = created + timedelta(seconds=MAX_LEASE_SECONDS)
|
|
lease_id = "lease-" + secrets.token_hex(16)
|
|
lease = RepairLease(
|
|
lease_id=lease_id,
|
|
task_id=task_id,
|
|
owner_identity=owner_identity,
|
|
branch=branch,
|
|
description=description,
|
|
allowed_paths=allowed,
|
|
initial_head_oid=head,
|
|
current_head_oid=head,
|
|
created_at=_ts(created),
|
|
expires_at=_ts(expires),
|
|
max_expires_at=_ts(max_expires),
|
|
)
|
|
self.store.create(lease)
|
|
return lease
|
|
|
|
def attach_existing_branch(
|
|
self, lease_id: str, *, owner_identity: str, expected_head_oid: str
|
|
) -> RepairLease:
|
|
lease = self._active(self.store.load(lease_id))
|
|
self._require_owner(lease, owner_identity)
|
|
current = self.git.branch_head(lease.branch)
|
|
expected = _oid(expected_head_oid, "expected_head_oid")
|
|
if current != expected or current != lease.current_head_oid:
|
|
raise LeaseError("branch head does not match the lease")
|
|
lease.attached = True
|
|
lease.revision += 1
|
|
self.store.save(lease)
|
|
return lease
|
|
|
|
def authorize_commit(
|
|
self,
|
|
lease_id: str,
|
|
*,
|
|
owner_identity: str,
|
|
expected_head_oid: str,
|
|
paths: Sequence[str],
|
|
content: Mapping[str, str] | None = None,
|
|
) -> RepairLease:
|
|
lease = self._active(self.store.load(lease_id))
|
|
self._require_owner(lease, owner_identity)
|
|
expected = _oid(expected_head_oid, "expected_head_oid")
|
|
current = self.git.branch_head(lease.branch)
|
|
if current != expected or current != lease.current_head_oid:
|
|
raise LeaseError("stale or externally advanced branch head")
|
|
if not lease.attached:
|
|
raise LeaseError("lease is not attached")
|
|
validate_paths(paths, lease.allowed_paths)
|
|
if content and any(contains_secret_like_content(value) for value in content.values()):
|
|
raise LeaseError("secret-like content rejected")
|
|
return lease
|
|
|
|
def record_commit(
|
|
self,
|
|
lease_id: str,
|
|
*,
|
|
owner_identity: str,
|
|
expected_head_oid: str,
|
|
new_head_oid: str,
|
|
paths: Sequence[str],
|
|
) -> RepairLease:
|
|
lease = self._active(self.store.load(lease_id))
|
|
self._require_owner(lease, owner_identity)
|
|
new_head = _oid(new_head_oid, "new_head_oid")
|
|
expected = _oid(expected_head_oid, "expected_head_oid")
|
|
if not lease.attached or lease.current_head_oid != expected:
|
|
raise LeaseError("stale lease head")
|
|
if self.git.branch_head(lease.branch) != new_head:
|
|
raise LeaseError("branch head does not match committed head")
|
|
validate_paths(paths, lease.allowed_paths)
|
|
if self.git.parent(new_head) != expected or not self.git.is_ancestor(expected, new_head):
|
|
raise LeaseError("commit is not a fast-forward child of the expected head")
|
|
actual_paths = self.git.changed_paths(new_head)
|
|
allowed = validate_paths(actual_paths, lease.allowed_paths)
|
|
requested = set(validate_paths(paths, lease.allowed_paths))
|
|
if not set(allowed).issubset(requested):
|
|
raise LeaseError("commit changed an unrequested path")
|
|
lease.current_head_oid = new_head
|
|
lease.revision += 1
|
|
lease.commits.append(
|
|
CommitEntry(
|
|
commit_oid=new_head,
|
|
parent_oid=expected,
|
|
paths=allowed,
|
|
recorded_at=_ts(self.clock()),
|
|
)
|
|
)
|
|
self.store.save(lease)
|
|
return lease
|
|
|
|
def recover(self, lease_id: str, *, owner_identity: str) -> RepairLease:
|
|
lease = self._active(self.store.load(lease_id))
|
|
self._require_owner(lease, owner_identity)
|
|
current = self.git.branch_head(lease.branch)
|
|
if current == lease.current_head_oid:
|
|
return lease
|
|
if not self.git.is_ancestor(lease.current_head_oid, current):
|
|
raise LeaseError("branch moved non-fast-forward")
|
|
commits = self.git.commits_between(lease.current_head_oid, current)
|
|
for commit in commits:
|
|
actual = self.git.changed_paths(commit)
|
|
validate_paths(actual, lease.allowed_paths)
|
|
lease.current_head_oid = current
|
|
lease.revision += 1
|
|
self.store.save(lease)
|
|
return lease
|
|
|
|
def renew(self, lease_id: str, *, owner_identity: str, ttl_seconds: int) -> RepairLease:
|
|
lease = self._active(self.store.load(lease_id))
|
|
self._require_owner(lease, owner_identity)
|
|
if not isinstance(ttl_seconds, int) or ttl_seconds <= 0:
|
|
raise LeaseError("renewal TTL must be positive")
|
|
candidate = self.clock() + timedelta(seconds=ttl_seconds)
|
|
maximum = _parse_ts(lease.max_expires_at)
|
|
if candidate > maximum:
|
|
raise LeaseError("renewal exceeds maximum lease lifetime")
|
|
lease.expires_at = _ts(candidate)
|
|
lease.revision += 1
|
|
self.store.save(lease)
|
|
return lease
|
|
|
|
def complete(self, lease_id: str, *, owner_identity: str) -> RepairLease:
|
|
lease = self._active(self.store.load(lease_id))
|
|
self._require_owner(lease, owner_identity)
|
|
lease.state = "COMPLETED"
|
|
lease.revision += 1
|
|
self.store.save(lease)
|
|
return lease
|
|
|
|
def revoke(self, lease_id: str, *, owner_identity: str) -> RepairLease:
|
|
lease = self._active(self.store.load(lease_id))
|
|
self._require_owner(lease, owner_identity)
|
|
lease.state = "REVOKED"
|
|
lease.revision += 1
|
|
self.store.save(lease)
|
|
return lease
|
|
|
|
|
|
def sanitized_lease_view(lease: RepairLease) -> dict[str, Any]:
|
|
"""Return lease metadata only; no content, token, or credential values."""
|
|
return {
|
|
"lease_id": lease.lease_id,
|
|
"task_id": lease.task_id,
|
|
"owner_identity": lease.owner_identity,
|
|
"branch": lease.branch,
|
|
"description": lease.description,
|
|
"initial_head_oid": lease.initial_head_oid,
|
|
"current_head_oid": lease.current_head_oid,
|
|
"created_at": lease.created_at,
|
|
"expires_at": lease.expires_at,
|
|
"state": lease.state,
|
|
"revision": lease.revision,
|
|
"commit_count": len(lease.commits),
|
|
"attached": lease.attached,
|
|
}
|