224 lines
8.7 KiB
Python
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())
|