Files
openhands-agent-canvas/scripts/deploy.py
T
gitea d54691c7e7
Deploy OpenHands Agent Canvas / Deploy to Komodo (push) Failing after 1m12s
fix: handle stack busy with retry and match update id properly
2026-10-24 00:13:11 +00:00

196 lines
7.2 KiB
Python
Executable File

#!/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, 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 is busy with another operation, wait and retry
if ("Stack busy" in err or "busy" in err) and attempt < retries - 1:
print(f"[INFO] Stack is busy with a previous operation. Waiting {retry_delay}s (attempt {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_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 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}
)
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},
retries=12, retry_delay=5
)
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=12, retry_delay=5
)
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()