diff --git a/codex_context.yaml b/codex_context.yaml index dbf99b1..f812f21 100644 --- a/codex_context.yaml +++ b/codex_context.yaml @@ -70,6 +70,11 @@ SESSION_STATE: D10: "Updated ./ollama_client.py for reliability: urlopen timeout=10, try/except guards for HTTPError, URLError, JSONDecodeError, invalid response shape, fallback Exception; errors return 'ERROR: '." D11: "Created ./deploy_agent.py: imports ask from ollama_client; generate_compose(service)->strict YAML-only prompt; propagates 'ERROR:' responses; inline test generate_compose('nginx')." D12: "User requested git commit on 2026-04-22; commit scope includes ./codex_context.yaml, ./ollama_client.py, ./deploy_agent.py, ./start-codex.sh." + D13: "Git commit created on 2026-04-22: 4cf42fc 'Add local Ollama automation scripts'." + D14: "Updated ./deploy_agent.py: added PyYAML validation, requires top-level services key, retries invalid output up to 2 times with corrective prompt, returns 'ERROR: invalid docker-compose' after exhaustion." + D15: "Extended ./deploy_agent.py with deploy_service(service): generates compose, writes ./deployments//docker-compose.yml without overwriting existing directories, runs 'docker compose up -d' via subprocess, returns DEPLOYED or ERROR." + D16: "Updated ./deploy_agent.py with get_service_status(path), post-deploy 'docker compose ps' verification requiring 'Up', error outputs including ps output when available, and pre-deploy 'docker ps' port-80 check that adds prompt note 'Use a different port than 80'." + D17: "User requested git commit on 2026-04-22; commit scope includes ./deploy_agent.py and ./codex_context.yaml for deployment status and safety updates." todos: T1: "For all future meaningful changes/decisions, update and overwrite ./codex_context.yaml." T2: "DONE: Commit current changes." @@ -80,6 +85,10 @@ SESSION_STATE: T7: "DONE: Add minimal local Ollama Python client." T8: "DONE: Harden local Ollama Python client error handling." T9: "DONE: Add compose-generation agent using local LLM client." + T10: "DONE: Commit local Ollama automation scripts." + T11: "DONE: Add docker-compose YAML validation and retry logic." + T12: "DONE: Add automatic service deployment workflow." + T13: "DONE: Add deployment status verification and basic port-80 safety check." issues: I1: "Tailscale DNS health warning: configured DNS servers unreachable." I2: "Preferred gateway path unavailable: 100.108.208.3:8080 connection failed." diff --git a/deploy_agent.py b/deploy_agent.py index 93ab10e..96a96fc 100644 --- a/deploy_agent.py +++ b/deploy_agent.py @@ -1,16 +1,118 @@ +from pathlib import Path +import subprocess + from ollama_client import ask +import yaml -def generate_compose(service: str) -> str: +def _build_prompt(service: str, extra: str = "") -> str: prompt = ( "Output ONLY valid docker-compose YAML. No explanations. No markdown.\n\n" f"Generate a docker-compose file for this service: {service}" ) - response = ask(prompt) - if response.startswith("ERROR:"): - return response - return response + 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) + + +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 + + +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") + + try: + subprocess.run( + ["docker", "compose", "up", "-d"], + cwd=target_dir, + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: + message = exc.stderr.strip() or exc.stdout.strip() or str(exc) + return f"ERROR: {message}" + except Exception as exc: + return f"ERROR: {exc}" + + status = get_service_status(target_dir) + if status.startswith("ERROR:"): + return status + if "Up" not in status: + return f"ERROR: no running services\n{status}" + + return f"DEPLOYED: {target_dir.name}\n{status}" if __name__ == "__main__": - print(generate_compose("nginx")) + print(deploy_service("nginx"))