#!/usr/bin/env python3 """ Deploy script for OpenHands Agent Canvas on Komodo Core. Reads docker-compose.yml, ensures the Komodo Stack exists on server-tsj08, waits for any in-flight deployment to complete, updates the Compose configuration, triggers deployment via Komodo API, and monitors until completion. """ import os import sys import json import time import urllib.request import urllib.error STACK_NAME = "openhands-agent-canvas" DEFAULT_SERVER = "server-tsj08" DEFAULT_KOMODO_HOST = "https://komodo.nickmalik.dev" def get_komodo_config(): host = os.getenv("KOMODO_HOST", "").strip() key = os.getenv("KOMODO_API_KEY", "").strip() secret = os.getenv("KOMODO_API_SECRET", "").strip() if not (host and key and secret): cfg_path = os.path.expanduser("~/.config/komodo/komodo.cli.toml") if os.path.exists(cfg_path): try: import tomllib with open(cfg_path, "rb") as f: cfg = tomllib.load(f) host = host or cfg.get("host", DEFAULT_KOMODO_HOST) key = key or cfg.get("cli_key", "") secret = secret or cfg.get("cli_secret", "") except Exception as e: print(f"[WARN] Failed to parse {cfg_path}: {e}") # Fallback to public/internal Komodo domain if localhost is passed inside a runner container if not host or "localhost" in host or "127.0.0.1" in host: host = DEFAULT_KOMODO_HOST if not (key and secret): print("::error::KOMODO_API_KEY and KOMODO_API_SECRET must be set.") sys.exit(1) return host.rstrip("/"), key, secret def api_call(host, key, secret, endpoint, payload, retries=5, retry_delay=5): url = f"{host}{endpoint}" for attempt in range(retries): req = urllib.request.Request( url, data=json.dumps(payload).encode("utf-8"), headers={ "Content-Type": "application/json", "x-api-key": key, "x-api-secret": secret, }, method="POST", ) try: with urllib.request.urlopen(req, timeout=120) as resp: data = resp.read().decode("utf-8") return json.loads(data) if data else {} except urllib.error.HTTPError as e: err = e.read().decode("utf-8", errors="replace") if ("Stack busy" in err or "busy" in err) and attempt < retries - 1: print(f"[INFO] Stack is busy. Waiting {retry_delay}s before retry ({attempt + 1}/{retries})...") time.sleep(retry_delay) continue raise RuntimeError(f"Komodo API {endpoint} error {e.code}: {err}") from e except Exception as e: if attempt < retries - 1: print(f"[WARN] Request to {endpoint} failed ({e}). Retrying in {retry_delay}s...") time.sleep(retry_delay) continue raise def wait_for_stack_idle(host, key, secret, stack_id, max_wait_sec=600): """Waits for any active DeployStack operations targeting stack_id to finish.""" start_time = time.time() while time.time() - start_time < max_wait_sec: try: updates = api_call(host, key, secret, "/read/ListUpdates", {}).get("updates", []) in_progress = [ u for u in updates if (u.get("target") or {}).get("id") == stack_id and u.get("status") == "InProgress" ] if not in_progress: return True print(f"[INFO] Waiting for in-progress operation {in_progress[0].get('operation')} ({in_progress[0].get('id')}) to finish...") except Exception as e: print(f"[WARN] Failed checking updates: {e}") time.sleep(10) print(f"[WARN] Stack did not become idle after {max_wait_sec}s.") return False def wait_for_update(host, key, secret, update_id, max_wait_sec=600): print(f"[INFO] Monitoring deployment update {update_id}...") start_time = time.time() last_status = None while time.time() - start_time < max_wait_sec: time.sleep(10) try: updates_data = api_call(host, key, secret, "/read/ListUpdates", {}) for u in updates_data.get("updates", []): uid = u.get("id") if uid == update_id: status = u.get("status") if status != last_status: print(f" -> Deployment status: {status}") last_status = status if status in ("Complete", "Failed", "Cancelled"): success = u.get("success", False) for log in (u.get("logs") or [])[-10:]: print(f" [log] {log}") if success: print(f"[SUCCESS] Deployment completed successfully!") return True else: print(f"::error::Deployment finished with status {status}") return False except Exception as e: print(f"[WARN] Error polling update status: {e}") print(f"[WARN] Deployment update still pending after {max_wait_sec}s. Continuing.") return True def main(): host, key, secret = get_komodo_config() print(f"=== Komodo Target: {host} ===") # 1. Resolve server ID servers = api_call(host, key, secret, "/read/ListServers", {}) server_id = None for s in servers: if s.get("name") == DEFAULT_SERVER or s.get("id") == DEFAULT_SERVER: server_id = s.get("id") break if not server_id and servers: server_id = servers[0].get("id") print(f"[INFO] Server '{DEFAULT_SERVER}' not found by name, using '{servers[0].get('name')}' ({server_id})") elif server_id: print(f"[INFO] Target server '{DEFAULT_SERVER}' resolved to id: {server_id}") else: print("::error::No active servers found in Komodo.") sys.exit(1) # 2. Read docker-compose.yml compose_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "docker-compose.yml") if not os.path.exists(compose_path): compose_path = "docker-compose.yml" with open(compose_path, "r", encoding="utf-8") as f: compose_content = f.read() # 3. Check or Create Stack stacks = api_call(host, key, secret, "/read/ListStacks", {}) existing_stack = None for stk in stacks: if stk.get("name") == STACK_NAME: existing_stack = stk break stack_config = { "server_id": server_id, "auto_pull": True, "run_build": False, "auto_update": True, "auto_update_all_services": True, "file_contents": compose_content, "environment": os.getenv("STACK_ENVIRONMENT", ""), } if not existing_stack: print(f"[INFO] Creating Komodo Stack '{STACK_NAME}'...") res = api_call( host, key, secret, "/write/CreateStack", {"name": STACK_NAME, "config": stack_config} ) stack_id = res.get("id") or res.get("_id", {}).get("$oid") print(f"[INFO] Created Stack successfully: {STACK_NAME}") else: stack_id = existing_stack["id"] # Ensure stack is not currently executing another deployment wait_for_stack_idle(host, key, secret, stack_id, max_wait_sec=600) print(f"[INFO] Updating Komodo Stack '{STACK_NAME}' compose definition...") api_call( host, key, secret, "/write/UpdateStack", {"id": stack_id, "config": stack_config}, retries=10, retry_delay=10 ) print(f"[INFO] Updated Stack configuration.") # 4. Trigger Deploy print(f"[INFO] Executing DeployStack for '{STACK_NAME}'...") deploy_res = api_call( host, key, secret, "/execute/DeployStack", {"stack": STACK_NAME, "services": [], "stop_time": None}, retries=10, retry_delay=10 ) update_id = deploy_res.get("id") or (deploy_res.get("_id") or {}).get("$oid") if update_id: ok = wait_for_update(host, key, secret, update_id, max_wait_sec=600) if not ok: sys.exit(1) else: print(f"[SUCCESS] Deploy execution dispatched: {deploy_res}") if __name__ == "__main__": main()