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

224 lines
8.7 KiB
Python

"""Hardened loopback HTTP boundary for the dormant Candidate."""
from __future__ import annotations
import argparse
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from .api import AutonomousReleaseAPI
from .errors import AutonomousReleaseError
from .orchestrator import AutonomousReleaseOrchestrator
from .policy import ReleasePolicy
from .store import StateStore
from .workflow import WorkflowEngine
class CandidateApplication:
def __init__(self, state_path: str | Path) -> None:
self.store = StateStore(state_path)
workflow = WorkflowEngine(self.store)
self.api = AutonomousReleaseAPI(
AutonomousReleaseOrchestrator(workflow, ReleasePolicy())
)
def ready(self) -> bool:
with self.store.transaction() as connection:
return connection.execute("SELECT 1").fetchone()[0] == 1
class CandidateRequestHandler(BaseHTTPRequestHandler):
server_version = "AIOSAutonomousReleaseCandidate/2"
sys_version = ""
@property
def application(self) -> CandidateApplication:
return self.server.application # type: ignore[attr-defined]
def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
self.send_response(status.value)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.end_headers()
self.wfile.write(encoded)
def _read_json(self) -> dict[str, Any]:
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if content_type != "application/json":
raise UnsupportedMediaType
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError as error:
raise InvalidRequest from error
if length <= 0 or length > 1_048_576:
raise InvalidRequest
try:
raw = self.rfile.read(length)
value = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise InvalidRequest from error
if not isinstance(value, dict):
raise InvalidRequest
return value
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/health":
self._send_json(
HTTPStatus.OK,
{
"status": "ok",
"mode": "candidate-shadow-only",
"production_side_effects": False,
"secrets_included": False,
},
)
return
if path == "/ready":
if self.application.ready():
self._send_json(
HTTPStatus.OK, {"status": "ready", "secrets_included": False}
)
else:
self._send_json(
HTTPStatus.SERVICE_UNAVAILABLE,
{"status": "not_ready", "secrets_included": False},
)
return
if path.startswith("/v1/tasks/") and path.endswith("/steps"):
task_id = path[len("/v1/tasks/") : -len("/steps")].strip("/")
if not task_id or "/" in task_id:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
return
try:
self._send_json(HTTPStatus.OK, self.application.api.task_steps(task_id))
except KeyError:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "task_not_found"})
return
if path.startswith("/v1/tasks/") and path.endswith("/evidence"):
task_id = path[len("/v1/tasks/") : -len("/evidence")].strip("/")
if not task_id or "/" in task_id:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
return
try:
self._send_json(HTTPStatus.OK, self.application.api.evidence(task_id))
except KeyError:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "task_not_found"})
return
if path.startswith("/v1/tasks/"):
task_id = path[len("/v1/tasks/") :].strip("/")
if not task_id or "/" in task_id:
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
return
try:
self._send_json(HTTPStatus.OK, self.application.api.task_status(task_id))
except KeyError:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "task_not_found"})
return
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
try:
payload = self._read_json()
if path == "/v1/tasks":
self._send_json(HTTPStatus.CREATED, self.application.api.create_task(payload))
return
if path == "/v1/release/plan":
self._send_json(
HTTPStatus.CREATED, self.application.api.create_release_plan(payload)
)
return
if path == "/v1/release/verify":
self._send_json(HTTPStatus.OK, self.application.api.verify_release(payload))
return
if path.startswith("/v1/tasks/") and path.endswith("/evidence/close"):
task_id = path[len("/v1/tasks/") : -len("/evidence/close")].strip("/")
if not task_id:
raise InvalidRequest
self._send_json(HTTPStatus.OK, self.application.api.close_evidence(task_id))
return
for action, handler in (
("claim", self.application.api.claim),
("heartbeat", self.application.api.heartbeat),
("result", self.application.api.result),
):
suffix = f"/{action}"
if path.startswith("/v1/tasks/") and path.endswith(suffix):
task_id = path[len("/v1/tasks/") : -len(suffix)].strip("/")
if not task_id or "/" in task_id:
raise InvalidRequest
self._send_json(HTTPStatus.OK, handler(task_id, payload))
return
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not_found"})
except UnsupportedMediaType:
self._send_json(
HTTPStatus.UNSUPPORTED_MEDIA_TYPE, {"error": "content_type_required"}
)
except (InvalidRequest, ValueError, KeyError):
self._send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid_request"})
except AutonomousReleaseError as error:
self._send_json(HTTPStatus.CONFLICT, {"error": error.code})
except Exception:
self._send_json(
HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "candidate_request_failed"}
)
def log_message(self, _format: str, *_args: Any) -> None:
return
class InvalidRequest(Exception):
pass
class UnsupportedMediaType(Exception):
pass
class CandidateHTTPServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = False
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
try:
loopback = ipaddress.ip_address(address[0]).is_loopback
except ValueError as error:
raise ValueError("candidate service requires a numeric loopback address") from error
if not loopback:
raise ValueError("candidate service must bind to loopback")
self.application = application
super().__init__(address, CandidateRequestHandler)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="aios-autonomous-release-service")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=19951)
parser.add_argument("--state", required=True)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
application = CandidateApplication(args.state)
server = CandidateHTTPServer((args.host, args.port), application)
try:
server.serve_forever(poll_interval=0.5)
finally:
server.server_close()
return 0
if __name__ == "__main__":
raise SystemExit(main())