homelab-codex-ws/deploy_agent.py

220 lines
6.2 KiB
Python
Raw Permalink Normal View History

2026-04-22 21:33:58 +02:00
from pathlib import Path
import subprocess
2026-04-22 21:19:00 +02:00
from ollama_client import ask
2026-04-22 21:33:58 +02:00
import yaml
2026-04-22 21:19:00 +02:00
2026-04-22 21:33:58 +02:00
def _build_prompt(service: str, extra: str = "") -> str:
2026-04-22 21:19:00 +02:00
prompt = (
"Output ONLY valid docker-compose YAML. No explanations. No markdown.\n\n"
f"Generate a docker-compose file for this service: {service}"
)
2026-04-22 21:33:58 +02:00
if extra:
prompt = f"{prompt}\n{extra}"
return prompt
def _generate_compose(service: str, extra: str = "") -> str:
base_prompt = (
_build_prompt(service, extra)
)
retry_prompt = (
"Previous output was invalid. Fix YAML and return valid docker-compose only."
)
prompt = base_prompt
for _ in range(3):
response = ask(prompt)
if response.startswith("ERROR:"):
return response
try:
parsed = yaml.safe_load(response)
except yaml.YAMLError:
prompt = f"{base_prompt}\n\n{retry_prompt}"
continue
if isinstance(parsed, dict) and "services" in parsed:
return response
prompt = f"{base_prompt}\n\n{retry_prompt}"
return "ERROR: invalid docker-compose"
def generate_compose(service: str) -> str:
return _generate_compose(service)
2026-04-22 21:41:22 +02:00
def _is_valid_compose(compose: str) -> bool:
try:
parsed = yaml.safe_load(compose)
except yaml.YAMLError:
return False
return isinstance(parsed, dict) and "services" in parsed
2026-04-22 22:05:15 +02:00
def _get_compose_logs(path: Path) -> str:
try:
ids_result = subprocess.run(
["docker", "compose", "ps", "-q"],
cwd=path,
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
return str(exc)
2026-04-22 21:41:22 +02:00
2026-04-22 22:05:15 +02:00
container_ids = [line.strip() for line in ids_result.stdout.splitlines() if line.strip()]
if not container_ids:
return ""
parts = []
total = 0
limit = 2000
for container_id in container_ids:
try:
logs_result = subprocess.run(
["docker", "logs", container_id, "--tail=50"],
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
chunk = f"{container_id}:\n{exc}\n"
else:
chunk_body = logs_result.stdout or logs_result.stderr
chunk = f"{container_id}:\n{chunk_body}\n"
remaining = limit - total
if remaining <= 0:
break
if len(chunk) > remaining:
chunk = chunk[:remaining]
parts.append(chunk)
total += len(chunk)
if total >= limit:
break
return "".join(parts).strip()
def _fix_compose(service: str, error: str, status: str, logs: str) -> str:
2026-04-22 21:41:22 +02:00
prompt = (
"Deployment failed.\n\n"
f"Service: {service}\n\n"
f"Error: {error}\n\n"
f"Status: {status}\n\n"
2026-04-22 22:05:15 +02:00
f"Logs: {logs}\n\n"
2026-04-22 21:41:22 +02:00
"Fix the docker-compose YAML. Return ONLY corrected YAML."
)
response = ask(prompt)
if response.startswith("ERROR:"):
return response
if not _is_valid_compose(response):
return "ERROR: invalid docker-compose"
return response
2026-04-22 21:33:58 +02:00
def get_service_status(path: Path) -> str:
try:
result = subprocess.run(
["docker", "compose", "ps"],
cwd=path,
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
return f"ERROR: {exc}"
output = result.stdout.strip()
if result.returncode != 0:
error = result.stderr.strip() or output or "docker compose ps failed"
return f"ERROR: {error}"
return output
2026-04-22 21:41:22 +02:00
def _run_compose_up(path: Path) -> tuple[bool, str]:
try:
result = subprocess.run(
["docker", "compose", "up", "-d"],
cwd=path,
check=False,
capture_output=True,
text=True,
)
except Exception as exc:
return False, str(exc)
if result.returncode != 0:
error = result.stderr.strip() or result.stdout.strip() or "docker compose up failed"
return False, error
return True, result.stdout.strip()
2026-04-22 21:33:58 +02:00
def deploy_service(service: str) -> str:
prompt_extra = ""
try:
docker_ps = subprocess.run(
["docker", "ps"],
check=False,
capture_output=True,
text=True,
)
docker_ps_output = docker_ps.stdout
if ":80->" in docker_ps_output or "0.0.0.0:80-" in docker_ps_output or "[::]:80-" in docker_ps_output:
prompt_extra = "Use a different port than 80"
except Exception:
docker_ps_output = ""
compose = _generate_compose(service, prompt_extra)
if compose.startswith("ERROR:"):
return compose
deployments_dir = Path("./deployments")
target_dir = deployments_dir / service
suffix = 1
while target_dir.exists():
target_dir = deployments_dir / f"{service}-{suffix}"
suffix += 1
target_dir.mkdir(parents=True, exist_ok=False)
compose_path = target_dir / "docker-compose.yml"
compose_path.write_text(compose, encoding="utf-8")
2026-04-22 21:41:22 +02:00
ok, error = _run_compose_up(target_dir)
if not ok:
status = get_service_status(target_dir)
2026-04-22 22:05:15 +02:00
logs = _get_compose_logs(target_dir)
fixed_compose = _fix_compose(service, error, status, logs)
2026-04-22 21:41:22 +02:00
if fixed_compose.startswith("ERROR:"):
if status and not status.startswith("ERROR:"):
return f"ERROR: {error}\n{status}"
return f"ERROR: {error}"
compose_path.write_text(fixed_compose, encoding="utf-8")
ok, error = _run_compose_up(target_dir)
if not ok:
status = get_service_status(target_dir)
2026-04-22 22:08:26 +02:00
logs = _get_compose_logs(target_dir)
debug = f"""
ESCALATION REQUIRED
SERVICE: {service}
ERROR:
{error}
STATUS:
{status}
LOGS:
{logs}
"""
return f"ESCALATE_TO_CODEX\n{debug}"
2026-04-22 21:33:58 +02:00
status = get_service_status(target_dir)
if status.startswith("ERROR:"):
return status
2026-04-22 22:05:15 +02:00
if "Up" not in status or "unhealthy" in status.lower():
2026-04-22 21:33:58 +02:00
return f"ERROR: no running services\n{status}"
return f"DEPLOYED: {target_dir.name}\n{status}"
2026-04-22 21:19:00 +02:00
if __name__ == "__main__":
2026-04-22 21:33:58 +02:00
print(deploy_service("nginx"))