Files
aios/services/aios-autonomous-release-candidate/aios_autonomous_release_candidate/cli.py
T

245 lines
9.3 KiB
Python

"""Candidate inspection CLI; all commands are local and non-production."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
from .artifact import ArtifactBuilder, ArtifactManifest, BuiltArtifact
from .evidence import EvidenceChain
from .release import ReleaseController
from .serialization import (
release_plan_from_mapping,
release_plan_to_mapping,
task_plan_from_mapping,
task_plan_to_mapping,
)
from .service import CandidateApplication
from .store import StateStore
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="aios-autonomous-release")
sub = parser.add_subparsers(dest="command", required=True)
plan = sub.add_parser("plan")
plan.add_argument("payload")
digest = sub.add_parser("digest")
digest.add_argument("payload")
status = sub.add_parser("status")
status.add_argument("--state", required=True, type=Path)
status.add_argument("--task-id", required=True)
evidence = sub.add_parser("verify-evidence")
evidence.add_argument("--state", required=True, type=Path)
evidence.add_argument("--task-id", required=True)
evidence.add_argument("--require", action="append", default=[])
task = sub.add_parser("task")
task_sub = task.add_subparsers(dest="task_command", required=True)
task_create = task_sub.add_parser("create")
task_create.add_argument("--state", required=True, type=Path)
task_create.add_argument("--payload", required=True, type=Path)
for name in ("status", "steps"):
item = task_sub.add_parser(name)
item.add_argument("--state", required=True, type=Path)
item.add_argument("--task-id", required=True)
evidence_group = sub.add_parser("evidence")
evidence_sub = evidence_group.add_subparsers(dest="evidence_command", required=True)
evidence_verify = evidence_sub.add_parser("verify")
evidence_verify.add_argument("--state", required=True, type=Path)
evidence_verify.add_argument("--task-id", required=True)
evidence_verify.add_argument("--require", action="append", default=[])
artifact = sub.add_parser("artifact")
artifact_sub = artifact.add_subparsers(dest="artifact_command", required=True)
artifact_build = artifact_sub.add_parser("build")
artifact_build.add_argument("--source", required=True, type=Path)
artifact_build.add_argument("--source-oid", required=True)
artifact_build.add_argument("--tree-oid", required=True)
artifact_build.add_argument("--output-directory", required=True, type=Path)
artifact_verify = artifact_sub.add_parser("verify")
artifact_verify.add_argument("--artifact", required=True, type=Path)
artifact_verify.add_argument("--manifest", required=True, type=Path)
release = sub.add_parser("release")
release_sub = release.add_subparsers(dest="release_command", required=True)
for name in ("plan", "verify"):
item = release_sub.add_parser(name)
item.add_argument("--payload", required=True, type=Path)
acceptance = sub.add_parser("acceptance")
acceptance.add_argument("--repo-root", required=True, type=Path)
acceptance.add_argument("--output", required=True, type=Path)
return parser
def load_json(path: Path) -> dict:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError("JSON payload must be an object")
return value
def exclusive(path: Path, content: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
descriptor = os.open(
path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
0o640,
)
try:
os.write(descriptor, content)
os.fsync(descriptor)
finally:
os.close(descriptor)
def artifact_from_files(artifact: Path, manifest: Path) -> BuiltArtifact:
payload = load_json(manifest)
contract = ArtifactManifest(
source_oid=str(payload["source_oid"]),
tree_oid=str(payload["tree_oid"]),
files={str(key): str(value) for key, value in payload["files"].items()},
artifact_sha256=str(payload["artifact_sha256"]),
package_closure_sha256=str(payload["package_closure_sha256"]),
format_version=int(payload.get("format_version", 1)),
secrets_included=bool(payload.get("secrets_included", False)),
)
return BuiltArtifact(artifact.read_bytes(), contract)
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command in {"plan", "digest"}:
plan = task_plan_from_mapping(json.loads(args.payload))
if args.command == "plan":
print(json.dumps(task_plan_to_mapping(plan), sort_keys=True))
else:
print(plan.digest())
return 0
if args.command == "task":
application = CandidateApplication(args.state)
if args.task_command == "create":
result = application.api.create_task(load_json(args.payload))
elif args.task_command == "steps":
result = application.api.task_steps(args.task_id)
else:
result = application.api.task_status(args.task_id)
print(json.dumps(result, sort_keys=True))
return 0
if args.command == "evidence":
chain = EvidenceChain(StateStore(args.state), args.task_id)
result = {
"task_id": args.task_id,
"verified": chain.verify(),
"evidence_closure": chain.close(tuple(args.require)),
"production_side_effects": False,
"secrets_included": False,
}
print(json.dumps(result, sort_keys=True))
return 0
if args.command == "artifact":
builder = ArtifactBuilder()
if args.artifact_command == "build":
built = builder.build_from_directory(args.source_oid, args.tree_oid, args.source)
builder.verify(built)
artifact_path = args.output_directory / "candidate.tar"
manifest_path = args.output_directory / "manifest.json"
exclusive(artifact_path, built.content)
exclusive(manifest_path, built.manifest.as_json_bytes() + b"\n")
result = {
"artifact": str(artifact_path),
"manifest": str(manifest_path),
"artifact_sha256": built.manifest.artifact_sha256,
"manifest_sha256": built.manifest.manifest_sha256,
"package_closure_sha256": built.manifest.package_closure_sha256,
"production_side_effects": False,
"secrets_included": False,
}
else:
built = artifact_from_files(args.artifact, args.manifest)
builder.verify(built)
result = {
"verified": True,
"artifact_sha256": built.manifest.artifact_sha256,
"manifest_sha256": built.manifest.manifest_sha256,
"package_closure_sha256": built.manifest.package_closure_sha256,
"production_side_effects": False,
"secrets_included": False,
}
print(json.dumps(result, sort_keys=True))
return 0
if args.command == "release":
plan = release_plan_from_mapping(load_json(args.payload))
if args.release_command == "plan":
result = {
"plan": release_plan_to_mapping(plan),
"release_state": "CREATED",
"production_side_effects": False,
"secrets_included": False,
}
else:
controller = ReleaseController()
controller.attach_plan(plan)
controller.mark_built()
controller.verify(artifact_ok=True, evidence_ok=True)
controller.request_owner_approval()
result = {
"release_state": controller.status().state.value,
"production_release": "BLOCKED_EXTERNAL_OWNER_SIGNATURE",
"production_side_effects": False,
"secrets_included": False,
}
print(json.dumps(result, sort_keys=True))
return 0
if args.command == "acceptance":
runner = args.repo_root / "scripts" / "autonomous-release" / "run_acceptance.py"
completed = subprocess.run(
[
sys.executable,
str(runner),
"--repo-root",
str(args.repo_root),
"--output",
str(args.output),
],
check=False,
)
return completed.returncode
store = StateStore(args.state)
if args.command == "status":
state, revision = store.get_task_state(args.task_id)
print(
json.dumps(
{
"task_id": args.task_id,
"state": state.value,
"revision": revision,
"production_side_effects": False,
"secrets_included": False,
},
sort_keys=True,
)
)
return 0
chain = EvidenceChain(store, args.task_id)
print(
json.dumps(
{
"task_id": args.task_id,
"evidence_closure": chain.close(tuple(args.require)),
"secrets_included": False,
},
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())