This commit is contained in:
Fabzer 2026-04-24 19:24:50 -05:00 committed by GitHub
commit b4f338ccf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1022 additions and 29 deletions

View file

@ -2,7 +2,7 @@
Each backend provides the same interface (BaseEnvironment ABC) for running
shell commands in a specific execution context: local, Docker, Singularity,
SSH, Modal, or Daytona.
SSH, Modal, Daytona, or Koyeb.
The terminal_tool.py factory (_create_environment) selects the backend
based on the TERMINAL_ENV configuration.

231
tools/environments/koyeb.py Normal file
View file

@ -0,0 +1,231 @@
"""Koyeb cloud execution environment.
Uses the Koyeb Python SDK to run commands in cloud sandboxes.
Each task gets its own sandbox which is deleted on cleanup.
"""
import logging
import math
import os
import re
import shlex
import threading
from pathlib import Path
from typing import Any
from tools.environments.base import (
BaseEnvironment,
_ThreadedProcessHandle,
)
from tools.environments.file_sync import (
FileSyncManager,
iter_sync_files,
quoted_mkdir_command,
quoted_rm_command,
unique_parent_dirs,
)
logger = logging.getLogger(__name__)
class KoyebEnvironment(BaseEnvironment):
"""Koyeb cloud sandbox execution backend.
Spawn-per-call via _ThreadedProcessHandle wrapping blocking SDK calls.
cancel_fn wired to sandbox.delete() for interrupt support.
Shell timeout wrapper preserved (SDK timeout unreliable).
"""
_stdin_mode = "heredoc"
def __init__(
self,
image: str,
cwd: str = "/root",
timeout: int = 60,
cpu: int = 1,
memory: int = 5120,
disk: int = 10240,
persistent_filesystem: bool = True,
task_id: str = "default",
instance_type: str = "micro",
region: str = None,
):
requested_cwd = cwd
super().__init__(cwd=cwd, timeout=timeout)
from koyeb import Sandbox
self._task_id = task_id
self._sandbox = None
self._lock = threading.Lock()
self._instance_type = instance_type
self._region = region or os.getenv("KOYEB_REGION", "na")
self._api_token = os.getenv("KOYEB_API_TOKEN")
# Convert memory from MB to GB (Koyeb uses GB)
memory_gib = max(1, math.ceil(memory / 1024))
# Koyeb instance types: micro, small, medium, large, xlarge, 2xlarge, etc.
# For now, we'll use the instance_type parameter directly
# cpu and memory parameters are kept for compatibility but may be overridden by instance_type
# Koyeb app names must be lowercase alphanumeric + hyphens only.
# Sanitize task_id: replace underscores/invalid chars with hyphens,
# collapse runs, strip leading/trailing hyphens, and truncate.
safe_id = re.sub(r"[^a-z0-9-]", "-", task_id.lower())
safe_id = re.sub(r"-{2,}", "-", safe_id).strip("-")
sandbox_name = f"hermes-{safe_id}"[:63] # Koyeb name max length
try:
self._sandbox = Sandbox.create(
image=image,
name=sandbox_name,
wait_ready=True,
instance_type=self._instance_type,
region=self._region,
api_token=self._api_token,
timeout=300,
)
logger.info("Koyeb: created sandbox %s for task %s",
self._sandbox.id, task_id)
except Exception as e:
logger.error("Koyeb: failed to create sandbox: %s", e)
raise
# Detect remote home dir
self._remote_home = "/root"
try:
home = self._sandbox.exec("echo $HOME").stdout.strip()
if home:
self._remote_home = home
if requested_cwd in ("~", "/root"):
self.cwd = home
except Exception:
pass
logger.info("Koyeb: resolved home to %s, cwd to %s", self._remote_home, self.cwd)
self._sync_manager = FileSyncManager(
get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"),
upload_fn=self._koyeb_upload,
delete_fn=self._koyeb_delete,
bulk_upload_fn=self._koyeb_bulk_upload,
bulk_download_fn=self._koyeb_bulk_download,
)
self._sync_manager.sync(force=True)
self.init_session()
def _koyeb_upload(self, host_path: str, remote_path: str) -> None:
"""Upload a single file via Koyeb SDK."""
parent = str(Path(remote_path).parent)
self._sandbox.exec(f"mkdir -p {shlex.quote(parent)}")
self._sandbox.filesystem.upload_file(host_path, remote_path, encoding="base64")
def _koyeb_bulk_upload(self, files: list[tuple[str, str]]) -> None:
"""Upload many files as a single tar archive to avoid per-file HTTP overhead."""
if not files:
return
import tarfile
import tempfile
with tempfile.NamedTemporaryFile(suffix=".tar", delete=False) as tmp:
tmp_path = tmp.name
try:
with tarfile.open(tmp_path, "w") as tar:
for host_path, remote_path in files:
# Store with absolute remote path inside the tar
tar.add(host_path, arcname=remote_path)
remote_tar = f"/tmp/.hermes_upload.{os.getpid()}.tar"
self._sandbox.filesystem.upload_file(tmp_path, remote_tar, encoding="base64")
self._sandbox.exec(f"tar xf {shlex.quote(remote_tar)} -C / && rm -f {shlex.quote(remote_tar)}")
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def _koyeb_bulk_download(self, dest: Path) -> None:
"""Download remote .hermes/ as a tar archive."""
rel_base = f"{self._remote_home}/.hermes".lstrip("/")
# PID-suffixed remote temp path avoids collisions if sync_back fires
# concurrently for the same sandbox (e.g. retry after partial failure).
remote_tar = f"/tmp/.hermes_sync.{os.getpid()}.tar"
self._sandbox.exec(
f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(rel_base)}"
)
self._sandbox.filesystem.download_file(remote_tar, str(dest))
# Clean up remote temp file
try:
self._sandbox.exec(f"rm -f {shlex.quote(remote_tar)}")
except Exception:
pass # best-effort cleanup
def _koyeb_delete(self, remote_paths: list[str]) -> None:
"""Batch-delete remote files via SDK exec."""
self._sandbox.exec(quoted_rm_command(remote_paths))
# ------------------------------------------------------------------
# Sandbox lifecycle
# ------------------------------------------------------------------
def _ensure_sandbox_ready(self) -> None:
"""Restart sandbox if it was stopped (e.g., by a previous interrupt)."""
# Koyeb sandboxes don't have a stopped state like Daytona
# They're either running or need to be recreated
pass
def _before_execute(self) -> None:
"""Ensure sandbox is ready, then sync files via FileSyncManager."""
with self._lock:
self._ensure_sandbox_ready()
self._sync_manager.sync()
def _run_bash(self, cmd_string: str, *, login: bool = False,
timeout: int = 120,
stdin_data: str | None = None):
"""Return a _ThreadedProcessHandle wrapping a blocking Koyeb SDK call."""
sandbox = self._sandbox
lock = self._lock
def cancel():
with lock:
try:
sandbox.delete()
except Exception:
pass
if login:
shell_cmd = f"bash -l -c {shlex.quote(cmd_string)}"
else:
shell_cmd = f"bash -c {shlex.quote(cmd_string)}"
def exec_fn() -> tuple[str, int]:
result = sandbox.exec(shell_cmd, timeout=timeout)
output = result.stdout or ""
if result.stderr:
output = f"{output}\n{result.stderr}" if output else result.stderr
return (output, result.exit_code)
return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
def cleanup(self):
with self._lock:
if self._sandbox is None:
return
# Sync remote changes back to host before teardown
if self._sync_manager:
logger.info("Koyeb: syncing files from sandbox...")
try:
self._sync_manager.sync_back()
except Exception as e:
logger.warning("Koyeb: sync_back failed: %s", e)
try:
self._sandbox.delete()
logger.info("Koyeb: deleted sandbox %s", self._sandbox.id)
except Exception as e:
logger.warning("Koyeb: cleanup failed: %s", e)
self._sandbox = None

View file

@ -2,16 +2,17 @@
"""
Terminal Tool Module
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments.
Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode.
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, Daytona, and Koyeb environments.
Supports local execution, containerized backends, and cloud sandboxes (Modal, Daytona, Koyeb), including managed gateway mode.
Environment Selection (via TERMINAL_ENV environment variable):
- "local": Execute directly on the host machine (default, fastest)
- "docker": Execute in Docker containers (isolated, requires Docker)
- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
- "koyeb": Execute in Koyeb cloud sandboxes
Features:
- Multiple execution backends (local, docker, modal)
- Multiple execution backends (local, docker, modal, koyeb)
- Background task support
- VM/container lifecycle management
- Automatic cleanup after inactivity
@ -855,7 +856,7 @@ def _get_env_config() -> Dict[str, Any]:
):
host_cwd = candidate
cwd = "/workspace"
elif env_type in ("modal", "docker", "singularity", "daytona") and cwd:
elif env_type in ("modal", "docker", "singularity", "daytona", "koyeb") and cwd:
# Host paths and relative paths that won't work inside containers
is_host_path = any(cwd.startswith(p) for p in host_prefixes)
is_relative = not os.path.isabs(cwd) # e.g. "." or "src/"
@ -873,6 +874,7 @@ def _get_env_config() -> Dict[str, Any]:
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
"koyeb_image": os.getenv("TERMINAL_KOYEB_IMAGE", default_image),
"cwd": cwd,
"host_cwd": host_cwd,
"docker_mount_cwd_to_workspace": mount_docker_cwd,
@ -891,12 +893,15 @@ def _get_env_config() -> Dict[str, Any]:
os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
).lower() in ("true", "1", "yes"),
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"),
# Container resource config (applies to docker, singularity, modal, daytona -- ignored for local/ssh)
# Container resource config (applies to docker, singularity, modal, daytona, koyeb -- ignored for local/ssh)
"container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),
"container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB)
"container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB)
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"),
"docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"),
# Koyeb-specific config
"koyeb_instance_type": os.getenv("TERMINAL_KOYEB_INSTANCE_TYPE", "micro"),
"koyeb_region": os.getenv("KOYEB_REGION", "na"),
}
@ -918,8 +923,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
Create an execution environment for sandboxed command execution.
Args:
env_type: One of "local", "docker", "singularity", "modal", "daytona", "ssh"
image: Docker/Singularity/Modal image name (ignored for local/ssh)
env_type: One of "local", "docker", "singularity", "modal", "daytona", "koyeb", "ssh"
image: Docker/Singularity/Modal/Koyeb image name (ignored for local/ssh)
cwd: Working directory
timeout: Default command timeout
ssh_config: SSH connection config (for env_type="ssh")
@ -1022,6 +1027,22 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
persistent_filesystem=persistent, task_id=task_id,
)
elif env_type == "koyeb":
# Lazy import so koyeb SDK is only required when backend is selected.
from tools.environments.koyeb import KoyebEnvironment as _KoyebEnvironment
# Get Koyeb-specific configuration
koyeb_instance_type = cc.get("koyeb_instance_type", "micro")
koyeb_region = cc.get("koyeb_region")
return _KoyebEnvironment(
image=image, cwd=cwd, timeout=timeout,
cpu=int(cpu), memory=memory, disk=disk,
persistent_filesystem=persistent, task_id=task_id,
instance_type=koyeb_instance_type,
region=koyeb_region,
)
elif env_type == "ssh":
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
@ -1035,7 +1056,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
)
else:
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', or 'ssh'")
raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', 'koyeb', or 'ssh'")
def _cleanup_inactive_envs(lifetime_seconds: int = 300):
@ -1462,6 +1483,8 @@ def terminal_tool(
image = overrides.get("modal_image") or config["modal_image"]
elif env_type == "daytona":
image = overrides.get("daytona_image") or config["daytona_image"]
elif env_type == "koyeb":
image = overrides.get("koyeb_image") or config["koyeb_image"]
else:
image = ""
@ -1538,7 +1561,7 @@ def terminal_tool(
}
container_config = None
if env_type in ("docker", "singularity", "modal", "daytona"):
if env_type in ("docker", "singularity", "modal", "daytona", "koyeb"):
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
@ -1948,10 +1971,14 @@ def check_terminal_requirements() -> bool:
from daytona import Daytona # noqa: F401 — SDK presence check
return os.getenv("DAYTONA_API_KEY") is not None
elif env_type == "koyeb":
from koyeb import Sandbox # noqa: F401 — SDK presence check
return os.getenv("KOYEB_API_TOKEN") is not None
else:
logger.error(
"Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, "
"modal, daytona, ssh.",
"modal, daytona, koyeb, ssh.",
env_type,
)
return False
@ -1970,6 +1997,7 @@ if __name__ == "__main__":
print(f" Environment type: {config['env_type']}")
print(f" Docker image: {config['docker_image']}")
print(f" Modal image: {config['modal_image']}")
print(f" Koyeb image: {config['koyeb_image']}")
print(f" Working directory: {config['cwd']}")
print(f" Default timeout: {config['timeout']}s")
print(f" Lifetime: {config['lifetime_seconds']}s")
@ -1991,11 +2019,12 @@ if __name__ == "__main__":
print("\nEnvironment Variables:")
default_img = "nikolaik/python-nodejs:python3.11-nodejs20"
print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/ssh)")
print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/koyeb/ssh)")
print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}")
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
print(f" TERMINAL_KOYEB_IMAGE: {os.getenv('TERMINAL_KOYEB_IMAGE', default_img)}")
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}")
from hermes_constants import display_hermes_home as _dhh
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}")