254 lines
9.7 KiB
Python
254 lines
9.7 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
from typing import Any, Mapping
|
|
|
|
from .common import atomic_write_json, require_oid, sha256_file
|
|
|
|
|
|
class CandidateFixtureError(RuntimeError):
|
|
pass
|
|
|
|
|
|
FORBIDDEN_ROOTS = (
|
|
Path("/var/lib/aios-gitops/release"),
|
|
Path("/var/lib/aios-gitops/artifacts"),
|
|
Path("/opt/aios"),
|
|
Path("/data/aios-gitops/runtime/releases"),
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CandidateFixtureConfig:
|
|
root: Path
|
|
host: str
|
|
port: int
|
|
repository: str
|
|
version: str
|
|
release_branch: str
|
|
expected_base_oid: str
|
|
target_oid: str
|
|
target_tree_oid: str
|
|
tag_oid: str
|
|
approval_plan_id: str
|
|
|
|
@classmethod
|
|
def from_mapping(cls, value: Mapping[str, Any]) -> "CandidateFixtureConfig":
|
|
required = {
|
|
"root", "host", "port", "repository", "version", "release_branch",
|
|
"expected_base_oid", "target_oid", "target_tree_oid", "tag_oid",
|
|
"approval_plan_id",
|
|
}
|
|
if set(value) != required:
|
|
raise CandidateFixtureError("CANDIDATE_FIXTURE_SCHEMA_INVALID")
|
|
root = Path(value["root"])
|
|
if not root.is_absolute() or root.is_symlink():
|
|
raise CandidateFixtureError("CANDIDATE_ROOT_INVALID")
|
|
resolved = root.resolve()
|
|
if any(resolved == denied or denied in resolved.parents for denied in FORBIDDEN_ROOTS):
|
|
raise CandidateFixtureError("PRODUCTION_ROOT_DENIED")
|
|
if value["host"] != "127.0.0.1" or int(value["port"]) != 8080:
|
|
raise CandidateFixtureError("CANDIDATE_ENDPOINT_INVALID")
|
|
if value["version"] != "V2R1C4":
|
|
raise CandidateFixtureError("CANDIDATE_VERSION_INVALID")
|
|
for key in ("expected_base_oid", "target_oid", "target_tree_oid", "tag_oid"):
|
|
require_oid(value[key], key)
|
|
for key in ("repository", "release_branch", "approval_plan_id"):
|
|
if not isinstance(value[key], str) or not value[key]:
|
|
raise CandidateFixtureError("CANDIDATE_BINDING_INVALID")
|
|
return cls(
|
|
root=root, host=value["host"], port=int(value["port"]),
|
|
repository=value["repository"], version=value["version"],
|
|
release_branch=value["release_branch"],
|
|
expected_base_oid=value["expected_base_oid"],
|
|
target_oid=value["target_oid"], target_tree_oid=value["target_tree_oid"],
|
|
tag_oid=value["tag_oid"], approval_plan_id=value["approval_plan_id"],
|
|
)
|
|
|
|
|
|
class CandidateIntegrationFixture:
|
|
def __init__(self, config: CandidateFixtureConfig):
|
|
self.config = config
|
|
|
|
def initialize(self) -> dict[str, Any]:
|
|
root = self.config.root
|
|
paths = {
|
|
"registry": root / "registry" / "V2R1C4.json",
|
|
"baseline": root / "baseline" / "CANDIDATE.json",
|
|
"artifact": root / "artifacts" / "V2R1C4.definition.json",
|
|
"binding": root / "releases" / "V2R1C4" / "binding.json",
|
|
}
|
|
for path in paths.values():
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
|
|
binding = {
|
|
"approval_plan_id": self.config.approval_plan_id,
|
|
"candidate_only": True,
|
|
"expected_base_oid": self.config.expected_base_oid,
|
|
"production_deployed": False,
|
|
"release_branch": self.config.release_branch,
|
|
"repository": self.config.repository,
|
|
"target_oid": self.config.target_oid,
|
|
"target_tree_oid": self.config.target_tree_oid,
|
|
"version": self.config.version,
|
|
}
|
|
atomic_write_json(paths["binding"], binding)
|
|
binding_sha256 = sha256_file(paths["binding"])
|
|
atomic_write_json(paths["artifact"], {
|
|
"binding_sha256": binding_sha256,
|
|
"candidate_only": True,
|
|
"production_artifact": False,
|
|
"target_oid": self.config.target_oid,
|
|
"target_tree_oid": self.config.target_tree_oid,
|
|
"version": self.config.version,
|
|
})
|
|
artifact_sha256 = sha256_file(paths["artifact"])
|
|
atomic_write_json(paths["registry"], {
|
|
"artifact_definition_sha256": artifact_sha256,
|
|
"candidate_only": True,
|
|
"status": "ISOLATED_CANDIDATE",
|
|
"target_oid": self.config.target_oid,
|
|
"version": self.config.version,
|
|
})
|
|
atomic_write_json(paths["baseline"], {
|
|
"candidate_only": True,
|
|
"expected_base_oid": self.config.expected_base_oid,
|
|
"production_baseline_modified": False,
|
|
"target_oid": self.config.target_oid,
|
|
"version": self.config.version,
|
|
})
|
|
return {
|
|
"artifact_definition_sha256": artifact_sha256,
|
|
"binding_sha256": binding_sha256,
|
|
"candidate_only": True,
|
|
"paths": {key: str(value) for key, value in sorted(paths.items())},
|
|
}
|
|
|
|
def merge(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
required = {
|
|
"operation", "repository", "release_branch", "source_oid",
|
|
"target_oid", "expected_base_oid", "approval_plan_id",
|
|
}
|
|
if set(request) != required:
|
|
raise CandidateFixtureError("MERGE_REQUEST_SCHEMA_INVALID")
|
|
expected = {
|
|
"operation": "candidate_merge",
|
|
"repository": self.config.repository,
|
|
"release_branch": self.config.release_branch,
|
|
"source_oid": self.config.target_oid,
|
|
"target_oid": self.config.target_oid,
|
|
"expected_base_oid": self.config.expected_base_oid,
|
|
"approval_plan_id": self.config.approval_plan_id,
|
|
}
|
|
if dict(request) != expected:
|
|
raise CandidateFixtureError("MERGE_BINDING_MISMATCH")
|
|
return {
|
|
"candidate_only": True,
|
|
"release_oid": self.config.target_oid,
|
|
"target_tree_oid": self.config.target_tree_oid,
|
|
}
|
|
|
|
def tag(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
|
expected = {
|
|
"approval_plan_id": self.config.approval_plan_id,
|
|
"execute": False,
|
|
"operation": "candidate_tag_plan",
|
|
"target_oid": self.config.target_oid,
|
|
"target_tree_oid": self.config.target_tree_oid,
|
|
"version": self.config.version,
|
|
}
|
|
if dict(request) != expected:
|
|
raise CandidateFixtureError("TAG_BINDING_MISMATCH")
|
|
return {
|
|
"candidate_only": True,
|
|
"execute": False,
|
|
"tag_oid": self.config.tag_oid,
|
|
"tag_target_oid": self.config.target_oid,
|
|
"version": self.config.version,
|
|
}
|
|
|
|
|
|
def _handler(fixture: CandidateIntegrationFixture):
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "AIOSCandidateFixture/1"
|
|
|
|
def _json(self, status: int, value: Mapping[str, Any]):
|
|
raw = json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(raw)))
|
|
self.end_headers()
|
|
self.wfile.write(raw)
|
|
|
|
def do_GET(self):
|
|
if self.path != "/health":
|
|
self._json(404, {"status": "NOT_FOUND"})
|
|
return
|
|
self._json(200, {
|
|
"candidate_only": True,
|
|
"production_deployed": False,
|
|
"status": "ok",
|
|
"target_oid": fixture.config.target_oid,
|
|
"version": fixture.config.version,
|
|
})
|
|
|
|
def do_POST(self):
|
|
if self.path != "/api/create-tag":
|
|
self._json(404, {"status": "NOT_FOUND"})
|
|
return
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
if length < 2 or length > 65536:
|
|
raise CandidateFixtureError("TAG_REQUEST_SIZE_INVALID")
|
|
value = json.loads(self.rfile.read(length))
|
|
if not isinstance(value, dict):
|
|
raise CandidateFixtureError("TAG_REQUEST_INVALID")
|
|
self._json(200, fixture.tag(value))
|
|
except (ValueError, json.JSONDecodeError, CandidateFixtureError) as exc:
|
|
self._json(409, {"error": str(exc), "status": "FAILED_CLOSED"})
|
|
|
|
def log_message(self, fmt, *args):
|
|
return
|
|
|
|
return Handler
|
|
|
|
|
|
def _read_config(path: Path) -> CandidateFixtureConfig:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
if not isinstance(value, dict):
|
|
raise CandidateFixtureError("CANDIDATE_FIXTURE_SCHEMA_INVALID")
|
|
return CandidateFixtureConfig.from_mapping(value)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--config", type=Path, required=True)
|
|
parser.add_argument("--merge-stdio", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
fixture = CandidateIntegrationFixture(_read_config(args.config))
|
|
fixture.initialize()
|
|
if args.merge_stdio:
|
|
raw = sys.stdin.buffer.readline(1048577)
|
|
if not raw or len(raw) > 1048576:
|
|
raise CandidateFixtureError("MERGE_REQUEST_SIZE_INVALID")
|
|
value = json.loads(raw)
|
|
if not isinstance(value, dict):
|
|
raise CandidateFixtureError("MERGE_REQUEST_INVALID")
|
|
sys.stdout.write(json.dumps(fixture.merge(value), sort_keys=True) + "\n")
|
|
return 0
|
|
server = ThreadingHTTPServer(
|
|
(fixture.config.host, fixture.config.port), _handler(fixture))
|
|
try:
|
|
server.serve_forever()
|
|
finally:
|
|
server.server_close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|