from __future__ import annotations import time from pathlib import Path from typing import Any, Dict import requests from lab.compose import compose_payload, generate_compose from lab.utils import command_available, run, write_json def prepare(profile: Dict[str, Any], run_dir: Path, dry_run: bool = False) -> Dict[str, Any]: payload = compose_payload(profile) compose_path = run_dir / "compose" / "compose.yaml" result = { "compose_path": str(compose_path), "service_count": len(payload.get("services", {})), "compose_preview": payload, "docker_available": command_available("docker"), "status": "ready", } if dry_run: result["status"] = "planned" return result compose_path, payload = generate_compose(profile, run_dir) if not result["docker_available"]: result["status"] = "blocked-artifact" result["blocked_reason"] = "docker unavailable on this machine" return result config = run(["docker", "compose", "-f", str(compose_path), "config"], cwd=run_dir) result["compose_config_rc"] = config.returncode if config.returncode != 0: result["status"] = "blocked-artifact" result["blocked_reason"] = config.stderr.strip() or "docker compose config failed" return result up = run(["docker", "compose", "-f", str(compose_path), "up", "-d", "--wait"], cwd=run_dir) result["compose_up_rc"] = up.returncode if up.returncode != 0: result["status"] = "blocked-artifact" result["blocked_reason"] = up.stderr.strip() or up.stdout.strip() or "docker compose up failed" return result return result def wait_ready(profile: Dict[str, Any], run_dir: Path, compose_path: Path) -> Dict[str, Any]: timeout_seconds = int(profile.get("ready_timeout_seconds") or 45) baseline_urls = profile.get("baseline_urls", []) or [] started = time.monotonic() observations = [] status = "completed" detail = f"baseline urls ready ({len(baseline_urls)})" while True: observations = [] ready = True for url in baseline_urls: try: response = requests.get(url, timeout=4) observations.append({"url": url, "status_code": response.status_code}) if response.status_code >= 500: ready = False except Exception as exc: observations.append({"url": url, "error": str(exc)}) ready = False if ready: break if time.monotonic() - started >= timeout_seconds: status = "failed" detail = f"services not ready within {timeout_seconds}s" break time.sleep(1) payload = { "status": status, "detail": detail, "elapsed_seconds": round(time.monotonic() - started, 1), "observations": observations, "compose_path": str(compose_path), } write_json(run_dir / "logs" / "ready.json", payload) return payload def teardown(run_dir: Path, compose_path: Path) -> Dict[str, Any]: if not command_available("docker") or not compose_path.exists(): return {"status": "skipped", "detail": "docker unavailable or compose file missing"} down = run(["docker", "compose", "-f", str(compose_path), "down", "-v", "--remove-orphans"], cwd=run_dir) if down.returncode != 0: return {"status": "failed", "detail": down.stderr.strip() or down.stdout.strip() or "docker compose down failed"} return {"status": "completed", "detail": "docker compose down completed"}