Improve arcade deploy CLI Command (#634)
Also fleshed out `arcade server` commands and MCPApp.name validation. Example of output of `arcade deploy`: <img width="2112" height="1320" alt="image" src="https://github.com/user-attachments/assets/51fd3dd9-0ff1-442c-a9bb-1dbcd7337e7a" />
This commit is contained in:
parent
5f89497198
commit
4ca824cf8f
9 changed files with 903 additions and 454 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
|
|
@ -6,13 +7,20 @@ import subprocess
|
|||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, Field
|
||||
from rich.console import Console
|
||||
from rich.columns import Columns
|
||||
from rich.console import Console, Group
|
||||
from rich.live import Live
|
||||
from rich.prompt import Confirm
|
||||
from rich.spinner import Spinner
|
||||
from rich.text import Text
|
||||
from typing_extensions import Literal
|
||||
|
||||
from arcade_cli.secret import load_env_file
|
||||
from arcade_cli.utils import compute_base_url, validate_and_get_config
|
||||
|
|
@ -63,7 +71,7 @@ class DeploymentToolkits(BaseModel):
|
|||
packages: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DeploymentRequest(BaseModel):
|
||||
class CreateDeploymentRequest(BaseModel):
|
||||
"""Deployment request payload for /v1/deployments endpoint."""
|
||||
|
||||
name: str
|
||||
|
|
@ -71,7 +79,227 @@ class DeploymentRequest(BaseModel):
|
|||
toolkits: DeploymentToolkits
|
||||
|
||||
|
||||
# Functions
|
||||
class UpdateDeploymentRequest(BaseModel):
|
||||
"""Deployment request payload for /v1/deployments/{deployment_name} endpoint."""
|
||||
|
||||
description: str
|
||||
toolkits: DeploymentToolkits
|
||||
|
||||
|
||||
# Deployment Status Functions
|
||||
|
||||
|
||||
def get_deployment_status(engine_url: str, api_key: str, server_name: str) -> str:
|
||||
"""
|
||||
Get the status of a deployment.
|
||||
|
||||
Args:
|
||||
engine_url: The base URL of the Arcade Engine
|
||||
server_name: The name of the server to get the status of
|
||||
|
||||
Returns:
|
||||
The status of the deployment.
|
||||
Possible values are: "pending", "updating", "unknown", "running", "failed".
|
||||
"""
|
||||
client = httpx.Client(
|
||||
base_url=engine_url,
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=360,
|
||||
)
|
||||
response = client.get(f"/v1/deployments/{server_name}/status")
|
||||
response.raise_for_status()
|
||||
status = cast(str, response.json().get("status", "unknown"))
|
||||
return status
|
||||
|
||||
|
||||
async def _poll_deployment_status(
|
||||
engine_url: str,
|
||||
api_key: str,
|
||||
server_name: str,
|
||||
state: dict,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""Poll deployment status until it's running or error."""
|
||||
while state["status"] in ["pending", "unknown", "updating"]:
|
||||
try:
|
||||
status = get_deployment_status(engine_url, api_key, server_name)
|
||||
state["status"] = status
|
||||
if status in ["running", "failed"]:
|
||||
break
|
||||
except Exception as e:
|
||||
if debug:
|
||||
console.print(f"Error polling status: {e}", style="dim red")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def _stream_deployment_logs_to_deque(
|
||||
engine_url: str,
|
||||
api_key: str,
|
||||
server_name: str,
|
||||
log_deque: deque,
|
||||
state: dict,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""Stream deployment logs into a deque with retry logic."""
|
||||
stream_url = f"{engine_url}/v1/deployments/{server_name}/logs/stream"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
while state["status"] in ["pending", "unknown", "updating"]:
|
||||
try:
|
||||
async with (
|
||||
httpx.AsyncClient(timeout=None) as client, # noqa: S113 - expected indefinite log stream
|
||||
client.stream("GET", stream_url, headers=headers) as response,
|
||||
):
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if line.strip():
|
||||
log_deque.append(line)
|
||||
# End state check
|
||||
if state["status"] not in ["pending", "unknown", "updating"]:
|
||||
break
|
||||
except httpx.HTTPStatusError as e:
|
||||
if debug:
|
||||
console.print(f"Failed to stream logs: {e.response.status_code}", style="dim red")
|
||||
await asyncio.sleep(3)
|
||||
except Exception as e:
|
||||
if debug:
|
||||
console.print(f"Error streaming logs: {e}", style="dim red")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def _monitor_deployment_with_logs(
|
||||
engine_url: str,
|
||||
api_key: str,
|
||||
server_name: str,
|
||||
debug: bool = False,
|
||||
is_update: bool = False,
|
||||
) -> tuple[Literal["running", "failed"], list[str]]:
|
||||
"""
|
||||
Monitor deployment with live status and streaming logs display.
|
||||
|
||||
Args:
|
||||
engine_url: The base URL of the Arcade Engine
|
||||
api_key: The API key for authentication
|
||||
server_name: The name of the server to monitor
|
||||
debug: Whether to show debug information
|
||||
is_update: If True, wait for status to be 'updating' before streaming logs or 'failed' before exiting
|
||||
|
||||
Returns:
|
||||
Tuple of (final status, list of all logs collected)
|
||||
"""
|
||||
state = {"status": "pending"}
|
||||
log_deque: deque[str] = deque(maxlen=1000)
|
||||
|
||||
# Friendly messages that rotate while waiting for logs
|
||||
waiting_messages = [
|
||||
"Waiting for logs...",
|
||||
"Still getting logs ready...",
|
||||
"Build environment warming up...",
|
||||
"Preparing deployment resources...",
|
||||
]
|
||||
|
||||
status_task = asyncio.create_task(
|
||||
_poll_deployment_status(engine_url, api_key, server_name, state, debug)
|
||||
)
|
||||
|
||||
# Don't stream logs until the deployment is 'updating' or 'failed' otherwise we will get logs from the previous deployment
|
||||
if is_update:
|
||||
while state["status"] not in ["updating", "failed"]:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Start log streaming task
|
||||
logs_task = asyncio.create_task(
|
||||
_stream_deployment_logs_to_deque(engine_url, api_key, server_name, log_deque, state, debug)
|
||||
)
|
||||
|
||||
# Live display with spinner and logs
|
||||
spinner = Spinner("dots", style="green")
|
||||
log_spinner = Spinner("dots", style="dim")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
with Live(console=console, refresh_per_second=4) as live:
|
||||
while state["status"] in ["pending", "unknown", "updating"]:
|
||||
elapsed = int(time.time() - start_time)
|
||||
|
||||
# Show different messages based on status
|
||||
if state["status"] == "updating":
|
||||
status_text = Text(
|
||||
"Updating deployment (this may take a few minutes)...", style="bold green"
|
||||
)
|
||||
else:
|
||||
status_text = Text(
|
||||
"Deployment in progress (this may take a few minutes)...", style="bold green"
|
||||
)
|
||||
status_line = Columns([spinner, status_text], padding=(0, 1))
|
||||
|
||||
logs_header = Text("\nRecent logs:", style="dim")
|
||||
|
||||
if log_deque:
|
||||
# Get the last logs and ensure we only show 6 lines total
|
||||
recent_logs = list(log_deque)[-6:]
|
||||
log_lines_text = Text()
|
||||
for log_line in recent_logs:
|
||||
log_lines_text.append(f" {log_line}\n", style="dim")
|
||||
# Pad with empty lines if we have fewer than 6 logs
|
||||
for _ in range(6 - len(recent_logs)):
|
||||
log_lines_text.append("\n")
|
||||
|
||||
footer = Text(
|
||||
"\nYou can safely exit with Ctrl+C at any time. The deployment will continue normally.",
|
||||
style="green",
|
||||
)
|
||||
display = Group(Text("\n"), status_line, logs_header, log_lines_text, footer)
|
||||
else:
|
||||
# Rotate message every 7 seconds while waiting for logs
|
||||
message_index = (elapsed // 7) % len(waiting_messages)
|
||||
current_message = waiting_messages[message_index]
|
||||
waiting_line = Columns(
|
||||
[log_spinner, Text(current_message, style="dim italic")], padding=(0, 1)
|
||||
)
|
||||
padding = Text("\n" * 5)
|
||||
footer = Text(
|
||||
"\nYou can safely exit with Ctrl+C at any time. The deployment will continue normally.",
|
||||
style="green",
|
||||
)
|
||||
display = Group(
|
||||
Text("\n"), status_line, logs_header, Text(" "), waiting_line, padding, footer
|
||||
)
|
||||
|
||||
live.update(display)
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
status_task.cancel()
|
||||
logs_task.cancel()
|
||||
await asyncio.gather(status_task, logs_task, return_exceptions=True)
|
||||
|
||||
all_logs = list(log_deque)
|
||||
|
||||
return cast(Literal["running", "failed"], state["status"]), all_logs
|
||||
|
||||
|
||||
# Create Deployment Functions
|
||||
|
||||
|
||||
def server_already_exists(engine_url: str, api_key: str, server_name: str) -> bool:
|
||||
"""Check if a server already exists in the Arcade Engine."""
|
||||
client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"})
|
||||
response = client.get(f"/v1/workers/{server_name}")
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.json().get("managed")
|
||||
|
||||
|
||||
def update_deployment(
|
||||
engine_url: str, api_key: str, server_name: str, update_deployment_request: dict
|
||||
) -> None:
|
||||
"""Update a deployment in the Arcade Engine."""
|
||||
client = httpx.Client(base_url=engine_url, headers={"Authorization": f"Bearer {api_key}"})
|
||||
response = client.put(f"/v1/deployments/{server_name}", json=update_deployment_request)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def create_package_archive(package_dir: Path) -> str:
|
||||
|
|
@ -106,20 +334,16 @@ def create_package_archive(package_dir: Path) -> str:
|
|||
name = tarinfo.name
|
||||
|
||||
parts = Path(name).parts
|
||||
if any(part.startswith(".") for part in parts):
|
||||
return None
|
||||
|
||||
if "__pycache__" in parts:
|
||||
return None
|
||||
|
||||
if any(part.endswith(".egg-info") for part in parts):
|
||||
return None
|
||||
|
||||
if "dist" in parts or "build" in parts:
|
||||
return None
|
||||
|
||||
if name.endswith(".lock"):
|
||||
return None
|
||||
for part in parts:
|
||||
if (
|
||||
part.startswith(".")
|
||||
or part == "__pycache__"
|
||||
or part.endswith(".egg-info")
|
||||
or part in ["dist", "build"]
|
||||
or part.endswith(".lock")
|
||||
):
|
||||
return None
|
||||
|
||||
return tarinfo
|
||||
|
||||
|
|
@ -152,7 +376,7 @@ def start_server_process(entrypoint: str, debug: bool = False) -> tuple[subproce
|
|||
"""
|
||||
port = random.randint(8000, 9000) # noqa: S311
|
||||
|
||||
# override app.run() settings
|
||||
# override MCPApp.run() settings
|
||||
env = {
|
||||
**os.environ,
|
||||
"ARCADE_SERVER_HOST": "localhost",
|
||||
|
|
@ -496,7 +720,8 @@ def deploy_server_logic(
|
|||
console.print("\nValidating user is logged in...", style="dim")
|
||||
config = validate_and_get_config()
|
||||
engine_url = compute_base_url(force_tls, force_no_tls, host, port)
|
||||
console.print(f"✓ {config.user.email} is logged in", style="green")
|
||||
user_email = config.user.email if config.user else "User"
|
||||
console.print(f"✓ {user_email} is logged in", style="green")
|
||||
|
||||
# Step 2: Validate pyproject.toml exists in current directory
|
||||
console.print("\nValidating pyproject.toml exists in current directory...", style="dim")
|
||||
|
|
@ -517,19 +742,20 @@ def deploy_server_logic(
|
|||
load_dotenv(env_path, override=False)
|
||||
console.print(f"✓ Loaded environment from {env_path}", style="green")
|
||||
else:
|
||||
console.print(f"⚠️ No .env file found at {env_path}", style="yellow")
|
||||
console.print(f"[!] No .env file found at {env_path}", style="yellow")
|
||||
|
||||
# Step 4: Verify server and extract metadata (or skip if --skip-validate)
|
||||
required_secrets_from_validation: set[str] = set()
|
||||
|
||||
if skip_validate:
|
||||
console.print("\n⚠️ Skipping server validation (--skip-validate set)", style="yellow")
|
||||
console.print("\n[!] Skipping server validation (--skip-validate set)", style="yellow")
|
||||
# Use the provided server_name and server_version
|
||||
# These are guaranteed to be set due to validation in main.py
|
||||
if server_name is None:
|
||||
raise ValueError("server_name must be provided when skip_validate is True")
|
||||
if server_version is None:
|
||||
raise ValueError("server_version must be provided when skip_validate is True")
|
||||
|
||||
console.print(f"✓ Using server name: {server_name}", style="green")
|
||||
console.print(f"✓ Using server version: {server_version}", style="green")
|
||||
else:
|
||||
|
|
@ -551,7 +777,7 @@ def deploy_server_logic(
|
|||
secrets_to_upsert: set[str] = set()
|
||||
|
||||
if secrets == "skip":
|
||||
console.print("\n⚠️ Skipping secret upload (--secrets skip)", style="yellow")
|
||||
console.print("\n[!] Skipping secret upload (--secrets skip)", style="yellow")
|
||||
elif secrets == "all":
|
||||
console.print("\nUploading ALL secrets from .env file...", style="dim")
|
||||
secrets_to_upsert = set(load_env_file(str(env_path)).keys())
|
||||
|
|
@ -559,7 +785,7 @@ def deploy_server_logic(
|
|||
console.print(f"✓ Found {len(secrets_to_upsert)} secret(s) in .env file", style="green")
|
||||
upsert_secrets_to_engine(engine_url, config.api.key, secrets_to_upsert, debug)
|
||||
else:
|
||||
console.print("⚠️ No secrets found in .env file", style="yellow")
|
||||
console.print("[!] No secrets found in .env file", style="yellow")
|
||||
elif secrets == "auto":
|
||||
# Only upload required secrets discovered during validation
|
||||
if required_secrets_from_validation:
|
||||
|
|
@ -582,50 +808,48 @@ def deploy_server_logic(
|
|||
except Exception as e:
|
||||
raise ValueError(f"Failed to create package archive: {e}") from e
|
||||
|
||||
# Step 7: Build deployment request payload
|
||||
deployment_request = DeploymentRequest(
|
||||
name=server_name,
|
||||
description="MCP Server deployed via CLI",
|
||||
toolkits=DeploymentToolkits(
|
||||
bundles=[
|
||||
ToolkitBundle(
|
||||
name=server_name,
|
||||
version=server_version,
|
||||
bytes=archive_base64,
|
||||
type="mcp",
|
||||
entrypoint=entrypoint,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
# Step 8: Send deployment request to engine
|
||||
console.print("\nDeploying to Arcade Engine...", style="dim")
|
||||
# Step 7: Send deployment request to engine
|
||||
is_update = False
|
||||
try:
|
||||
response = deploy_server_to_engine(
|
||||
engine_url, config.api.key, deployment_request.model_dump(), debug
|
||||
toolkit_bundle = ToolkitBundle(
|
||||
name=server_name,
|
||||
version=server_version,
|
||||
bytes=archive_base64,
|
||||
type="mcp",
|
||||
entrypoint=entrypoint,
|
||||
)
|
||||
deployment_toolkits = DeploymentToolkits(bundles=[toolkit_bundle])
|
||||
|
||||
if server_already_exists(engine_url, config.api.key, server_name):
|
||||
is_update = True
|
||||
update_request = UpdateDeploymentRequest(
|
||||
description="MCP Server deployed via CLI",
|
||||
toolkits=deployment_toolkits,
|
||||
)
|
||||
update_deployment(engine_url, config.api.key, server_name, update_request.model_dump())
|
||||
else:
|
||||
create_request = CreateDeploymentRequest(
|
||||
name=server_name,
|
||||
description="MCP Server deployed via CLI",
|
||||
toolkits=deployment_toolkits,
|
||||
)
|
||||
deploy_server_to_engine(engine_url, config.api.key, create_request.model_dump(), debug)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Deployment failed: {e}") from e
|
||||
|
||||
console.print(
|
||||
f"✓ Server '{server_name}' v{server_version} deployed successfully", style="bold green"
|
||||
# Step 8: Monitor deployment with live status and logs
|
||||
final_status, all_logs = asyncio.run(
|
||||
_monitor_deployment_with_logs(engine_url, config.api.key, server_name, debug, is_update)
|
||||
)
|
||||
|
||||
deployment_id = response.get("id", "N/A")
|
||||
deployment_uri = response.get("http", {}).get("uri", "N/A")
|
||||
deployment_secret = response.get("http", {}).get("secret", "N/A").get("value", "N/A")
|
||||
if final_status == "running":
|
||||
console.print("\n✓ Deployment successful! Server is running.", style="bold green")
|
||||
elif final_status == "failed":
|
||||
console.print("\n✗ Deployment failed. Check logs for details.", style="bold red")
|
||||
|
||||
console.print("\n[bold]Deployment Details:[/bold]")
|
||||
console.print(f" • Server ID: [cyan]{deployment_id}[/cyan]")
|
||||
console.print(f" • Server URI: [cyan]{deployment_uri}[/cyan]")
|
||||
console.print(f" • Server Secret: [cyan]{deployment_secret}[/cyan]")
|
||||
console.print("\n[yellow]⚠ Note:[/yellow] Your server is now starting up...", style="bold")
|
||||
console.print(
|
||||
"\n This process may take a few minutes. Your server will be available at the URI above once ready."
|
||||
)
|
||||
|
||||
console.print(
|
||||
"\nView and manage your servers: [link]https://api.arcade.dev/dashboard/[/link]",
|
||||
style="dim",
|
||||
)
|
||||
# Offer to view full deployment logs
|
||||
if all_logs and Confirm.ask("\nView full deployment logs?", default=False): # type: ignore[arg-type]
|
||||
with console.pager(styles=True):
|
||||
console.print("[bold]Full Deployment Logs[/bold]\n", style="cyan")
|
||||
for i, log_line in enumerate(all_logs, 1):
|
||||
console.print(f"{i:4d} | {log_line}", style="dim")
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ from rich.console import Console
|
|||
from rich.text import Text
|
||||
from tqdm import tqdm
|
||||
|
||||
import arcade_cli.secret as secret
|
||||
import arcade_cli.worker as worker
|
||||
from arcade_cli.authn import LocalAuthCallbackServer, check_existing_login
|
||||
from arcade_cli.constants import (
|
||||
PROD_CLOUD_HOST,
|
||||
|
|
@ -26,6 +24,8 @@ from arcade_cli.constants import (
|
|||
from arcade_cli.display import (
|
||||
display_eval_results,
|
||||
)
|
||||
from arcade_cli.secret import app as secret_app
|
||||
from arcade_cli.server import app as server_app
|
||||
from arcade_cli.show import show_logic
|
||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||
from arcade_cli.utils import (
|
||||
|
|
@ -57,14 +57,14 @@ cli = TrackedTyper(
|
|||
|
||||
|
||||
cli.add_typer(
|
||||
worker.app,
|
||||
server_app,
|
||||
name="server",
|
||||
help="Manage deployments of tool servers (logs, list, etc)",
|
||||
rich_help_panel="Manage",
|
||||
)
|
||||
|
||||
cli.add_typer(
|
||||
secret.app,
|
||||
secret_app,
|
||||
name="secret",
|
||||
help="Manage tool secrets in the cloud (set, unset, list)",
|
||||
rich_help_panel="Manage",
|
||||
|
|
|
|||
|
|
@ -19,14 +19,14 @@ try:
|
|||
ARCADE_MCP_MAX_VERSION = str(int(ARCADE_MCP_MIN_VERSION.split(".")[0]) + 1) + ".0.0"
|
||||
except Exception as e:
|
||||
console.print(f"[red]Failed to get arcade-mcp version: {e}[/red]")
|
||||
ARCADE_MCP_MIN_VERSION = "1.4.0" # Default version if unable to fetch
|
||||
ARCADE_MCP_MIN_VERSION = "1.5.0" # Default version if unable to fetch
|
||||
ARCADE_MCP_MAX_VERSION = "2.0.0"
|
||||
|
||||
ARCADE_TDK_MIN_VERSION = "3.0.0"
|
||||
ARCADE_TDK_MAX_VERSION = "4.0.0"
|
||||
ARCADE_SERVE_MIN_VERSION = "3.0.0"
|
||||
ARCADE_SERVE_MAX_VERSION = "4.0.0"
|
||||
ARCADE_MCP_SERVER_MIN_VERSION = "1.5.0"
|
||||
ARCADE_MCP_SERVER_MIN_VERSION = "1.7.0"
|
||||
ARCADE_MCP_SERVER_MAX_VERSION = "2.0.0"
|
||||
|
||||
|
||||
|
|
|
|||
411
libs/arcade-cli/arcade_cli/server.py
Normal file
411
libs/arcade-cli/arcade_cli/server.py
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
from arcadepy import Arcade, NotFoundError
|
||||
from arcadepy.types import WorkerHealthResponse, WorkerResponse
|
||||
from dateutil import parser
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from arcade_cli.constants import (
|
||||
PROD_ENGINE_HOST,
|
||||
)
|
||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||
from arcade_cli.utils import (
|
||||
compute_base_url,
|
||||
handle_cli_error,
|
||||
validate_and_get_config,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def _format_timestamp_to_local(timestamp_str: str) -> str:
|
||||
"""
|
||||
Convert a UTC timestamp string to local timezone format.
|
||||
|
||||
Args:
|
||||
timestamp_str: UTC timestamp in format "2025-10-22T21:08:23.508906574Z"
|
||||
|
||||
Returns:
|
||||
Formatted timestamp string in local timezone
|
||||
"""
|
||||
try:
|
||||
# Parse the UTC timestamp and convert to local timezone
|
||||
utc_dt = datetime.fromisoformat(timestamp_str.replace("Z", "+00:00"))
|
||||
local_dt = utc_dt.astimezone()
|
||||
return local_dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
except (ValueError, TypeError):
|
||||
# If parsing fails, return the original timestamp
|
||||
return timestamp_str
|
||||
|
||||
|
||||
def _parse_time_string(time_str: str) -> datetime:
|
||||
"""
|
||||
Parse a time string that can be either relative (e.g., "1h", "42m", "2d")
|
||||
or absolute (e.g., "2025-10-03T12:24:36Z").
|
||||
|
||||
Args:
|
||||
time_str: Time string in relative or absolute format
|
||||
|
||||
Returns:
|
||||
datetime object in UTC timezone
|
||||
|
||||
Raises:
|
||||
ValueError: If the time string cannot be parsed
|
||||
"""
|
||||
if not time_str:
|
||||
raise ValueError("Time string cannot be empty")
|
||||
|
||||
# Handle relative time formats (e.g., "1h", "42m", "2d", "0m")
|
||||
relative_pattern = r"^(\d+)([smhd])$"
|
||||
match = re.match(relative_pattern, time_str.lower())
|
||||
|
||||
if match:
|
||||
value = int(match.group(1))
|
||||
unit = match.group(2)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if unit == "s":
|
||||
delta = timedelta(seconds=value)
|
||||
elif unit == "m":
|
||||
delta = timedelta(minutes=value)
|
||||
elif unit == "h":
|
||||
delta = timedelta(hours=value)
|
||||
elif unit == "d":
|
||||
delta = timedelta(days=value)
|
||||
else:
|
||||
raise ValueError(f"Unsupported time unit: {unit}")
|
||||
|
||||
return now - delta
|
||||
|
||||
# Handle absolute time formats using dateutil parser
|
||||
try:
|
||||
parsed_dt = parser.parse(time_str)
|
||||
|
||||
# Ensure timezone awareness
|
||||
if parsed_dt.tzinfo is None:
|
||||
# Assume UTC if no timezone info
|
||||
parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
# Convert to UTC
|
||||
parsed_dt = parsed_dt.astimezone(timezone.utc)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ValueError(f"Unable to parse time string '{time_str}': {e}")
|
||||
|
||||
return parsed_dt
|
||||
|
||||
|
||||
app = TrackedTyper(
|
||||
cls=TrackedTyperGroup,
|
||||
add_completion=False,
|
||||
no_args_is_help=True,
|
||||
pretty_exceptions_enable=False,
|
||||
pretty_exceptions_show_locals=False,
|
||||
pretty_exceptions_short=True,
|
||||
)
|
||||
|
||||
state = {
|
||||
"engine_url": compute_base_url(
|
||||
host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
host: str = typer.Option(
|
||||
PROD_ENGINE_HOST,
|
||||
"--host",
|
||||
"-h",
|
||||
help="The Arcade Engine host.",
|
||||
),
|
||||
port: int = typer.Option(
|
||||
None,
|
||||
"--port",
|
||||
"-p",
|
||||
help="The port of the Arcade Engine host.",
|
||||
),
|
||||
force_tls: bool = typer.Option(
|
||||
False,
|
||||
"--tls",
|
||||
help="Whether to force TLS for the connection to the Arcade Engine.",
|
||||
),
|
||||
force_no_tls: bool = typer.Option(
|
||||
False,
|
||||
"--no-tls",
|
||||
help="Whether to disable TLS for the connection to the Arcade Engine.",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Manage users in the system.
|
||||
"""
|
||||
engine_url = compute_base_url(force_tls, force_no_tls, host, port)
|
||||
state["engine_url"] = engine_url
|
||||
|
||||
|
||||
@app.command("list", help="List all servers")
|
||||
def list_servers(
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug",
|
||||
"-d",
|
||||
help="Show debug information",
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
base_url = state["engine_url"]
|
||||
client = Arcade(api_key=config.api.key, base_url=base_url)
|
||||
try:
|
||||
servers = client.workers.list(limit=100)
|
||||
_print_servers_table(servers.items)
|
||||
except Exception as e:
|
||||
handle_cli_error("Failed to list servers", e, debug=debug)
|
||||
|
||||
|
||||
@app.command("get", help="Get a server's details")
|
||||
def get_server(
|
||||
server_name: str,
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug",
|
||||
"-d",
|
||||
help="Show debug information",
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
base_url = state["engine_url"]
|
||||
client = Arcade(api_key=config.api.key, base_url=base_url)
|
||||
try:
|
||||
server = client.workers.get(server_name)
|
||||
server_health = client.workers.health(server_name)
|
||||
_print_server_details(server, server_health)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Failed to get server '{server_name}'", e, debug=debug)
|
||||
|
||||
|
||||
@app.command("enable", help="Enable a server")
|
||||
def enable_server(
|
||||
server_name: str,
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug",
|
||||
"-d",
|
||||
help="Show debug information",
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
engine_url = state["engine_url"]
|
||||
arcade = Arcade(api_key=config.api.key, base_url=engine_url)
|
||||
try:
|
||||
arcade.workers.update(server_name, enabled=True)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Failed to enable worker '{server_name}'", e, debug=debug)
|
||||
|
||||
|
||||
@app.command("disable", help="Disable a server")
|
||||
def disable_server(
|
||||
server_name: str,
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug",
|
||||
"-d",
|
||||
help="Show debug information",
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
engine_url = state["engine_url"]
|
||||
arcade = Arcade(api_key=config.api.key, base_url=engine_url)
|
||||
try:
|
||||
arcade.workers.update(server_name, enabled=False)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Failed to disable worker '{server_name}'", e, debug=debug)
|
||||
|
||||
|
||||
@app.command("delete", help="Delete a server that is managed by Arcade")
|
||||
def delete_server(
|
||||
server_name: str,
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug",
|
||||
"-d",
|
||||
help="Show debug information",
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
engine_url = state["engine_url"]
|
||||
|
||||
try:
|
||||
arcade = Arcade(api_key=config.api.key, base_url=engine_url)
|
||||
arcade.workers.delete(server_name)
|
||||
console.print(f"✓ Server '{server_name}' deleted successfully", style="green")
|
||||
except NotFoundError as e:
|
||||
handle_cli_error(
|
||||
f"Server '{server_name}' doesn't exist or cannot be deleted", e, debug=debug
|
||||
)
|
||||
except Exception as e:
|
||||
handle_cli_error(
|
||||
f"Server '{server_name}' doesn't exist or cannot be deleted", e, debug=debug
|
||||
)
|
||||
|
||||
|
||||
@app.command("logs", help="Get logs for a server that is managed by Arcade")
|
||||
def get_server_logs(
|
||||
server_name: str,
|
||||
follow: bool = typer.Option(
|
||||
False,
|
||||
"--follow",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
help="Follow (stream) the log output in real-time",
|
||||
rich_help_panel="Streaming Options",
|
||||
),
|
||||
since: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--since",
|
||||
"-s",
|
||||
help="Show logs since timestamp (e.g., 2025-10-03T12:24:36Z) or relative (e.g., 42m for 42 minutes ago). Defaults to 1h (1 hour ago) for non-streaming, 0s (now) for streaming.",
|
||||
rich_help_panel="Time Range Options",
|
||||
),
|
||||
until: Optional[str] = typer.Option(
|
||||
None,
|
||||
"--until",
|
||||
"-u",
|
||||
help="Show logs until timestamp (e.g., 2025-10-03T12:24:36Z) or relative (e.g., 42m for 42 minutes ago). Defaults to 0s (now).",
|
||||
rich_help_panel="Time Range Options",
|
||||
),
|
||||
debug: bool = typer.Option(
|
||||
False,
|
||||
"--debug",
|
||||
"-d",
|
||||
help="Show debug information",
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
headers = {"Authorization": f"Bearer {config.api.key}", "Content-Type": "application/json"}
|
||||
|
||||
# Set defaults based on whether we're following or not
|
||||
if since is None:
|
||||
since = "0s" if follow else "1h"
|
||||
if until is None:
|
||||
until = "0s"
|
||||
|
||||
try:
|
||||
# Parse time strings to UTC datetime objects
|
||||
since_dt = _parse_time_string(since)
|
||||
until_dt = _parse_time_string(until)
|
||||
|
||||
# Validate that since is before until
|
||||
if since_dt >= until_dt:
|
||||
raise ValueError(f"'since' time ({since}) must be before 'until' time ({until})") # noqa: TRY301
|
||||
except ValueError as e:
|
||||
handle_cli_error(f"Invalid time format: {e}", debug=debug)
|
||||
|
||||
if follow:
|
||||
# Use the streaming endpoint
|
||||
engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs/stream"
|
||||
asyncio.run(_stream_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug))
|
||||
else:
|
||||
# Use the non-streaming endpoint
|
||||
engine_url = state["engine_url"] + f"/v1/deployments/{server_name}/logs"
|
||||
_display_deployment_logs(engine_url, headers, since_dt, until_dt, debug=debug)
|
||||
|
||||
|
||||
def _display_deployment_logs(
|
||||
engine_url: str, headers: dict, since: datetime, until: datetime, debug: bool
|
||||
) -> None:
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
params = {"start_time_utc": since.isoformat(), "end_time_utc": until.isoformat()}
|
||||
response = client.get(engine_url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
logs = response.json()
|
||||
for log in logs:
|
||||
formatted_timestamp = _format_timestamp_to_local(log["timestamp"])
|
||||
print(f"[{formatted_timestamp}] {log['line']}")
|
||||
except httpx.HTTPStatusError as e:
|
||||
handle_cli_error(
|
||||
f"Failed to fetch logs: {e.response.status_code} {e.response.text}", debug=debug
|
||||
)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Error fetching logs: {e}", debug=debug)
|
||||
|
||||
|
||||
async def _stream_deployment_logs(
|
||||
engine_url: str, headers: dict, since: datetime, until: datetime, debug: bool
|
||||
) -> None:
|
||||
try:
|
||||
async with (
|
||||
httpx.AsyncClient(timeout=None) as client, # noqa: S113 - expected indefinite log stream
|
||||
client.stream(
|
||||
"GET",
|
||||
engine_url,
|
||||
headers=headers,
|
||||
params={"start_time_utc": since.isoformat(), "end_time_utc": until.isoformat()},
|
||||
) as response,
|
||||
):
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Handle SSE format: "data: {json}"
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data = json.loads(line[6:])
|
||||
timestamp_str = data.get("Timestamp", "")
|
||||
log_line = data.get("Line", "")
|
||||
formatted_timestamp = _format_timestamp_to_local(timestamp_str)
|
||||
print(f"[{formatted_timestamp}] {log_line}")
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
print(line)
|
||||
else:
|
||||
print(line)
|
||||
except httpx.HTTPStatusError as e:
|
||||
handle_cli_error(f"Failed to stream logs: {e.response.status_code}", debug=debug)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Error streaming logs: {e}", debug=debug)
|
||||
|
||||
|
||||
def _print_servers_table(servers: list[WorkerResponse]) -> None:
|
||||
if not servers:
|
||||
console.print("No servers found", style="bold red")
|
||||
return
|
||||
|
||||
table = Table(title="Servers")
|
||||
table.add_column("Name")
|
||||
table.add_column("Enabled")
|
||||
table.add_column("Host")
|
||||
table.add_column("Managed by Arcade")
|
||||
|
||||
for server in servers:
|
||||
if server.id is None:
|
||||
continue
|
||||
uri = server.http.uri if server.http and server.http.uri else "N/A"
|
||||
table.add_row(
|
||||
server.id,
|
||||
str(server.enabled),
|
||||
uri,
|
||||
str(server.managed),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def _print_server_details(server: WorkerResponse, server_health: WorkerHealthResponse) -> None:
|
||||
table = Table(title="Server Details")
|
||||
table.add_column("Name")
|
||||
table.add_column("Enabled")
|
||||
table.add_column("Is Healthy")
|
||||
table.add_column("Host")
|
||||
table.add_column("Managed by Arcade")
|
||||
uri = server.http.uri if server.http and server.http.uri else "N/A"
|
||||
table.add_row(
|
||||
server.id, str(server.enabled), str(server_health.healthy), uri, str(server.managed)
|
||||
)
|
||||
console.print(table)
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
import httpx
|
||||
import typer
|
||||
from arcadepy import Arcade, NotFoundError
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from arcade_cli.constants import (
|
||||
PROD_CLOUD_HOST,
|
||||
PROD_ENGINE_HOST,
|
||||
)
|
||||
from arcade_cli.usage.command_tracker import TrackedTyper, TrackedTyperGroup
|
||||
from arcade_cli.utils import (
|
||||
CLIError,
|
||||
compute_base_url,
|
||||
handle_cli_error,
|
||||
validate_and_get_config,
|
||||
)
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
app = TrackedTyper(
|
||||
cls=TrackedTyperGroup,
|
||||
add_completion=False,
|
||||
no_args_is_help=True,
|
||||
pretty_exceptions_enable=False,
|
||||
pretty_exceptions_show_locals=False,
|
||||
pretty_exceptions_short=True,
|
||||
)
|
||||
|
||||
state = {
|
||||
"engine_url": compute_base_url(
|
||||
host=PROD_ENGINE_HOST, port=None, force_tls=False, force_no_tls=False
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@app.callback()
|
||||
def main(
|
||||
host: str = typer.Option(
|
||||
PROD_ENGINE_HOST,
|
||||
"--host",
|
||||
"-h",
|
||||
help="The Arcade Engine host.",
|
||||
),
|
||||
port: int = typer.Option(
|
||||
None,
|
||||
"--port",
|
||||
"-p",
|
||||
help="The port of the Arcade Engine host.",
|
||||
),
|
||||
force_tls: bool = typer.Option(
|
||||
False,
|
||||
"--tls",
|
||||
help="Whether to force TLS for the connection to the Arcade Engine.",
|
||||
),
|
||||
force_no_tls: bool = typer.Option(
|
||||
False,
|
||||
"--no-tls",
|
||||
help="Whether to disable TLS for the connection to the Arcade Engine.",
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
Manage users in the system.
|
||||
"""
|
||||
engine_url = compute_base_url(force_tls, force_no_tls, host, port)
|
||||
state["engine_url"] = engine_url
|
||||
|
||||
|
||||
@app.command("list", help="List all workers")
|
||||
def list_workers(
|
||||
cloud_host: str = typer.Option(
|
||||
PROD_CLOUD_HOST,
|
||||
"--cloud-host",
|
||||
"-c",
|
||||
help="The Arcade Engine host.",
|
||||
hidden=True,
|
||||
),
|
||||
cloud_port: int = typer.Option(
|
||||
None,
|
||||
"--cloud-port",
|
||||
"-cp",
|
||||
help="The port of the Arcade Engine host.",
|
||||
hidden=True,
|
||||
),
|
||||
force_tls: bool = typer.Option(
|
||||
False,
|
||||
"--tls",
|
||||
help="Whether to force TLS for the connection to the Arcade Engine.",
|
||||
hidden=True,
|
||||
),
|
||||
force_no_tls: bool = typer.Option(
|
||||
False,
|
||||
"--no-tls",
|
||||
help="Whether to disable TLS for the connection to the Arcade Engine.",
|
||||
hidden=True,
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
engine_url = state["engine_url"]
|
||||
client = Arcade(api_key=config.api.key, base_url=engine_url)
|
||||
deployments = []
|
||||
try:
|
||||
cloud_url = compute_base_url(force_tls, force_no_tls, cloud_host, cloud_port)
|
||||
cloud_client = httpx.Client(base_url=cloud_url)
|
||||
response = cloud_client.get(
|
||||
"/api/v1/workers", headers={"Authorization": f"Bearer {config.api.key}"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
deployments = response.json()["data"]["workers"]
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Failed to list deployed servers: {e}")
|
||||
|
||||
print_worker_table(client, deployments)
|
||||
|
||||
|
||||
def print_worker_table(client: Arcade, deployments: list[dict]) -> None:
|
||||
workers = client.workers.list()
|
||||
if not workers.items:
|
||||
console.print("No workers found", style="bold red")
|
||||
return
|
||||
|
||||
# Create and print a table of worker information
|
||||
table = Table(title="Workers")
|
||||
table.add_column("ID")
|
||||
table.add_column("Cloud Deployed")
|
||||
table.add_column("Engine Registered")
|
||||
table.add_column("Enabled")
|
||||
table.add_column("Host")
|
||||
table.add_column("Toolkits")
|
||||
|
||||
# Track workers that are registered in the engine
|
||||
engine_workers = []
|
||||
for worker in workers.items:
|
||||
if worker.id is None:
|
||||
continue
|
||||
engine_workers.append(worker.id)
|
||||
# Check if the worker is deployed in the cloud
|
||||
is_deployed = is_cloud_deployment(worker.id, deployments)
|
||||
# Get the toolkits for the worker
|
||||
|
||||
tools = get_toolkits(client, worker.id)
|
||||
uri = worker.http.uri if worker.http and worker.http.uri else ""
|
||||
table.add_row(
|
||||
worker.id,
|
||||
str(is_deployed),
|
||||
str(True),
|
||||
str(worker.enabled),
|
||||
compare_endpoints(worker.id, uri, deployments),
|
||||
"Could not fetch toolkits" if tools == "" else tools,
|
||||
)
|
||||
for deployment in deployments:
|
||||
if deployment["name"] not in engine_workers:
|
||||
table.add_row(deployment["name"], "True", "False", "False", deployment["endpoint"], "")
|
||||
console.print(table)
|
||||
|
||||
|
||||
# Check if the worker is in the list of cloud deployments
|
||||
def is_cloud_deployment(name: str, deployments: list[dict]) -> bool:
|
||||
return any(deployment["name"] == name for deployment in deployments)
|
||||
|
||||
|
||||
# Compare the endpoint of the worker in the engine to the endpoint in the cloud
|
||||
# Return a highlighted diff if the endpoint in the engine is different from the endpoint in the cloud
|
||||
def compare_endpoints(worker_id: str, engine_endpoint: str, deployments: list[dict]) -> str:
|
||||
if is_cloud_deployment(worker_id, deployments):
|
||||
for deployment in deployments:
|
||||
deployment_endpoint = deployment["endpoint"]
|
||||
if deployment["name"] == worker_id:
|
||||
if deployment_endpoint == engine_endpoint:
|
||||
return engine_endpoint
|
||||
else:
|
||||
return f"[red]Endpoint Mismatch[/red]\n[yellow]Registered Endpoint: {engine_endpoint}[/yellow]\n[green]Actual Endpoint: {deployment_endpoint}[/green]"
|
||||
return engine_endpoint
|
||||
|
||||
|
||||
@app.command("enable", help="Enable a worker")
|
||||
def enable_worker(
|
||||
worker_id: str,
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
engine_url = state["engine_url"]
|
||||
arcade = Arcade(api_key=config.api.key, base_url=engine_url)
|
||||
try:
|
||||
arcade.workers.update(worker_id, enabled=True)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Failed to enable worker '{worker_id}': {e}")
|
||||
|
||||
|
||||
@app.command("disable", help="Disable a worker")
|
||||
def disable_worker(
|
||||
worker_id: str,
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
engine_url = state["engine_url"]
|
||||
arcade = Arcade(api_key=config.api.key, base_url=engine_url)
|
||||
try:
|
||||
arcade.workers.update(worker_id, enabled=False)
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Failed to disable worker '{worker_id}': {e}")
|
||||
|
||||
|
||||
@app.command("rm", help="Remove a worker")
|
||||
def rm_worker(
|
||||
worker_id: str,
|
||||
engine_only: bool = typer.Option(
|
||||
False,
|
||||
"--deregister",
|
||||
"-d",
|
||||
help="Deregister the worker from the engine",
|
||||
),
|
||||
cloud_host: str = typer.Option(
|
||||
PROD_CLOUD_HOST,
|
||||
"--cloud-host",
|
||||
"-c",
|
||||
help="The Arcade Engine host.",
|
||||
hidden=True,
|
||||
),
|
||||
cloud_port: int = typer.Option(
|
||||
None,
|
||||
"--cloud-port",
|
||||
"-cp",
|
||||
help="The port of the Arcade Engine host.",
|
||||
hidden=True,
|
||||
),
|
||||
force_tls: bool = typer.Option(
|
||||
False,
|
||||
"--tls",
|
||||
help="Whether to force TLS for the connection to the Arcade Engine.",
|
||||
hidden=True,
|
||||
),
|
||||
force_no_tls: bool = typer.Option(
|
||||
False,
|
||||
"--no-tls",
|
||||
help="Whether to disable TLS for the connection to the Arcade Engine.",
|
||||
hidden=True,
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
engine_url = state["engine_url"]
|
||||
cloud_url = compute_base_url(force_tls, force_no_tls, cloud_host, cloud_port)
|
||||
|
||||
# First attempt to delete from the cloud
|
||||
if not engine_only:
|
||||
try:
|
||||
client = httpx.Client()
|
||||
response = client.delete(
|
||||
f"{cloud_url}/api/v1/workers/{worker_id}",
|
||||
headers={"Authorization": f"Bearer {config.api.key}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
handle_cli_error(
|
||||
"Deployment not found. To deregister the worker from the engine, use the --deregister flag."
|
||||
)
|
||||
else:
|
||||
handle_cli_error(f"Error deleting deployment: {e}")
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Error deleting deployment: {e}")
|
||||
|
||||
# Then try to delete from the engine
|
||||
try:
|
||||
arcade = Arcade(api_key=config.api.key, base_url=engine_url)
|
||||
arcade.workers.delete(worker_id)
|
||||
except NotFoundError:
|
||||
console.print("Worker not found", style="bold red")
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Error deleting worker from engine: {e}")
|
||||
|
||||
|
||||
@app.command("logs", help="Get logs for a worker")
|
||||
def worker_logs(
|
||||
worker_id: str,
|
||||
cloud_host: str = typer.Option(
|
||||
PROD_CLOUD_HOST,
|
||||
"--cloud-host",
|
||||
"-c",
|
||||
help="The Arcade Engine host.",
|
||||
hidden=True,
|
||||
),
|
||||
cloud_port: int = typer.Option(
|
||||
None,
|
||||
"--cloud-port",
|
||||
"-cp",
|
||||
help="The port of the Arcade Engine host.",
|
||||
hidden=True,
|
||||
),
|
||||
force_tls: bool = typer.Option(
|
||||
False,
|
||||
"--tls",
|
||||
help="Whether to force TLS for the connection to the Arcade Engine.",
|
||||
hidden=True,
|
||||
),
|
||||
force_no_tls: bool = typer.Option(
|
||||
False,
|
||||
"--no-tls",
|
||||
help="Whether to disable TLS for the connection to the Arcade Engine.",
|
||||
hidden=True,
|
||||
),
|
||||
) -> None:
|
||||
config = validate_and_get_config()
|
||||
cloud_url = compute_base_url(force_tls, force_no_tls, cloud_host, cloud_port)
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET",
|
||||
f"{cloud_url}/api/v1/workers/logs/{worker_id}",
|
||||
headers={"Authorization": f"Bearer {config.api.key}", "Accept": "text/event-stream"},
|
||||
# allow the connection to stay open indefinitely
|
||||
timeout=None, # noqa: S113
|
||||
) as s:
|
||||
for line in s.iter_lines():
|
||||
if not line or "[DONE]" in line: # Skip empty lines
|
||||
continue
|
||||
if "event: error" in line:
|
||||
handle_cli_error("Could not stream logs")
|
||||
if line.startswith("data:"):
|
||||
# Extract just the data portion after 'data:'
|
||||
data = line[5:].strip() # Remove 'data:' prefix and whitespace
|
||||
console.print(data, markup=False)
|
||||
except CLIError:
|
||||
raise
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Error connecting to log stream: {e}")
|
||||
|
||||
|
||||
def get_toolkits(client: Arcade, worker_id: str | None) -> str:
|
||||
if worker_id is None:
|
||||
return ""
|
||||
try:
|
||||
# Get tools for the given worker
|
||||
tools = client.workers.tools(worker_id)
|
||||
toolkits: list[str] = []
|
||||
if not tools.items:
|
||||
return ""
|
||||
|
||||
# Get toolkit names
|
||||
for page in tools.iter_pages():
|
||||
for tool in page.items:
|
||||
if tool.toolkit.name not in toolkits:
|
||||
toolkits.append(tool.toolkit.name)
|
||||
return ", ".join(toolkits)
|
||||
except NotFoundError:
|
||||
return ""
|
||||
except Exception as e:
|
||||
handle_cli_error(f"Error getting server tools: {e}")
|
||||
return ""
|
||||
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -91,7 +92,7 @@ class MCPApp:
|
|||
reload: Enable auto-reload for development
|
||||
**kwargs: Additional server configuration
|
||||
"""
|
||||
self.name = name
|
||||
self._name = self._validate_name(name)
|
||||
self.version = version
|
||||
self.title = title or name
|
||||
self.instructions = instructions
|
||||
|
|
@ -111,7 +112,7 @@ class MCPApp:
|
|||
|
||||
self._mcp_settings = MCPSettings(
|
||||
server=ServerSettings(
|
||||
name=self.name,
|
||||
name=self._name,
|
||||
version=self.version,
|
||||
title=self.title,
|
||||
instructions=self.instructions,
|
||||
|
|
@ -122,7 +123,57 @@ class MCPApp:
|
|||
if not logger._core.handlers: # type: ignore[attr-defined]
|
||||
self._setup_logging(transport == "stdio")
|
||||
|
||||
def _validate_name(self, name: str) -> str:
|
||||
"""
|
||||
Validate that the name follows the required pattern:
|
||||
- Alphanumeric characters and underscores only
|
||||
- Must end with alphanumeric character
|
||||
- Cannot start with underscore
|
||||
- Cannot have consecutive underscores
|
||||
|
||||
Args:
|
||||
name: The name to validate
|
||||
|
||||
Returns:
|
||||
The validated name
|
||||
|
||||
Raises:
|
||||
TypeError: If the name is not a string
|
||||
ValueError: If the name doesn't follow the required pattern
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
raise TypeError("MCPApp's name must be a string")
|
||||
|
||||
if not name:
|
||||
raise ValueError("MCPApp's name cannot be empty")
|
||||
|
||||
if not re.match(r"^[a-zA-Z0-9_]+$", name):
|
||||
raise ValueError(
|
||||
"MCPApp's name must contain only alphanumeric characters and underscores"
|
||||
)
|
||||
|
||||
if name.startswith("_"):
|
||||
raise ValueError("MCPApp's name cannot start with an underscore")
|
||||
|
||||
if "__" in name:
|
||||
raise ValueError("MCPApp's name cannot have consecutive underscores")
|
||||
|
||||
if not re.match(r".*[a-zA-Z0-9]$", name):
|
||||
raise ValueError("MCPApp's name must end with an alphanumeric character")
|
||||
|
||||
return name
|
||||
|
||||
# Properties (exposed below initializer)
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the server name."""
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str) -> None:
|
||||
"""Set the server name with validation."""
|
||||
self._name = self._validate_name(value)
|
||||
|
||||
@property
|
||||
def tools(self) -> _ToolsAPI:
|
||||
"""Runtime and build-time tools API: add/update/remove/list."""
|
||||
|
|
@ -245,7 +296,7 @@ class MCPApp:
|
|||
# parent watcher has already been setup
|
||||
reload = False
|
||||
|
||||
logger.info(f"Starting {self.name} v{self.version} with {len(self._catalog)} tools")
|
||||
logger.info(f"Starting {self._name} v{self.version} with {len(self._catalog)} tools")
|
||||
|
||||
if transport in ["http", "streamable-http", "streamable"]:
|
||||
if reload:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "arcade-mcp-server"
|
||||
version = "1.6.1"
|
||||
version = "1.7.0"
|
||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Arcade.dev" }]
|
||||
|
|
|
|||
|
|
@ -327,9 +327,10 @@ class TestMCPApp:
|
|||
|
||||
def test_create_and_run_server(self, mcp_app: MCPApp):
|
||||
"""Test _create_and_run_server method with mocked dependencies."""
|
||||
with patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create, patch(
|
||||
"arcade_mcp_server.mcp_app.uvicorn"
|
||||
) as mock_uvicorn:
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create,
|
||||
patch("arcade_mcp_server.mcp_app.uvicorn") as mock_uvicorn,
|
||||
):
|
||||
mock_fastapi_app = Mock()
|
||||
mock_create.return_value = mock_fastapi_app
|
||||
|
||||
|
|
@ -352,9 +353,10 @@ class TestMCPApp:
|
|||
)
|
||||
|
||||
# Test with DEBUG log level
|
||||
with patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create, patch(
|
||||
"arcade_mcp_server.mcp_app.uvicorn"
|
||||
) as mock_uvicorn:
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.create_arcade_mcp") as mock_create,
|
||||
patch("arcade_mcp_server.mcp_app.uvicorn") as mock_uvicorn,
|
||||
):
|
||||
mock_fastapi_app = Mock()
|
||||
mock_create.return_value = mock_fastapi_app
|
||||
|
||||
|
|
@ -381,9 +383,10 @@ class TestMCPApp:
|
|||
mock_process.terminate = Mock()
|
||||
mock_process.wait = Mock()
|
||||
|
||||
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
|
||||
"arcade_mcp_server.mcp_app.watch"
|
||||
) as mock_watch:
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
|
||||
patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
|
||||
):
|
||||
mock_popen.return_value = mock_process
|
||||
# Return empty iterator to exit immediately
|
||||
mock_watch.return_value = iter([])
|
||||
|
|
@ -401,9 +404,10 @@ class TestMCPApp:
|
|||
mock_process1 = Mock()
|
||||
mock_process2 = Mock()
|
||||
|
||||
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
|
||||
"arcade_mcp_server.mcp_app.watch"
|
||||
) as mock_watch:
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
|
||||
patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
|
||||
):
|
||||
mock_popen.side_effect = [mock_process1, mock_process2]
|
||||
# Yield one set of changes then stop
|
||||
mock_watch.return_value = iter([{("change", "test.py")}])
|
||||
|
|
@ -422,9 +426,10 @@ class TestMCPApp:
|
|||
mock_process = Mock()
|
||||
mock_process.wait = Mock() # Succeeds without timeout
|
||||
|
||||
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
|
||||
"arcade_mcp_server.mcp_app.watch"
|
||||
) as mock_watch:
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
|
||||
patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
|
||||
):
|
||||
mock_popen.return_value = mock_process
|
||||
mock_watch.return_value = iter([{("change", "test.py")}])
|
||||
|
||||
|
|
@ -439,13 +444,12 @@ class TestMCPApp:
|
|||
"""Test _run_with_reload force kills process on timeout."""
|
||||
mock_process = Mock()
|
||||
# First wait times out, second succeeds
|
||||
mock_process.wait = Mock(
|
||||
side_effect=[subprocess.TimeoutExpired("cmd", 5), None]
|
||||
)
|
||||
mock_process.wait = Mock(side_effect=[subprocess.TimeoutExpired("cmd", 5), None])
|
||||
|
||||
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
|
||||
"arcade_mcp_server.mcp_app.watch"
|
||||
) as mock_watch:
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
|
||||
patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
|
||||
):
|
||||
mock_popen.return_value = mock_process
|
||||
mock_watch.return_value = iter([{("change", "test.py")}])
|
||||
|
||||
|
|
@ -460,9 +464,10 @@ class TestMCPApp:
|
|||
"""Test _run_with_reload handles KeyboardInterrupt gracefully."""
|
||||
mock_process = Mock()
|
||||
|
||||
with patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen, patch(
|
||||
"arcade_mcp_server.mcp_app.watch"
|
||||
) as mock_watch:
|
||||
with (
|
||||
patch("arcade_mcp_server.mcp_app.subprocess.Popen") as mock_popen,
|
||||
patch("arcade_mcp_server.mcp_app.watch") as mock_watch,
|
||||
):
|
||||
mock_popen.return_value = mock_process
|
||||
mock_watch.side_effect = KeyboardInterrupt()
|
||||
|
||||
|
|
@ -474,9 +479,10 @@ class TestMCPApp:
|
|||
|
||||
def test_run_routes_to_reload_method(self, mcp_app: MCPApp):
|
||||
"""Test run() routes to _run_with_reload when reload=True."""
|
||||
with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
|
||||
mcp_app, "_create_and_run_server"
|
||||
) as mock_direct:
|
||||
with (
|
||||
patch.object(mcp_app, "_run_with_reload") as mock_reload,
|
||||
patch.object(mcp_app, "_create_and_run_server") as mock_direct,
|
||||
):
|
||||
mcp_app.run(reload=True, transport="http", host="127.0.0.1", port=8000)
|
||||
|
||||
mock_reload.assert_called_once_with("127.0.0.1", 8000)
|
||||
|
|
@ -484,9 +490,10 @@ class TestMCPApp:
|
|||
|
||||
def test_run_routes_to_direct_method(self, mcp_app: MCPApp):
|
||||
"""Test run() routes to _create_and_run_server when reload=False."""
|
||||
with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
|
||||
mcp_app, "_create_and_run_server"
|
||||
) as mock_direct:
|
||||
with (
|
||||
patch.object(mcp_app, "_run_with_reload") as mock_reload,
|
||||
patch.object(mcp_app, "_create_and_run_server") as mock_direct,
|
||||
):
|
||||
mcp_app.run(reload=False, transport="http", host="127.0.0.1", port=8000)
|
||||
|
||||
mock_direct.assert_called_once_with("127.0.0.1", 8000)
|
||||
|
|
@ -496,9 +503,10 @@ class TestMCPApp:
|
|||
"""Test run() disables reload when ARCADE_MCP_CHILD_PROCESS is set."""
|
||||
monkeypatch.setenv("ARCADE_MCP_CHILD_PROCESS", "1")
|
||||
|
||||
with patch.object(mcp_app, "_run_with_reload") as mock_reload, patch.object(
|
||||
mcp_app, "_create_and_run_server"
|
||||
) as mock_direct:
|
||||
with (
|
||||
patch.object(mcp_app, "_run_with_reload") as mock_reload,
|
||||
patch.object(mcp_app, "_create_and_run_server") as mock_direct,
|
||||
):
|
||||
mcp_app.run(reload=True, transport="http", host="127.0.0.1", port=8000)
|
||||
|
||||
# Should route to direct method even though reload=True
|
||||
|
|
@ -517,3 +525,105 @@ class TestMCPApp:
|
|||
# Test with reload=False
|
||||
mcp_app.run(reload=False, transport="stdio")
|
||||
mock_stdio.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,expected_result",
|
||||
[
|
||||
# Valid names
|
||||
("ValidName", "ValidName"),
|
||||
("valid_name", "valid_name"),
|
||||
("ValidName123", "ValidName123"),
|
||||
("valid_name_123", "valid_name_123"),
|
||||
("a", "a"),
|
||||
("A", "A"),
|
||||
("1", "1"),
|
||||
("name1", "name1"),
|
||||
("Name1", "Name1"),
|
||||
("validName", "validName"),
|
||||
("Valid_Name", "Valid_Name"),
|
||||
("valid_name_test", "valid_name_test"),
|
||||
("Test123Name", "Test123Name"),
|
||||
("a1b2c3", "a1b2c3"),
|
||||
("A1B2C3", "A1B2C3"),
|
||||
],
|
||||
)
|
||||
def test_validate_name_valid_names(self, name: str, expected_result: str):
|
||||
"""Test _validate_name with valid names."""
|
||||
app = MCPApp()
|
||||
result = app._validate_name(name)
|
||||
assert result == expected_result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,expected_error",
|
||||
[
|
||||
# Empty name
|
||||
("", ValueError),
|
||||
# Non-string types
|
||||
(None, TypeError),
|
||||
(123, TypeError),
|
||||
([], TypeError),
|
||||
({}, TypeError),
|
||||
# Names starting with underscore
|
||||
("_invalid", ValueError),
|
||||
("_name", ValueError),
|
||||
("_123", ValueError),
|
||||
("_", ValueError),
|
||||
# Names with consecutive underscores
|
||||
("name__test", ValueError),
|
||||
("test__name", ValueError),
|
||||
("__name", ValueError),
|
||||
("name__", ValueError),
|
||||
("__", ValueError),
|
||||
# Names ending with underscore
|
||||
("name_", ValueError),
|
||||
("test_", ValueError),
|
||||
("_", ValueError),
|
||||
# Names with invalid characters
|
||||
("name-test", ValueError),
|
||||
("name.test", ValueError),
|
||||
("name test", ValueError),
|
||||
("name@test", ValueError),
|
||||
("name#test", ValueError),
|
||||
("name$test", ValueError),
|
||||
("name%test", ValueError),
|
||||
("name^test", ValueError),
|
||||
("name&test", ValueError),
|
||||
("name*test", ValueError),
|
||||
("name+test", ValueError),
|
||||
("name=test", ValueError),
|
||||
("name[test", ValueError),
|
||||
("name]test", ValueError),
|
||||
("name{test", ValueError),
|
||||
("name}test", ValueError),
|
||||
("name|test", ValueError),
|
||||
("name\\test", ValueError),
|
||||
("name:test", ValueError),
|
||||
("name;test", ValueError),
|
||||
("name'test", ValueError),
|
||||
('name"test', ValueError),
|
||||
("name<test", ValueError),
|
||||
("name>test", ValueError),
|
||||
("name,test", ValueError),
|
||||
("name.test", ValueError),
|
||||
("name?test", ValueError),
|
||||
("name/test", ValueError),
|
||||
("name!test", ValueError),
|
||||
("name~test", ValueError),
|
||||
("name`test", ValueError),
|
||||
# Names with spaces
|
||||
("name test", ValueError),
|
||||
(" name", ValueError),
|
||||
("name ", ValueError),
|
||||
(" name ", ValueError),
|
||||
# Names with special unicode characters
|
||||
("nameñ", ValueError),
|
||||
("nameé", ValueError),
|
||||
("name中", ValueError),
|
||||
("name🚀", ValueError),
|
||||
],
|
||||
)
|
||||
def test_validate_name_invalid_names(self, name, expected_error):
|
||||
"""Test _validate_name with invalid names."""
|
||||
app = MCPApp()
|
||||
with pytest.raises(expected_error):
|
||||
app._validate_name(name)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "arcade-mcp"
|
||||
version = "1.4.0"
|
||||
version = "1.5.0"
|
||||
description = "Arcade.dev - Tool Calling platform for Agents"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
|
|
@ -21,7 +21,7 @@ requires-python = ">=3.10"
|
|||
|
||||
dependencies = [
|
||||
# CLI dependencies
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"arcade-mcp-server>=1.7.0,<2.0.0",
|
||||
"arcade-core>=3.0.0,<4.0.0",
|
||||
"typer==0.10.0",
|
||||
"rich==13.9.4",
|
||||
|
|
@ -42,7 +42,7 @@ all = [
|
|||
"pytz>=2024.1",
|
||||
"python-dateutil>=2.8.2",
|
||||
# mcp
|
||||
"arcade-mcp-server>=1.5.0,<2.0.0",
|
||||
"arcade-mcp-server>=1.7.0,<2.0.0",
|
||||
# serve
|
||||
"arcade-serve>=3.0.0,<4.0.0",
|
||||
# tdk
|
||||
|
|
|
|||
Loading…
Reference in a new issue