Files
openhands-agent-canvas/scripts/deploy.py
T
gitea b808a6eefe
Deploy OpenHands Agent Canvas / Deploy to Komodo (push) Successful in 1m44s
fix(deploy): wait for stack to become idle before update and deploy
2026-10-24 00:16:03 +00:00

220 lines
8.3 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,
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()