decomposer: generate files for Create a production-suitable Dockerfile and minimal Docker Compose configuration for packaging and running the completed FastAPI endpoint monitoring service.
This commit is contained in:
75
src/containerizer/generate.py
Normal file
75
src/containerizer/generate.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .render import PIN_RE, compose, dockerfile, dockerignore, validate_module
|
||||||
|
|
||||||
|
ARTIFACT_NAMES = ("Dockerfile", "compose.yaml", ".dockerignore")
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration(spec: dict[str, Any]) -> tuple[str, int, str | None]:
|
||||||
|
cfg = spec.get("container", {})
|
||||||
|
module = cfg.get("startup_module", spec.get("startup_module", "app.main:app"))
|
||||||
|
port = cfg.get("port", spec.get("port", 8000))
|
||||||
|
health = cfg.get("health_endpoint", spec.get("health_endpoint"))
|
||||||
|
validate_module(module)
|
||||||
|
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
|
||||||
|
raise ValueError("port must be an integer from 1 through 65535")
|
||||||
|
if health is not None and (not isinstance(health, str) or not health.startswith("/")):
|
||||||
|
raise ValueError("health_endpoint must be an absolute path")
|
||||||
|
return module, port, health
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_existing_service(root: Path, module: str, health: str | None) -> None:
|
||||||
|
mod, _ = validate_module(module)
|
||||||
|
source = root.joinpath(*mod.split(".")).with_suffix(".py")
|
||||||
|
if not source.is_file():
|
||||||
|
raise ValueError(f"startup module does not exist: {source.relative_to(root)}")
|
||||||
|
lock = root / "requirements.lock"
|
||||||
|
if not lock.is_file():
|
||||||
|
raise ValueError("existing service must provide requirements.lock")
|
||||||
|
entries = [line.strip() for line in lock.read_text().splitlines()
|
||||||
|
if line.strip() and not line.lstrip().startswith("#")]
|
||||||
|
invalid = [entry for entry in entries if not PIN_RE.fullmatch(entry)]
|
||||||
|
if not entries or invalid:
|
||||||
|
raise ValueError("requirements.lock must contain only exact name==version pins")
|
||||||
|
if health:
|
||||||
|
texts = "\n".join(p.read_text(errors="ignore") for p in root.rglob("*.py"))
|
||||||
|
if f'"{health}"' not in texts and f"'{health}'" not in texts:
|
||||||
|
raise ValueError("configured health_endpoint was not found in Python routes")
|
||||||
|
|
||||||
|
|
||||||
|
def containerize(payload: dict[str, Any], service_dir: str | Path) -> dict[str, Any]:
|
||||||
|
required = ("service_specification", "target_management_implementation",
|
||||||
|
"endpoint_check_implementation", "current_status_implementation")
|
||||||
|
missing = [key for key in required if key not in payload]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"missing contract keys: {', '.join(missing)}")
|
||||||
|
root = Path(service_dir).resolve()
|
||||||
|
module, port, health = _configuration(payload["service_specification"])
|
||||||
|
_validate_existing_service(root, module, health)
|
||||||
|
rendered = {
|
||||||
|
"Dockerfile": dockerfile(module, port),
|
||||||
|
"compose.yaml": compose(port, health),
|
||||||
|
".dockerignore": dockerignore(),
|
||||||
|
}
|
||||||
|
for name, content in rendered.items():
|
||||||
|
(root / name).write_text(content)
|
||||||
|
result = {key: payload[key] for key in required}
|
||||||
|
result["container_artifacts"] = {
|
||||||
|
"service_directory": str(root),
|
||||||
|
"startup": f"python -m uvicorn {module} --host 0.0.0.0 --port {port} --proxy-headers",
|
||||||
|
"dependency_lock": "requirements.lock (exact pins; installed with --no-deps)",
|
||||||
|
"non_root_user": "10001:10001",
|
||||||
|
"health_endpoint": health,
|
||||||
|
"files": {name: {"sha256": hashlib.sha256(content.encode()).hexdigest(),
|
||||||
|
"content": content} for name, content in rendered.items()},
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dumps(result: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||||
Reference in New Issue
Block a user