autonomy-unit-tests 23 passed on exact PR head
autonomy-smoke-tests executor CLI smoke passed on exact PR head
git-diff-check 11 additive paths; protected paths unchanged
py-compile 5 runtime modules compiled
reconciliation-contract Authority/Gitea main and branch OIDs reconciled
release-state-machine executor and metadata tests passed
secret-scan 11 changed files scanned; no secret markers
signer-validator-contract validator self-test and signer CLI contract passed
owner-approval-gate Owner actor 2 auto-merge intent verified for exact current head; protected target main; no unresolved conversations; signed-commit gate not required
456 lines
16 KiB
Python
Executable File
456 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Concrete V2R1C7 production deployment adapter.
|
|
|
|
The adapter consumes only an already approved immutable artifact. It never
|
|
writes Git refs, Registry, or Baseline. A rollback only tears down the V2R1C7
|
|
runtime and leaves V2R1C5/V2R1C6 untouched.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import grp
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import pwd
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any, Mapping
|
|
|
|
VERSION = "V2R1C7"
|
|
SERVICE = "aios-v2r1c7-production.service"
|
|
PORT = 18927
|
|
RUNTIME_USER = "aios-v2r1c7"
|
|
RUNTIME_GROUP = "aios-v2r1c7"
|
|
|
|
REQUIRED_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",
|
|
}
|
|
|
|
|
|
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")
|
|
if value.get("version") != VERSION:
|
|
raise ValueError("version_mismatch")
|
|
if value.get("production_service") != SERVICE:
|
|
raise ValueError("service_mismatch")
|
|
if int(value.get("production_port", -1)) != PORT:
|
|
raise ValueError("port_mismatch")
|
|
return value
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
if path.is_symlink() or not path.is_file():
|
|
raise ValueError("artifact_regular_file_required")
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def atomic_json(path: Path, value: Mapping[str, Any], *, mode: int, gid: int | None = None) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(path.name + ".tmp")
|
|
temporary.write_text(
|
|
json.dumps(dict(value), sort_keys=True, separators=(",", ":")) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
os.chmod(temporary, mode)
|
|
if gid is not None:
|
|
os.chown(temporary, 0, gid)
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def atomic_text(path: Path, text: str, *, mode: int, gid: int | None = None) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_name(path.name + ".tmp")
|
|
temporary.write_text(text, encoding="utf-8")
|
|
os.chmod(temporary, mode)
|
|
if gid is not None:
|
|
os.chown(temporary, 0, gid)
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def systemctl(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(
|
|
["systemctl", *args],
|
|
check=check,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=45,
|
|
env={"PATH": os.environ.get("PATH", "")},
|
|
)
|
|
|
|
|
|
def identity() -> tuple[int, int]:
|
|
try:
|
|
user = pwd.getpwnam(RUNTIME_USER)
|
|
group = grp.getgrnam(RUNTIME_GROUP)
|
|
except KeyError as exc:
|
|
raise RuntimeError("RUNTIME_IDENTITY_MISSING") from exc
|
|
if user.pw_gid != group.gr_gid:
|
|
raise RuntimeError("RUNTIME_IDENTITY_GROUP_MISMATCH")
|
|
return user.pw_uid, group.gr_gid
|
|
|
|
|
|
def safe_extract(artifact: Path, destination: Path) -> set[str]:
|
|
with tempfile.TemporaryDirectory(prefix="aios-v2r1c7-unpack-") as temp:
|
|
tar_path = Path(temp) / "release.tar"
|
|
with tar_path.open("wb") as output:
|
|
completed = subprocess.run(
|
|
["zstd", "-dc", str(artifact)],
|
|
check=False,
|
|
stdout=output,
|
|
stderr=subprocess.PIPE,
|
|
timeout=60,
|
|
env={"PATH": os.environ.get("PATH", "")},
|
|
)
|
|
if completed.returncode != 0:
|
|
raise RuntimeError("ARTIFACT_DECOMPRESSION_FAILED")
|
|
with tarfile.open(tar_path, "r:") as archive:
|
|
members = archive.getmembers()
|
|
names = {m.name for m in members}
|
|
missing = REQUIRED_MEMBERS - names
|
|
if missing:
|
|
raise RuntimeError("ARTIFACT_MEMBERS_MISSING:" + ",".join(sorted(missing)))
|
|
for member in members:
|
|
relative = Path(member.name)
|
|
if (
|
|
relative.is_absolute()
|
|
or ".." in relative.parts
|
|
or member.issym()
|
|
or member.islnk()
|
|
or not (member.isfile() or member.isdir())
|
|
):
|
|
raise RuntimeError("UNSAFE_ARTIFACT_MEMBER:" + member.name)
|
|
if not member.name.startswith("payload/"):
|
|
raise RuntimeError("UNEXPECTED_ARTIFACT_ROOT:" + member.name)
|
|
payload = destination
|
|
payload.mkdir(parents=True, exist_ok=True)
|
|
for member in members:
|
|
rel = Path(member.name).relative_to("payload")
|
|
if str(rel) == ".":
|
|
continue
|
|
target = payload / rel
|
|
resolved_parent = target.parent.resolve()
|
|
resolved_parent.relative_to(payload.resolve())
|
|
if member.isdir():
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
os.chmod(target, 0o755)
|
|
continue
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
source = archive.extractfile(member)
|
|
if source is None:
|
|
raise RuntimeError("ARTIFACT_MEMBER_READ_FAILED")
|
|
with target.open("wb") as output:
|
|
shutil.copyfileobj(source, output)
|
|
# Scripts must be executable; all other payload files are read-only.
|
|
mode = 0o755 if rel.parts and rel.parts[0] == "scripts" else 0o644
|
|
os.chmod(target, mode)
|
|
return names
|
|
|
|
|
|
def health(url: str, expected: 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):
|
|
return {"status": "UNAVAILABLE"}
|
|
exact = (
|
|
status == 200
|
|
and body.get("version") == expected["version"]
|
|
and body.get("commit_oid") == expected["target_oid"]
|
|
and body.get("artifact_sha256") == expected["artifact_sha256"]
|
|
and body.get("deployment_state") == "ACTIVE"
|
|
and body.get("production_deployed") is True
|
|
)
|
|
if not exact:
|
|
return {
|
|
"status": "CONFLICT",
|
|
"http_status": status,
|
|
"version": body.get("version"),
|
|
"target_oid": body.get("commit_oid"),
|
|
"artifact_sha256": body.get("artifact_sha256"),
|
|
"deployment_state": body.get("deployment_state"),
|
|
"production_deployed": body.get("production_deployed"),
|
|
}
|
|
return {
|
|
"status": "ACTIVE_VERIFIED",
|
|
"http_status": 200,
|
|
"version": body["version"],
|
|
"target_oid": body["commit_oid"],
|
|
"artifact_sha256": body["artifact_sha256"],
|
|
"deployment_state": body["deployment_state"],
|
|
"production_deployed": True,
|
|
}
|
|
|
|
|
|
def paths(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Path]:
|
|
release_id = (
|
|
f"{VERSION}-{str(value['target_oid'])[:12]}-"
|
|
f"{str(value['artifact_sha256'])[:12]}"
|
|
)
|
|
return {
|
|
"release_dir": args.release_root / release_id,
|
|
"current_link": args.current_link,
|
|
"env_file": args.env_file,
|
|
"state_file": args.state_file,
|
|
"unit_file": args.unit_file,
|
|
}
|
|
|
|
|
|
def probe(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
|
p = paths(args, value)
|
|
url = f"http://127.0.0.1:{PORT}/health"
|
|
active = systemctl("is-active", "--quiet", SERVICE, check=False).returncode == 0
|
|
if not active:
|
|
# A stale exact release directory is safe to reuse; it is not a deployment.
|
|
return {
|
|
"status": "NOT_APPLIED",
|
|
"version": VERSION,
|
|
"target_oid": value["target_oid"],
|
|
"artifact_sha256": value["artifact_sha256"],
|
|
"production_deployed": False,
|
|
"deployment_state": "ROLLED_BACK"
|
|
if p["state_file"].exists()
|
|
and "ROLLED_BACK" in p["state_file"].read_text(encoding="utf-8")
|
|
else "ABSENT",
|
|
}
|
|
result = health(url, value)
|
|
if result["status"] == "UNAVAILABLE":
|
|
return {
|
|
"status": "CONFLICT",
|
|
"version": VERSION,
|
|
"target_oid": value["target_oid"],
|
|
"artifact_sha256": value["artifact_sha256"],
|
|
"reason": "service_active_health_unavailable",
|
|
}
|
|
return result
|
|
|
|
|
|
def install_release(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
|
_, gid = identity()
|
|
artifact = Path(str(value["artifact_path"]))
|
|
if artifact.stat().st_size != int(value["artifact_size"]):
|
|
raise RuntimeError("ARTIFACT_SIZE_MISMATCH")
|
|
if sha256_file(artifact) != value["artifact_sha256"]:
|
|
raise RuntimeError("ARTIFACT_DIGEST_MISMATCH")
|
|
|
|
p = paths(args, value)
|
|
release_dir = p["release_dir"]
|
|
marker = release_dir / "DEPLOYMENT-BINDING.json"
|
|
marker_value = {
|
|
"schema": "aios.runtime-release-binding.v1",
|
|
"version": VERSION,
|
|
"target_oid": value["target_oid"],
|
|
"artifact_sha256": value["artifact_sha256"],
|
|
"approval_plan_id": value["approval_plan_id"],
|
|
}
|
|
|
|
if release_dir.exists():
|
|
if not marker.is_file():
|
|
raise RuntimeError("EXISTING_RELEASE_DIR_UNTRUSTED")
|
|
current_marker = json.loads(marker.read_text(encoding="utf-8"))
|
|
if current_marker != marker_value:
|
|
raise RuntimeError("EXISTING_RELEASE_DIR_BINDING_CONFLICT")
|
|
else:
|
|
release_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
temp_dir = Path(
|
|
tempfile.mkdtemp(prefix=release_dir.name + ".", dir=str(release_dir.parent))
|
|
)
|
|
try:
|
|
safe_extract(artifact, temp_dir)
|
|
atomic_json(
|
|
temp_dir / "DEPLOYMENT-BINDING.json",
|
|
marker_value,
|
|
mode=0o644,
|
|
)
|
|
os.rename(temp_dir, release_dir)
|
|
except Exception:
|
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
raise
|
|
|
|
if (release_dir / "VERSION").read_text(encoding="utf-8").strip() != VERSION:
|
|
raise RuntimeError("RELEASE_VERSION_FILE_MISMATCH")
|
|
|
|
# Only replace our dedicated V2R1C7 link. Never touch previous versions.
|
|
current_link = p["current_link"]
|
|
if current_link.exists() and not current_link.is_symlink():
|
|
raise RuntimeError("CURRENT_RELEASE_PATH_NOT_SYMLINK")
|
|
if current_link.is_symlink():
|
|
existing = current_link.resolve()
|
|
if existing != release_dir.resolve():
|
|
raise RuntimeError("CURRENT_RELEASE_LINK_CONFLICT")
|
|
else:
|
|
current_link.parent.mkdir(parents=True, exist_ok=True)
|
|
new_link = current_link.with_name(current_link.name + ".new")
|
|
try:
|
|
new_link.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
new_link.symlink_to(release_dir)
|
|
os.replace(new_link, current_link)
|
|
|
|
state_value = {
|
|
"version": VERSION,
|
|
"commit_oid": value["target_oid"],
|
|
"artifact_sha256": value["artifact_sha256"],
|
|
"deployment_state": "ACTIVE",
|
|
}
|
|
atomic_json(p["state_file"], state_value, mode=0o640, gid=gid)
|
|
env = (
|
|
f"RELEASE_VERSION={VERSION}\n"
|
|
f"TARGET_OID={value['target_oid']}\n"
|
|
f"ARTIFACT_SHA256={value['artifact_sha256']}\n"
|
|
"DEPLOYMENT_STATE=ACTIVE\n"
|
|
f"AIOS_V2R1C7_PORT={PORT}\n"
|
|
f"DEPLOYMENT_STATE_FILE={p['state_file']}\n"
|
|
"AIOS_GITOPS_WRITES_ENABLED=false\n"
|
|
)
|
|
atomic_text(p["env_file"], env, mode=0o640, gid=gid)
|
|
|
|
unit_source = (
|
|
release_dir
|
|
/ "deploy"
|
|
/ "aios-v2"
|
|
/ "systemd"
|
|
/ "aios-v2r1c7-production.service"
|
|
)
|
|
unit_text = unit_source.read_text(encoding="utf-8")
|
|
if "aios-v2r1c7-production.service" in unit_text:
|
|
# Description text is irrelevant; service identity is the destination.
|
|
pass
|
|
atomic_text(p["unit_file"], unit_text, mode=0o644)
|
|
|
|
verify = subprocess.run(
|
|
["systemd-analyze", "verify", str(p["unit_file"])],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
env={"PATH": os.environ.get("PATH", "")},
|
|
)
|
|
if verify.returncode != 0:
|
|
raise RuntimeError("PRODUCTION_SYSTEMD_VERIFY_FAILED")
|
|
|
|
systemctl("daemon-reload")
|
|
systemctl("enable", SERVICE)
|
|
systemctl("restart", SERVICE)
|
|
|
|
deadline = time.time() + args.health_timeout
|
|
result = {"status": "UNAVAILABLE"}
|
|
while time.time() < deadline:
|
|
result = health(f"http://127.0.0.1:{PORT}/health", value)
|
|
if result["status"] == "ACTIVE_VERIFIED":
|
|
break
|
|
if result["status"] == "CONFLICT":
|
|
break
|
|
time.sleep(1)
|
|
if result["status"] != "ACTIVE_VERIFIED":
|
|
raise RuntimeError("PRODUCTION_HEALTH_VERIFY_FAILED")
|
|
|
|
# Restart recovery is part of production acceptance, not just staging.
|
|
systemctl("restart", SERVICE)
|
|
deadline = time.time() + args.health_timeout
|
|
while time.time() < deadline:
|
|
result = health(f"http://127.0.0.1:{PORT}/health", value)
|
|
if result["status"] == "ACTIVE_VERIFIED":
|
|
break
|
|
if result["status"] == "CONFLICT":
|
|
break
|
|
time.sleep(1)
|
|
if result["status"] != "ACTIVE_VERIFIED":
|
|
raise RuntimeError("PRODUCTION_RESTART_RECOVERY_FAILED")
|
|
return result
|
|
|
|
|
|
def rollback(args: argparse.Namespace, value: Mapping[str, Any]) -> dict[str, Any]:
|
|
_, gid = identity()
|
|
p = paths(args, value)
|
|
systemctl("stop", SERVICE, check=False)
|
|
systemctl("disable", SERVICE, check=False)
|
|
state = {
|
|
"version": VERSION,
|
|
"commit_oid": value["target_oid"],
|
|
"artifact_sha256": value["artifact_sha256"],
|
|
"deployment_state": "ROLLED_BACK",
|
|
}
|
|
atomic_json(p["state_file"], state, mode=0o640, gid=gid)
|
|
return {
|
|
"status": "ROLLED_BACK",
|
|
"version": VERSION,
|
|
"target_oid": value["target_oid"],
|
|
"artifact_sha256": value["artifact_sha256"],
|
|
"deployment_state": "ROLLED_BACK",
|
|
"production_deployed": False,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--release-root", type=Path, default=Path("/opt/aios/releases"))
|
|
p.add_argument("--current-link", type=Path, default=Path("/opt/aios/v2r1c7"))
|
|
p.add_argument("--env-file", type=Path, default=Path("/etc/aios/aios-v2r1c7.env"))
|
|
p.add_argument(
|
|
"--state-file",
|
|
type=Path,
|
|
default=Path("/var/lib/aios/v2r1c7/deployment-state.json"),
|
|
)
|
|
p.add_argument(
|
|
"--unit-file",
|
|
type=Path,
|
|
default=Path("/etc/systemd/system/aios-v2r1c7-production.service"),
|
|
)
|
|
p.add_argument("--health-timeout", type=int, default=30)
|
|
args = p.parse_args()
|
|
value = load_request()
|
|
operation = value.get("operation")
|
|
if operation == "probe":
|
|
result = probe(args, value)
|
|
elif operation == "apply":
|
|
result = install_release(args, value)
|
|
elif operation == "rollback":
|
|
result = rollback(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)
|