182 lines
6.5 KiB
Python
Executable File
182 lines
6.5 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):
|
|
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()
|