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