From 5dc53d1a663d5823304491976a2b05f841036db1 Mon Sep 17 00:00:00 2001 From: ADMIN Date: Sat, 24 Oct 2026 00:09:12 +0000 Subject: [PATCH] feat: initial OpenHands Agent Canvas stack with Docker-in-Docker and automatic Komodo deployment --- .env.template | 7 ++ .gitea/workflows/deploy.yml | 47 +++++++++++++ .gitignore | 12 ++++ README.md | 27 +++++++ docker-compose.yml | 40 +++++++++++ scripts/deploy.py | 137 ++++++++++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+) create mode 100644 .env.template create mode 100644 .gitea/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100755 scripts/deploy.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..c22d912 --- /dev/null +++ b/.env.template @@ -0,0 +1,7 @@ +# OpenHands Agent Canvas Configuration +OPENHANDS_PORT=18000 + +# LLM Providers (configurabili qui oppure direttamente da interfaccia web) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GEMINI_API_KEY= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..f1fddfb --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,47 @@ +name: Deploy OpenHands Agent Canvas + +on: + push: + branches: + - master + - main + workflow_dispatch: + +jobs: + deploy: + name: Deploy to Komodo + runs-on: hp + env: + KOMODO_HOST: ${{ secrets.KOMODO_HOST || 'http://localhost:9120' }} + KOMODO_API_KEY: ${{ secrets.KOMODO_API_KEY }} + KOMODO_API_SECRET: ${{ secrets.KOMODO_API_SECRET }} + steps: + - name: Install System Prerequisites + run: | + if command -v apk >/dev/null 2>&1; then + apk update + apk add --no-cache python3 curl bash + fi + python3 --version + + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Deploy Stack via Komodo + run: | + echo "=== Deploying OpenHands Agent Canvas ===" + python3 scripts/deploy.py + + - name: Verify Health + run: | + echo "=== Checking health endpoint on port 18000 ===" + sleep 10 + for i in 1 2 3 4 5 6; do + if curl -fsSL -o /dev/null "http://127.0.0.1:18000/" 2>/dev/null || curl -fsSL -o /dev/null "http://127.0.0.1:18000/canvas" 2>/dev/null; then + echo "OpenHands Agent Canvas is reachable on http://127.0.0.1:18000/ !" + exit 0 + fi + echo "Waiting for container startup (attempt $i/6)..." + sleep 5 + done + echo "Container is deploying in background. Check status in Komodo Web UI." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c4a7f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Environment files +.env +*.env + +# Local runtime data +data/ +projects/ +*.log + +# Python cache +__pycache__/ +*.pyc diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bc2b27 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# OpenHands Agent Canvas Stack + +Deploy automatico di **OpenHands Agent Canvas** con runtime Docker (Docker-in-Docker / DooD tramite socket Docker host) gestito via Gitea Actions e Komodo Core su `server-tsj08`. + +## 🚀 Architettura + +- **Immagine Base:** `ghcr.io/openhands/agent-canvas:latest` +- **Runtime Isolato per Conversazione:** `OH_CONVERSATION_RUNTIME=docker` con immagine `ghcr.io/openhands/agent-server:latest-python` +- **Socket Docker:** `/var/run/docker.sock` montato nel container per consentire lo spawn di runtime isolati per ciascun task/conversazione +- **Rete:** `scoobydoo` (external network di Komodo) +- **Porta Host:** `18000` (mappata su container `8000`) +- **Web UI URL:** `http://:18000/` o `http://:18000/canvas` + +## 🔄 CI/CD & Deploy Automatico + +Ad ogni push sui branch `main` o `master`: +1. Il workflow `.gitea/workflows/deploy.yml` viene attivato sul runner `hp`. +2. Esegue `scripts/deploy.py`, che dialoga con Komodo Core API (`http://localhost:9120`). +3. Aggiorna la configurazione dello stack `openhands-agent-canvas` e avvia il deploy del container. +4. Esegue un test di raggiungibilità HTTP sulla porta `18000`. + +## 🔐 Configurazione Segreti + +I seguenti segreti sono configurati nei Secrets del repository Gitea: +- `KOMODO_HOST` +- `KOMODO_API_KEY` +- `KOMODO_API_SECRET` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6cfafd2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.8" + +services: + agent-canvas: + image: ghcr.io/openhands/agent-canvas:latest + container_name: openhands-agent-canvas + restart: unless-stopped + ports: + - "${OPENHANDS_PORT:-18000}:8000" + environment: + - AGENT_CANVAS_ALLOW_LAN_SESSION_KEY=true + # Modalita container runtime per conversazione / sandbox Docker + - OH_CONVERSATION_RUNTIME=docker + - OH_CONVERSATION_IMAGE=ghcr.io/openhands/agent-server:latest-python + # LLM Provider credentials + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + - GEMINI_API_KEY=${GEMINI_API_KEY:-} + volumes: + # Docker socket per instanziare i runtime effimeri dei container + - /var/run/docker.sock:/var/run/docker.sock + # Dati applicativi e sessioni + - openhands-data:/home/openhands/.openhands + # Workspace montato per i progetti + - openhands-projects:/projects + labels: + npm.proxy.domains: "openhands.nickmalik.dev" + npm.proxy.host: "openhands-agent-canvas" + npm.proxy.port: "8000" + npm.proxy.scheme: "http" + npm.proxy.ssl.force: "true" + +volumes: + openhands-data: + openhands-projects: + +networks: + default: + name: scoobydoo + external: true diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100755 index 0000000..87f4fe2 --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,137 @@ +#!/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, and triggers deployment via Komodo API. +""" + +import os +import sys +import json +import time +import urllib.request +import urllib.error + +STACK_NAME = "openhands-agent-canvas" +DEFAULT_SERVER = "server-tsj08" + + +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", "http://localhost:9120") + 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}") + + host = host or "http://localhost:9120" + 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 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(f"::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} + ) + print(f"[SUCCESS] Deploy execution dispatched: {deploy_res}") + + +if __name__ == "__main__": + main()