diff --git a/src/containerizer/generate.py b/src/containerizer/generate.py new file mode 100644 index 0000000..acbbe64 --- /dev/null +++ b/src/containerizer/generate.py @@ -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"