245 lines
9.3 KiB
Python
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())
|