#!/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, updates the Compose configuration, triggers deployment via Komodo API, and waits for deployment 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): url = f"{host}{endpoint}" 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") raise RuntimeError(f"Komodo API {endpoint} error {e.code}: {err}") from e def wait_for_update(host, key, secret, update_id, max_wait_sec=300): 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(5) try: updates_data = api_call(host, key, secret, "/read/ListUpdates", {}) for u in updates_data.get("updates", []): uid = (u.get("_id") or {}).get("$oid") or 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 is taking longer than {max_wait_sec}s (likely pulling large image). 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} ) print(f"[INFO] Created Stack successfully: {res.get('name', STACK_NAME)}") else: print(f"[INFO] Updating Komodo Stack '{STACK_NAME}' compose definition...") api_call( host, key, secret, "/write/UpdateStack", {"id": existing_stack["id"], "config": stack_config} ) 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} ) update_id = (deploy_res.get("_id") or {}).get("$oid") or deploy_res.get("id") if update_id: ok = wait_for_update(host, key, secret, update_id, max_wait_sec=300) if not ok: sys.exit(1) else: print(f"[SUCCESS] Deploy execution dispatched: {deploy_res}") if __name__ == "__main__": main()