From b3f7d2ea264d40038a81c7bb7a20a16e9d788392 Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 10:51:10 -0400 Subject: [PATCH 1/8] fly.io connector --- client/public/flyio.svg | 1 + .../app/api/proxy/flyio/[...path]/route.ts | 13 + client/src/app/flyio/auth/page.tsx | 339 ++++++++++++++++++ .../connectors/ConnectorRegistry.ts | 11 + server/Dockerfile | 11 + .../backend/agent/prompt/provider_rules.py | 2 +- .../agent/skills/integrations/flyio/SKILL.md | 40 +++ .../backend/agent/tools/cloud_exec_tool.py | 52 ++- .../chat/backend/agent/tools/cloud_tools.py | 30 ++ server/chat/backend/agent/tools/flyio_tool.py | 80 +++++ server/connectors/flyio_connector/__init__.py | 0 .../connectors/flyio_connector/api_client.py | 86 +++++ server/connectors/flyio_connector/auth.py | 46 +++ server/main_compute.py | 4 + server/routes/flyio/__init__.py | 17 + server/routes/flyio/flyio_routes.py | 147 ++++++++ server/utils/providers.py | 1 + server/utils/secrets/secret_ref_utils.py | 1 + 18 files changed, 876 insertions(+), 5 deletions(-) create mode 100644 client/public/flyio.svg create mode 100644 client/src/app/api/proxy/flyio/[...path]/route.ts create mode 100644 client/src/app/flyio/auth/page.tsx create mode 100644 server/chat/backend/agent/skills/integrations/flyio/SKILL.md create mode 100644 server/chat/backend/agent/tools/flyio_tool.py create mode 100644 server/connectors/flyio_connector/__init__.py create mode 100644 server/connectors/flyio_connector/api_client.py create mode 100644 server/connectors/flyio_connector/auth.py create mode 100644 server/routes/flyio/__init__.py create mode 100644 server/routes/flyio/flyio_routes.py diff --git a/client/public/flyio.svg b/client/public/flyio.svg new file mode 100644 index 000000000..0d0086b7e --- /dev/null +++ b/client/public/flyio.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/app/api/proxy/flyio/[...path]/route.ts b/client/src/app/api/proxy/flyio/[...path]/route.ts new file mode 100644 index 000000000..a0b49f1f1 --- /dev/null +++ b/client/src/app/api/proxy/flyio/[...path]/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from 'next/server'; +import { forwardRequest } from '@/lib/backend-proxy'; + +async function handler( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params; + const backendPath = '/flyio_api/flyio/' + path.join('/'); + return forwardRequest(request, request.method, backendPath, 'flyio'); +} + +export { handler as GET, handler as POST, handler as DELETE }; diff --git a/client/src/app/flyio/auth/page.tsx b/client/src/app/flyio/auth/page.tsx new file mode 100644 index 000000000..2611d583b --- /dev/null +++ b/client/src/app/flyio/auth/page.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, ExternalLink, AlertCircle, CheckCircle2, Shield, LogOut, Server } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { providerPreferencesService } from '@/lib/services/providerPreferences'; +import ConnectorAuthGuard from "@/components/connectors/ConnectorAuthGuard"; + +interface FlyioStatus { + connected: boolean; + org_slug?: string; + tier?: "readonly" | "full"; + app_names?: string[]; +} + +export default function FlyioAuthPage() { + const [isLoading, setIsLoading] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [isCheckingStatus, setIsCheckingStatus] = useState(true); + const [error, setError] = useState(null); + const [apiToken, setApiToken] = useState(""); + const [orgSlug, setOrgSlug] = useState(""); + const [status, setStatus] = useState(null); + const { toast } = useToast(); + + const checkStatus = useCallback(async () => { + setIsCheckingStatus(true); + try { + const response = await fetch(`/api/proxy/flyio/status?validate=true`); + if (response.ok) { + const data = await response.json(); + setStatus({ + connected: data.connected === true, + org_slug: data.org_slug, + tier: data.tier, + app_names: data.app_names, + }); + } else { + setStatus({ connected: false }); + } + } catch { + setStatus({ connected: false }); + } finally { + setIsCheckingStatus(false); + } + }, []); + + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + const handleConnect = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!apiToken || !orgSlug) { + setError("Both API token and organization slug are required"); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/proxy/flyio/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiToken, orgSlug }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to connect to Fly.io'); + } + + localStorage.setItem("isFlyioConnected", "true"); + await providerPreferencesService.smartAutoSelect('flyio', true); + window.dispatchEvent(new CustomEvent('providerStateChanged')); + window.dispatchEvent(new CustomEvent('providerConnectionAction')); + + toast({ + title: "Fly.io Connected", + description: `Connected to org "${data.org_slug}" with ${data.tier === "readonly" ? "read-only" : "full"} access. Found ${data.app_names?.length ?? 0} app(s).`, + }); + + setApiToken(""); + setOrgSlug(""); + setStatus({ + connected: true, + org_slug: data.org_slug, + tier: data.tier, + app_names: data.app_names, + }); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Failed to connect to Fly.io'; + setError(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleDisconnect = async () => { + setIsDisconnecting(true); + try { + const response = await fetch(`/api/proxy/flyio/disconnect`, { + method: 'DELETE', + }); + + if (response.ok) { + localStorage.removeItem("isFlyioConnected"); + window.dispatchEvent(new CustomEvent('providerStateChanged')); + + toast({ + title: "Fly.io Disconnected", + description: "Your Fly.io account has been disconnected.", + }); + + setStatus({ connected: false }); + setApiToken(""); + setOrgSlug(""); + } else { + const data = await response.json(); + throw new Error(data.error || "Failed to disconnect"); + } + } catch (err: unknown) { + toast({ + title: "Error", + description: err instanceof Error ? err.message : "Failed to disconnect Fly.io", + variant: "destructive", + }); + } finally { + setIsDisconnecting(false); + } + }; + + if (isCheckingStatus) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

Fly.io Integration

+

+ Connect to Fly.io for application monitoring, machine lifecycle management, metrics, logs, and incident remediation. +

+
+ + {status?.connected ? ( +
+ + +
+ +
+ Fly.io Connected + Your Fly.io organization is linked to Aurora +
+
+
+ +
+
+ +
+

Organization

+

{status.org_slug}

+
+
+
+ +
+

Access Level

+ + {status.tier === "full" ? "Full Access" : "Read-Only"} + +
+
+
+ + {status.app_names && status.app_names.length > 0 && ( +
+

+ Apps ({status.app_names.length}) +

+
+ {status.app_names.map((name) => ( +
+ + {name} +
+ ))} +
+
+ )} + + {status.tier === "readonly" && ( +
+ +
+

Read-only access

+

+ Aurora can monitor and diagnose but cannot take remediation actions (restart, stop, start machines). + To enable remediation, reconnect with a full org token from your{" "} + dashboard tokens page. +

+
+
+ )} + +
+ +
+
+
+
+ ) : ( + + + Connect Your Fly.io Organization + Generate an org-scoped API token and paste it below + + +
+

+ Aurora uses an org-scoped API token to monitor your Fly.io applications. Generate one from your Fly.io dashboard. +

+ +
+

Setup

+
+
+ 1. +
+

+ In your Fly.io dashboard, go to Account →{" "} + + Access Tokens + {" "} + and create a token for your organization +

+
+
+
+ 2. +

Copy the token and paste it below along with your org slug

+
+
+

+ Aurora will automatically detect your token's permission level (read-only or full access). +

+
+
+ + {error && ( +
+ +

{error}

+
+ )} + +
+
+ + setOrgSlug(e.target.value)} + required + disabled={isLoading} + /> +

+ Use personal for personal accounts, or your org name for team organizations +

+
+ +
+ + setApiToken(e.target.value)} + required + disabled={isLoading} + /> +
+ +
+ +
+
+
+
+ )} +
+
+ ); +} diff --git a/client/src/components/connectors/ConnectorRegistry.ts b/client/src/components/connectors/ConnectorRegistry.ts index a503bdf8b..aa1fda16f 100644 --- a/client/src/components/connectors/ConnectorRegistry.ts +++ b/client/src/components/connectors/ConnectorRegistry.ts @@ -393,6 +393,17 @@ class ConnectorRegistry { storageKey: "isCloudflareConnected", }); + this.register({ + id: "flyio", + name: "Fly.io", + description: "Connect to Fly.io for application monitoring, machine lifecycle management, metrics, logs, and incident remediation across your org.", + iconPath: "/flyio.svg", + iconBgColor: "bg-white dark:bg-white", + category: "Infrastructure", + path: "/flyio/auth", + storageKey: "isFlyioConnected", + }); + this.register({ id: "jenkins", name: "Jenkins", diff --git a/server/Dockerfile b/server/Dockerfile index 5b0422928..dbf8a1fc2 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -206,6 +206,17 @@ RUN ARCH=$(uname -m) && \ # Install Tailscale CLI (for local dev SSH to tailnet devices) RUN curl -fsSL https://tailscale.com/install.sh | sh +# Install Fly.io CLI (flyctl) for Fly.io connector +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "x86_64" ]; then \ + FLY_ARCH="x86_64"; \ + elif [ "$ARCH" = "aarch64" ]; then \ + FLY_ARCH="arm64"; \ + else \ + echo "Unsupported architecture: $ARCH" && exit 1; \ + fi && \ + curl -fsSL "https://fly.io/install.sh" | FLYCTL_INSTALL="/usr/local" sh + # Install Node.js for MCP servers RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ apt-get install -y nodejs && \ diff --git a/server/chat/backend/agent/prompt/provider_rules.py b/server/chat/backend/agent/prompt/provider_rules.py index 0193c7f8d..1b2f9e0c4 100644 --- a/server/chat/backend/agent/prompt/provider_rules.py +++ b/server/chat/backend/agent/prompt/provider_rules.py @@ -7,7 +7,7 @@ # Providers not in this set (e.g. grafana) are observation-only and should # never be passed as the provider argument to cloud_exec. CLOUD_EXEC_PROVIDERS = frozenset({ - "gcp", "aws", "azure", "ovh", "scaleway", "tailscale", + "gcp", "aws", "azure", "ovh", "scaleway", "tailscale", "flyio", }) diff --git a/server/chat/backend/agent/skills/integrations/flyio/SKILL.md b/server/chat/backend/agent/skills/integrations/flyio/SKILL.md new file mode 100644 index 000000000..4137a6a6d --- /dev/null +++ b/server/chat/backend/agent/skills/integrations/flyio/SKILL.md @@ -0,0 +1,40 @@ +--- +name: flyio +id: flyio +description: "Fly.io integration for application monitoring, machine lifecycle management, metrics, logs, and incident remediation" +category: cloud_provider +connection_check: + method: is_connected_function + module: chat.backend.agent.tools.flyio_tool + function: is_flyio_connected +tools: + - cloud_exec + - query_flyio_metrics +index: "Fly.io — apps, machines, logs, metrics, deployments, health checks, remediation" +rca_priority: 8 +allowed-tools: cloud_exec, query_flyio_metrics +metadata: + author: aurora + version: "1.0" +--- + +# Fly.io Integration + +## Overview +Fly.io is connected for application monitoring, machine lifecycle management, and incident remediation. + +## Instructions + +### How to interact +- Use `cloud_exec('flyio', '')` for all Fly.io CLI operations. +- The CLI (`flyctl`) is pre-authenticated with the user's org-scoped token. +- Always pass `--json` flag for structured output when available. +- Always include `-a ` in commands that target a specific app. + +### Prometheus metrics +Use `query_flyio_metrics(query)` for PromQL queries against Fly.io's Prometheus federation. Common metrics include (but are not limited to) `fly_instance_up`, `fly_instance_cpu`, `fly_instance_memory_resident`, `fly_edge_http_responses_count`, `fly_edge_http_response_time_seconds_bucket`, `fly_instance_net_recv_bytes`, `fly_app_concurrency`. + +### Critical rules +- Always use `cloud_exec('flyio', ...)` -- never call the REST API directly. +- Always include `-a ` in commands that target a specific app. +- Always pass `--json` for structured output when available. diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py index c7db46c77..f93bfce8d 100644 --- a/server/chat/backend/agent/tools/cloud_exec_tool.py +++ b/server/chat/backend/agent/tools/cloud_exec_tool.py @@ -23,6 +23,7 @@ from utils.auth.cloud_auth import generate_contextual_access_token from utils.auth.cloud_auth import generate_azure_access_token +from utils.secrets.secret_ref_utils import get_user_token_data, get_token_owner_id from .output_sanitizer import sanitize_command_output, filter_error_messages, truncate_json_fields from .cloud_provider_utils import determine_target_provider_from_context from chat.backend.agent.prompt.prompt_builder import CLOUD_EXEC_PROVIDERS @@ -42,6 +43,8 @@ def _normalize_cloud_exec_provider(raw: Optional[str]) -> str: return "aws" if p == "scw": return "scaleway" + if p in ("fly", "flyctl"): + return "flyio" return p @@ -587,7 +590,6 @@ def setup_ovh_environment_isolated(user_id: str, selected_project_id: str | None logger.info("Setting up isolated OVH environment...") # Get OVH token data from database - from utils.secrets.secret_ref_utils import get_user_token_data token_data = get_user_token_data(user_id, 'ovh') if not token_data: @@ -650,7 +652,6 @@ def setup_ovh_environment_isolated(user_id: str, selected_project_id: str | None } if project_id: updated_storage["projectId"] = project_id - from utils.secrets.secret_ref_utils import get_token_owner_id owner_id = get_token_owner_id(user_id, "ovh") store_tokens_in_db(owner_id, updated_storage, 'ovh') logger.info("Successfully refreshed OVH access token") @@ -706,7 +707,6 @@ def setup_scaleway_environment_isolated(user_id: str, selected_project_id: str | logger.info("Setting up isolated Scaleway environment...") # Get Scaleway token data from database - from utils.secrets.secret_ref_utils import get_user_token_data token_data = get_user_token_data(user_id, 'scaleway') if not token_data: @@ -774,7 +774,6 @@ def setup_tailscale_environment_isolated(user_id: str, selected_tailnet: str | N logger.info("Setting up isolated Tailscale environment...") # Get Tailscale token data from database - from utils.secrets.secret_ref_utils import get_user_token_data stored_data = get_user_token_data(user_id, 'tailscale') if not stored_data: @@ -1096,6 +1095,45 @@ def execute_tailscale_command(command: str, isolated_env: dict) -> dict: } +def setup_flyio_environment_isolated(user_id: str, selected_org: str | None = None): + """Set up Fly.io environment with isolated credentials - NO global state modification. + + Fly.io CLI (flyctl) uses FLY_API_TOKEN for authentication. + """ + try: + fn_start = time.perf_counter() + logger.info("Setting up isolated Fly.io environment...") + + token_data = get_user_token_data(user_id, 'flyio') + + if not token_data: + logger.error("No Fly.io credentials found for user") + return False, None, None, None + + api_token = token_data.get('api_token') + org_slug = selected_org or token_data.get('org_slug') + + if not api_token: + logger.error("Missing Fly.io api_token") + return False, None, None, None + + isolated_env = { + "PATH": os.environ.get("PATH", ""), + "HOME": _ISOLATED_HOME, + "USER": os.environ.get("USER", ""), + "FLY_API_TOKEN": api_token, + } + + logger.info(f"Fly.io isolated environment configured (org: {org_slug})") + logger.info(f"TIME: setup_flyio_environment_isolated completed in {time.perf_counter() - fn_start:.2f}s") + + return True, org_slug, "api_token", isolated_env + + except Exception as e: + logger.error(f"Failed to setup Fly.io environment: {e}") + return False, None, None, None + + def is_read_only_command(command: str) -> bool: """Check if a cloud command is read-only (list, describe, get, etc.).""" read_only_verbs = ['list', 'describe', 'get', 'show', 'config', 'version', 'info', 'status', 'read', 'view', 'help', 'logs', 'top'] @@ -1424,6 +1462,12 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi if not success: return json.dumps({"error": f"Failed to setup Tailscale environment. Please connect your Tailscale account first.", "final_command": command, "requires_connection": True}) resource_id = tailnet + elif normalized_provider == 'flyio': + # Fly.io isolated setup - uses FLY_API_TOKEN env var + success, org_slug, auth_method, isolated_env = setup_flyio_environment_isolated(user_id, selected_project_id) + if not success: + return json.dumps({"error": f"Failed to setup Fly.io environment. Please connect your Fly.io account first.", "final_command": command, "requires_connection": True}) + resource_id = org_slug elif normalized_provider not in CLOUD_EXEC_PROVIDERS: return json.dumps({ "success": False, diff --git a/server/chat/backend/agent/tools/cloud_tools.py b/server/chat/backend/agent/tools/cloud_tools.py index f978d9842..dbcc0040b 100644 --- a/server/chat/backend/agent/tools/cloud_tools.py +++ b/server/chat/backend/agent/tools/cloud_tools.py @@ -175,6 +175,11 @@ CloudflareListZonesArgs, CloudflareActionArgs, ) +from .flyio_tool import ( + query_flyio_metrics, + is_flyio_connected, + FlyioMetricsQueryArgs, +) # Import all context management functions from utils from utils.cloud.cloud_utils import ( @@ -2366,6 +2371,31 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw): except Exception as e: logging.warning(f"Failed to add Cloudflare tools (treating as not connected): {e}") + # Add Fly.io metrics tool if connected + try: + if is_flyio_connected(user_id): + _ctx = with_user_context(query_flyio_metrics) + _notif = with_completion_notification(_ctx) + _final = wrap_func_with_capture(_notif, "query_flyio_metrics") if tool_capture else _notif + tools.append(StructuredTool.from_function( + func=_final, + name="query_flyio_metrics", + description=( + "Query Fly.io Prometheus metrics. Use PromQL expressions. " + "Available metrics: fly_instance_up (health), fly_instance_cpu (CPU), " + "fly_instance_memory_resident (memory RSS), fly_instance_net_recv_bytes / " + "fly_instance_net_sent_bytes (network), fly_edge_http_responses_count (HTTP by status), " + "fly_edge_http_response_time_seconds_bucket (latency), fly_app_concurrency (connections). " + "Labels: app, region, host, instance. Example: fly_instance_up{app=\"myapp\"}" + ), + args_schema=FlyioMetricsQueryArgs, + )) + logging.info(f"Added Fly.io metrics tool for user {user_id}") + else: + logging.debug(f"Fly.io tools not added - user {user_id} not connected to Fly.io") + except Exception as e: + logging.warning(f"Failed to add Fly.io tools (treating as not connected): {e}") + # Add alert payload drill-down tool for RCA sessions with an incident incident_id = getattr(state_context, 'incident_id', None) if state_context else None if incident_id and is_background: diff --git a/server/chat/backend/agent/tools/flyio_tool.py b/server/chat/backend/agent/tools/flyio_tool.py new file mode 100644 index 000000000..31fd33313 --- /dev/null +++ b/server/chat/backend/agent/tools/flyio_tool.py @@ -0,0 +1,80 @@ +""" +Fly.io agent tool -- connection check and metrics query. + +The primary agent interaction with Fly.io is through cloud_exec(provider='flyio', command='fly ...') +which uses the flyctl CLI. This module provides: +1. is_flyio_connected() -- connection gate for tool registration +2. A Prometheus metrics query tool for structured metrics access +""" + +import json +import logging +from typing import Optional + +from pydantic import BaseModel, Field +from utils.auth.token_management import get_token_data +from connectors.flyio_connector.api_client import FlyioClient + +logger = logging.getLogger(__name__) + + +def is_flyio_connected(user_id: str) -> bool: + """Check if Fly.io is connected for a user.""" + try: + token_data = get_token_data(user_id, "flyio") + return bool(token_data and "api_token" in token_data) + except Exception: + return False + + +class FlyioMetricsQueryArgs(BaseModel): + """Arguments for querying Fly.io Prometheus metrics.""" + query: str = Field(description="PromQL query (e.g. 'fly_instance_up{app=\"myapp\"}', 'rate(fly_instance_cpu{app=\"myapp\"}[5m])')") + time: Optional[str] = Field(default=None, description="Evaluation time (RFC3339 or Unix timestamp). Defaults to now.") + + +def query_flyio_metrics(query: str, time: Optional[str] = None, user_id: str = None) -> str: + """Query Fly.io Prometheus metrics endpoint.""" + token_data = get_token_data(user_id, "flyio") + if not token_data: + return json.dumps({"error": "Fly.io not connected. Please connect your Fly.io account first."}) + + api_token = token_data.get("api_token") + org_slug = token_data.get("org_slug") + + if not api_token or not org_slug: + return json.dumps({"error": "Incomplete Fly.io credentials"}) + + client = FlyioClient(api_token, org_slug) + + result = client.query_prometheus(query, time_param=time) + if result is None: + return json.dumps({"error": f"Prometheus query failed for: {query}"}) + + data = result.get("data", {}) + results = data.get("result", []) + + if not results: + return json.dumps({"query": query, "results": [], "message": "No data returned"}) + + formatted = [] + for r in results[:50]: + metric = r.get("metric", {}) + value = r.get("value", []) + formatted.append({ + "labels": metric, + "value": value[1] if len(value) > 1 else None, + "timestamp": value[0] if value else None, + }) + + response = { + "query": query, + "result_count": len(results), + "results": formatted, + } + + if len(results) > 50: + response["truncated"] = True + response["message"] = f"Showing 50 of {len(results)} results. Use more specific label selectors to narrow down." + + return json.dumps(response, indent=2) diff --git a/server/connectors/flyio_connector/__init__.py b/server/connectors/flyio_connector/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/server/connectors/flyio_connector/api_client.py b/server/connectors/flyio_connector/api_client.py new file mode 100644 index 000000000..b71d2b4e4 --- /dev/null +++ b/server/connectors/flyio_connector/api_client.py @@ -0,0 +1,86 @@ +""" +Fly.io REST API client for auth validation and Prometheus metrics. + +Used by the connector auth layer and the agent's metrics tool. +The agent uses flyctl CLI via cloud_exec for all other interactions. +""" + +import logging +import requests +from typing import Dict, List, Optional, Any + +logger = logging.getLogger(__name__) + +FLYIO_MACHINES_API = "https://api.machines.dev/v1" +FLYIO_PROMETHEUS_API = "https://api.fly.io/prometheus" + + +class FlyioClient: + """REST client for Fly.io Machines API and Prometheus federation endpoint.""" + + def __init__(self, api_token: str, org_slug: str): + self.api_token = api_token + self.org_slug = org_slug + self._headers = { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + } + + def list_apps(self) -> Optional[List[Dict[str, Any]]]: + """List all apps in the organization. Returns None on auth/network failure.""" + try: + response = requests.get( + f"{FLYIO_MACHINES_API}/apps", + headers=self._headers, + params={"org_slug": self.org_slug}, + timeout=15, + ) + if not response.ok: + logger.warning(f"Fly.io list_apps failed ({response.status_code})") + return None + data = response.json() + return data if isinstance(data, list) else data.get("apps", []) + except Exception as e: + logger.error(f"Fly.io list_apps error: {e}") + return None + + def has_write_access(self) -> bool: + """Probe whether the token has write access by attempting an invalid app create.""" + try: + response = requests.post( + f"{FLYIO_MACHINES_API}/apps", + headers=self._headers, + json={"app_name": "", "org_slug": self.org_slug}, + timeout=10, + ) + return response.status_code in (400, 422) + except Exception as e: + logger.warning(f"Fly.io write access probe failed: {e}") + return False + + def query_prometheus(self, query: str, time_param: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Execute a PromQL instant query against the Fly.io Prometheus federation. + + Args: + query: PromQL expression (e.g. 'fly_instance_up{app="myapp"}') + time_param: Optional RFC3339 or Unix timestamp for evaluation time + """ + try: + params: Dict[str, str] = {"query": query} + if time_param: + params["time"] = time_param + + response = requests.get( + f"{FLYIO_PROMETHEUS_API}/{self.org_slug}/api/v1/query", + headers=self._headers, + params=params, + timeout=20, + ) + if not response.ok: + logger.warning(f"Fly.io prometheus query failed ({response.status_code}): {query[:100]}") + return None + return response.json() + except Exception as e: + logger.error(f"Fly.io prometheus query error: {e}") + return None diff --git a/server/connectors/flyio_connector/auth.py b/server/connectors/flyio_connector/auth.py new file mode 100644 index 000000000..0a65f1feb --- /dev/null +++ b/server/connectors/flyio_connector/auth.py @@ -0,0 +1,46 @@ +""" +Fly.io API token validation and permission tier detection. + +Supports org-scoped tokens: +- Read-only tokens (fly tokens create readonly -o ) +- Full org deploy tokens (fly tokens create org -o ) +""" + +import logging +from typing import Dict, Optional, Tuple + +from connectors.flyio_connector.api_client import FlyioClient + +logger = logging.getLogger(__name__) + + +def validate_flyio_token(api_token: str, org_slug: str) -> Tuple[bool, Optional[Dict], Optional[str]]: + """ + Validate a Fly.io API token and detect its permission tier. + + Uses FlyioClient.list_apps() to verify the token works and retrieve the + app list that gets shown on the frontend. Then probes a write endpoint + to determine the tier. + """ + if not api_token or not org_slug: + return False, None, "API token and organization slug are required" + + api_token = api_token.strip() + org_slug = org_slug.strip().lower() + + client = FlyioClient(api_token, org_slug) + apps = client.list_apps() + + if apps is None: + return False, None, "Failed to connect to Fly.io. Check your token and org slug." + + tier = "full" if client.has_write_access() else "readonly" + app_names = [a.get("name", a.get("id", "unknown")) for a in apps] + + token_info = { + "org_slug": org_slug, + "tier": tier, + "app_names": app_names, + } + + return True, token_info, None diff --git a/server/main_compute.py b/server/main_compute.py index 808f977db..9009a4bd0 100644 --- a/server/main_compute.py +++ b/server/main_compute.py @@ -598,6 +598,10 @@ def enforce_user_org_binding(): from routes.cloudflare import cloudflare_bp app.register_blueprint(cloudflare_bp, url_prefix="/cloudflare_api") +# --- Fly.io Routes --- +from routes.flyio import flyio_bp +app.register_blueprint(flyio_bp, url_prefix="/flyio_api") + from routes.terraform import terraform_workspace_bp app.register_blueprint(terraform_workspace_bp) diff --git a/server/routes/flyio/__init__.py b/server/routes/flyio/__init__.py new file mode 100644 index 000000000..4d054ceb1 --- /dev/null +++ b/server/routes/flyio/__init__.py @@ -0,0 +1,17 @@ +"""Fly.io API routes.""" + +from flask import Blueprint, request +from utils.web.cors_utils import create_cors_response + +flyio_bp = Blueprint('flyio', __name__) + + +@flyio_bp.before_request +def handle_options_request(): + """Handle CORS preflight OPTIONS requests for all Fly.io routes.""" + if request.method == 'OPTIONS': + return create_cors_response() + return None + + +from . import flyio_routes # noqa: E402, F401 diff --git a/server/routes/flyio/flyio_routes.py b/server/routes/flyio/flyio_routes.py new file mode 100644 index 000000000..b39835669 --- /dev/null +++ b/server/routes/flyio/flyio_routes.py @@ -0,0 +1,147 @@ +""" +Fly.io API Routes - Authentication, Status, and Disconnect + +Provides endpoints for: +1. Connecting a Fly.io organization (API token validation + tier detection + storage) +2. Connection status +3. Disconnect + +Security: +- API token is stored in HashiCorp Vault (not in database) +- Only a secret reference is stored in the database +- Permission tier auto-detected (readonly vs full) +""" + +import logging +import re +from flask import request, jsonify + +from routes.flyio import flyio_bp +from utils.auth.rbac_decorators import require_permission +from utils.auth.token_management import store_tokens_in_db, get_token_data +from utils.secrets.secret_ref_utils import delete_user_secret +from utils.db.connection_utils import set_connection_status +from utils.web.limiter_ext import limiter +from connectors.flyio_connector.auth import validate_flyio_token +from connectors.flyio_connector.api_client import FlyioClient + +logger = logging.getLogger(__name__) + + +@flyio_bp.route('/flyio/connect', methods=['POST']) +@limiter.limit("10 per minute;50 per hour") +@require_permission("connectors", "write") +def flyio_connect(user_id): + """ + Connect a Fly.io organization using an API token. + + Request body: + { + "apiToken": "Fly.io org-scoped API token", + "orgSlug": "organization slug (e.g. 'personal' or 'my-company')" + } + """ + try: + data = request.get_json() or {} + + api_token = data.get('apiToken') + org_slug = data.get('orgSlug') + + if not api_token or not org_slug: + return jsonify({"error": "API token and organization slug are required"}), 400 + + api_token = api_token.strip() + org_slug = org_slug.strip().lower() + + match = re.search(r'fly\.io/dashboard/([^/?#]+)', org_slug) + if match: + org_slug = match.group(1) + + logger.info(f"Fly.io connect attempt for user {user_id}, org: {org_slug}") + + success, token_info, error = validate_flyio_token(api_token, org_slug) + if not success: + logger.warning(f"Fly.io credential validation failed for user {user_id}: {error}") + return jsonify({"error": error}), 401 + + token_data = { + "api_token": api_token, + "org_slug": org_slug, + "tier": token_info["tier"], + "app_names": token_info["app_names"], + } + + store_tokens_in_db(user_id, token_data, "flyio") + set_connection_status(user_id, "flyio", org_slug, "connected") + + logger.info(f"Fly.io connected for user {user_id}, org: {org_slug}, tier: {token_info['tier']}, apps: {len(token_info['app_names'])}") + + return jsonify({ + "org_slug": org_slug, + "tier": token_info["tier"], + "app_names": token_info["app_names"], + }), 200 + + except Exception as e: + logger.error(f"Fly.io connect error for user {user_id}: {e}", exc_info=True) + return jsonify({"error": "An unexpected error occurred. Please try again."}), 500 + + +@flyio_bp.route('/flyio/status', methods=['GET']) +@require_permission("connectors", "read") +def flyio_status(user_id): + """ + Check Fly.io connection status. + + Pass ?validate=true to do a live check against the Fly.io API + (used on the manage page). Without it, reads from stored data (fast). + """ + try: + token_data = get_token_data(user_id, "flyio") + if not token_data: + return jsonify({"connected": False}), 200 + + org_slug = token_data.get("org_slug") + tier = token_data.get("tier", "readonly") + app_names = token_data.get("app_names", []) + + if request.args.get("validate", "").lower() == "true": + api_token = token_data.get("api_token") + if not api_token or not org_slug: + return jsonify({"connected": False}), 200 + + apps = FlyioClient(api_token, org_slug).list_apps() + if apps is None: + return jsonify({"connected": False, "reason": "token_invalid"}), 200 + + app_names = [a.get("name", a.get("id", "unknown")) for a in apps] + + return jsonify({ + "connected": True, + "org_slug": org_slug, + "tier": tier, + "app_names": app_names, + }), 200 + + except Exception as e: + logger.error(f"Fly.io status check error for user {user_id}: {e}") + return jsonify({"connected": False}), 200 + + +@flyio_bp.route('/flyio/disconnect', methods=['DELETE']) +@require_permission("connectors", "write") +def flyio_disconnect(user_id): + """Disconnect Fly.io integration.""" + try: + token_data = get_token_data(user_id, "flyio") + org_slug = token_data.get("org_slug", "unknown") if token_data else "unknown" + + delete_user_secret(user_id, "flyio") + set_connection_status(user_id, "flyio", org_slug, "disconnected") + + logger.info(f"Fly.io disconnected for user {user_id}") + return jsonify({"success": True, "message": "Fly.io disconnected successfully"}), 200 + + except Exception as e: + logger.error(f"Fly.io disconnect error for user {user_id}: {e}", exc_info=True) + return jsonify({"error": "Failed to disconnect. Please try again."}), 500 diff --git a/server/utils/providers.py b/server/utils/providers.py index 98e84ccd9..26886ac67 100644 --- a/server/utils/providers.py +++ b/server/utils/providers.py @@ -20,6 +20,7 @@ "coroot", "datadog", "dynatrace", + "flyio", "gcp", "github", "gitlab", diff --git a/server/utils/secrets/secret_ref_utils.py b/server/utils/secrets/secret_ref_utils.py index cf0fb94ff..ae87c8120 100644 --- a/server/utils/secrets/secret_ref_utils.py +++ b/server/utils/secrets/secret_ref_utils.py @@ -70,6 +70,7 @@ "notion", # Notion (documentation platform) "google", # Google Chat — provider is "google_chat", split('_')[0] matches this "incidentio", # incident.io connector tokens + "flyio", # Fly.io connector tokens } From e3fe7f3c753a95eb0cd3ac773978742565b23a0f Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 11:34:29 -0400 Subject: [PATCH 2/8] address comments --- server/Dockerfile | 10 +--------- server/chat/backend/agent/tools/cloud_exec_tool.py | 8 ++++---- server/chat/backend/agent/tools/flyio_tool.py | 2 +- server/main_compute.py | 4 ++++ 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index dbf8a1fc2..33f2c26ea 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -207,15 +207,7 @@ RUN ARCH=$(uname -m) && \ RUN curl -fsSL https://tailscale.com/install.sh | sh # Install Fly.io CLI (flyctl) for Fly.io connector -RUN ARCH=$(uname -m) && \ - if [ "$ARCH" = "x86_64" ]; then \ - FLY_ARCH="x86_64"; \ - elif [ "$ARCH" = "aarch64" ]; then \ - FLY_ARCH="arm64"; \ - else \ - echo "Unsupported architecture: $ARCH" && exit 1; \ - fi && \ - curl -fsSL "https://fly.io/install.sh" | FLYCTL_INSTALL="/usr/local" sh +RUN curl -fsSL "https://fly.io/install.sh" | FLYCTL_INSTALL="/usr/local" sh # Install Node.js for MCP servers RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py index f93bfce8d..b624d99cf 100644 --- a/server/chat/backend/agent/tools/cloud_exec_tool.py +++ b/server/chat/backend/agent/tools/cloud_exec_tool.py @@ -1124,13 +1124,13 @@ def setup_flyio_environment_isolated(user_id: str, selected_org: str | None = No "FLY_API_TOKEN": api_token, } - logger.info(f"Fly.io isolated environment configured (org: {org_slug})") - logger.info(f"TIME: setup_flyio_environment_isolated completed in {time.perf_counter() - fn_start:.2f}s") + logger.info("Fly.io isolated environment configured (org: %s)", org_slug) + logger.info("TIME: setup_flyio_environment_isolated completed in %.2fs", time.perf_counter() - fn_start) return True, org_slug, "api_token", isolated_env except Exception as e: - logger.error(f"Failed to setup Fly.io environment: {e}") + logger.exception("Failed to setup Fly.io environment") return False, None, None, None @@ -1466,7 +1466,7 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi # Fly.io isolated setup - uses FLY_API_TOKEN env var success, org_slug, auth_method, isolated_env = setup_flyio_environment_isolated(user_id, selected_project_id) if not success: - return json.dumps({"error": f"Failed to setup Fly.io environment. Please connect your Fly.io account first.", "final_command": command, "requires_connection": True}) + return json.dumps({"error": "Failed to setup Fly.io environment. Please connect your Fly.io account first.", "final_command": command, "requires_connection": True}) resource_id = org_slug elif normalized_provider not in CLOUD_EXEC_PROVIDERS: return json.dumps({ diff --git a/server/chat/backend/agent/tools/flyio_tool.py b/server/chat/backend/agent/tools/flyio_tool.py index 31fd33313..a3343bc6d 100644 --- a/server/chat/backend/agent/tools/flyio_tool.py +++ b/server/chat/backend/agent/tools/flyio_tool.py @@ -33,7 +33,7 @@ class FlyioMetricsQueryArgs(BaseModel): time: Optional[str] = Field(default=None, description="Evaluation time (RFC3339 or Unix timestamp). Defaults to now.") -def query_flyio_metrics(query: str, time: Optional[str] = None, user_id: str = None) -> str: +def query_flyio_metrics(query: str, time: Optional[str] = None, user_id: str = None, session_id: str = None) -> str: """Query Fly.io Prometheus metrics endpoint.""" token_data = get_token_data(user_id, "flyio") if not token_data: diff --git a/server/main_compute.py b/server/main_compute.py index 9009a4bd0..e049f3beb 100644 --- a/server/main_compute.py +++ b/server/main_compute.py @@ -140,6 +140,10 @@ "allow_headers": ["Content-Type", "X-Provider", "X-Requested-With", "X-User-ID", "Authorization", "X-Provider-Preference"], "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"]}, + r"/flyio_api/*": {"origins": FRONTEND_URL, "supports_credentials": True, + "allow_headers": ["Content-Type", "X-Provider", "X-Requested-With", "X-User-ID", + "Authorization", "X-Provider-Preference"], + "methods": ["GET", "POST", "DELETE", "OPTIONS"]}, r"/api/ssh-keys*": {"origins": FRONTEND_URL, "supports_credentials": True, "allow_headers": ["Content-Type", "X-Provider", "X-Requested-With", "X-User-ID", "Authorization", "X-Provider-Preference"], From ca987ce1b48a9e43e1d268ecdcf95c47ffb14884 Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 13:12:54 -0400 Subject: [PATCH 3/8] Fix bugs --- client/src/components/tool-calls/CommandLogo.tsx | 13 +++++++++++++ .../components/tool-calls/ToolExecutionWidget.tsx | 9 +++++++++ .../components/tool-calls/tool-command-parser.ts | 3 ++- .../agent/skills/integrations/flyio/SKILL.md | 6 +++--- server/chat/backend/agent/tools/cloud_exec_tool.py | 5 +++++ server/connectors/flyio_connector/api_client.py | 4 +++- server/utils/auth/command_policy.py | 9 +++++++++ 7 files changed, 44 insertions(+), 5 deletions(-) diff --git a/client/src/components/tool-calls/CommandLogo.tsx b/client/src/components/tool-calls/CommandLogo.tsx index 65d98b643..efe05c539 100644 --- a/client/src/components/tool-calls/CommandLogo.tsx +++ b/client/src/components/tool-calls/CommandLogo.tsx @@ -116,6 +116,14 @@ const logos = { onError={(e) => console.error('Failed to load Tailscale logo:', e)} /> ), + flyio: ( + Fly.io console.error('Failed to load Fly.io logo:', e)} + /> + ), splunk: ( { if (provider === 'gcp') return 'gcloud' if (provider === 'aws') return 'aws' if (provider === 'azure') return 'az' + if (provider === 'flyio') return 'fly' return '' } // Helper: Check if command already has recognized CLI prefix -const RECOGNIZED_CLI_REGEX = /^(gcloud|kubectl|gsutil|bq|aws|az)\b/i +const RECOGNIZED_CLI_REGEX = /^(gcloud|kubectl|gsutil|bq|aws|az|fly|flyctl)\b/i export function extractIacAction(toolInput?: string, fallback?: string): string | undefined { if (!toolInput) return fallback diff --git a/server/chat/backend/agent/skills/integrations/flyio/SKILL.md b/server/chat/backend/agent/skills/integrations/flyio/SKILL.md index 4137a6a6d..cd8aa8480 100644 --- a/server/chat/backend/agent/skills/integrations/flyio/SKILL.md +++ b/server/chat/backend/agent/skills/integrations/flyio/SKILL.md @@ -29,12 +29,12 @@ Fly.io is connected for application monitoring, machine lifecycle management, an - Use `cloud_exec('flyio', '')` for all Fly.io CLI operations. - The CLI (`flyctl`) is pre-authenticated with the user's org-scoped token. - Always pass `--json` flag for structured output when available. -- Always include `-a ` in commands that target a specific app. ### Prometheus metrics Use `query_flyio_metrics(query)` for PromQL queries against Fly.io's Prometheus federation. Common metrics include (but are not limited to) `fly_instance_up`, `fly_instance_cpu`, `fly_instance_memory_resident`, `fly_edge_http_responses_count`, `fly_edge_http_response_time_seconds_bucket`, `fly_instance_net_recv_bytes`, `fly_app_concurrency`. ### Critical rules - Always use `cloud_exec('flyio', ...)` -- never call the REST API directly. -- Always include `-a ` in commands that target a specific app. -- Always pass `--json` for structured output when available. +- Use `-a ` for commands that target a specific app (e.g. `fly status -a myapp`). Do NOT use `-a` on global commands like `fly apps list`, `fly regions list`. +- Pass `--json` for structured output when available (not all commands support it -- if it fails, retry without). +- For logs, always use `--no-tail` to avoid indefinite streaming (e.g. `fly logs -a myapp --no-tail`). diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py index b624d99cf..273d2b67d 100644 --- a/server/chat/backend/agent/tools/cloud_exec_tool.py +++ b/server/chat/backend/agent/tools/cloud_exec_tool.py @@ -1687,6 +1687,9 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi elif provider.lower() == 'scaleway': supported_cli_tools = ['scw', 'kubectl', 'helm', 'terraform'] default_cli = 'scw' + elif provider.lower() == 'flyio': + supported_cli_tools = ['fly', 'flyctl'] + default_cli = 'fly' else: supported_cli_tools = [] default_cli = '' @@ -1707,6 +1710,8 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi command = f"ovhcloud {command}" elif provider.lower() == 'scaleway' and cli_tool == 'scw' and not terraform_invocation and not command.strip().startswith('scw'): command = f"scw {command}" + elif provider.lower() == 'flyio' and cli_tool == 'fly' and not command.strip().startswith(('fly ', 'flyctl ')): + command = f"fly {command}" # Apply provider-specific convenience flags if provider.lower() in ['gcp', 'gcloud'] and cli_tool == 'gcloud': diff --git a/server/connectors/flyio_connector/api_client.py b/server/connectors/flyio_connector/api_client.py index b71d2b4e4..4664e38c2 100644 --- a/server/connectors/flyio_connector/api_client.py +++ b/server/connectors/flyio_connector/api_client.py @@ -21,8 +21,10 @@ class FlyioClient: def __init__(self, api_token: str, org_slug: str): self.api_token = api_token self.org_slug = org_slug + + auth_value = api_token if api_token.startswith("FlyV1") else f"Bearer {api_token}" self._headers = { - "Authorization": f"Bearer {api_token}", + "Authorization": auth_value, "Content-Type": "application/json", } diff --git a/server/utils/auth/command_policy.py b/server/utils/auth/command_policy.py index 0e3003b8a..c847a24ff 100644 --- a/server/utils/auth/command_policy.py +++ b/server/utils/auth/command_policy.py @@ -669,6 +669,9 @@ def get_policy_templates() -> List[dict]: # Tailscale {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b", "description": "Tailscale read-only operations"}, + # Fly.io + {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machines?\s+(list|status)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+show|regions|platform)\b", + "description": "Fly.io read-only operations"}, # Terraform / IaC read-only {"priority": 130, "pattern": r"^(terraform|tofu)\s+(init|plan|validate|fmt|output|show|state\s+(list|show|pull)|version)\b", "description": "Non-destructive Terraform operations"}, @@ -749,6 +752,9 @@ def get_policy_templates() -> List[dict]: # Tailscale {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b", "description": "Tailscale read-only operations"}, + # Fly.io + {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machines?\s+(list|status)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+show|regions|platform)\b", + "description": "Fly.io read-only operations"}, # SSH access {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s", "description": "SSH, SCP, and SFTP access"}, @@ -812,6 +818,9 @@ def get_policy_templates() -> List[dict]: # Tailscale {"priority": 140, "pattern": r"^tailscale\s+\w", "description": "All Tailscale operations"}, + # Fly.io + {"priority": 139, "pattern": r"^(fly|flyctl)\s+\w", + "description": "All Fly.io operations"}, # SSH {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s", "description": "SSH, SCP, and SFTP access"}, From f4ce978cfb4f7f4d414874abf624d44ad1a11d69 Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 13:35:18 -0400 Subject: [PATCH 4/8] address comments --- server/chat/backend/agent/tools/cloud_exec_tool.py | 5 +++-- server/chat/backend/agent/tools/flyio_tool.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py index 273d2b67d..74594f5d0 100644 --- a/server/chat/backend/agent/tools/cloud_exec_tool.py +++ b/server/chat/backend/agent/tools/cloud_exec_tool.py @@ -1129,7 +1129,7 @@ def setup_flyio_environment_isolated(user_id: str, selected_org: str | None = No return True, org_slug, "api_token", isolated_env - except Exception as e: + except Exception: logger.exception("Failed to setup Fly.io environment") return False, None, None, None @@ -1395,7 +1395,8 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi # Prepend CLI prefix so patterns like ^aws\s+ match (cloud_exec receives # the subcommand without the provider prefix, e.g. "ecs list-clusters"). _CLI_PREFIX = {"aws": "aws", "gcp": "gcloud", "azure": "az", - "scaleway": "scw", "ovh": "ovhcloud", "tailscale": "tailscale"} + "scaleway": "scw", "ovh": "ovhcloud", "tailscale": "tailscale", + "flyio": "fly"} prefix = _CLI_PREFIX.get(provider.lower(), "") gated_cmd = f"{prefix} {command}" if prefix and not command.strip().startswith(prefix) else command from utils.auth.command_gate import gate_command diff --git a/server/chat/backend/agent/tools/flyio_tool.py b/server/chat/backend/agent/tools/flyio_tool.py index a3343bc6d..3b4d99f72 100644 --- a/server/chat/backend/agent/tools/flyio_tool.py +++ b/server/chat/backend/agent/tools/flyio_tool.py @@ -33,7 +33,7 @@ class FlyioMetricsQueryArgs(BaseModel): time: Optional[str] = Field(default=None, description="Evaluation time (RFC3339 or Unix timestamp). Defaults to now.") -def query_flyio_metrics(query: str, time: Optional[str] = None, user_id: str = None, session_id: str = None) -> str: +def query_flyio_metrics(query: str, time: Optional[str] = None, user_id: str = None, **kwargs) -> str: """Query Fly.io Prometheus metrics endpoint.""" token_data = get_token_data(user_id, "flyio") if not token_data: From f8fa1b244a002237dd6defa20604048f90898e80 Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 21:15:29 -0400 Subject: [PATCH 5/8] Address comments --- client/src/app/flyio/auth/page.tsx | 102 ++++++++++++------ server/Dockerfile | 3 +- .../backend/agent/tools/cloud_exec_tool.py | 9 +- server/connectors/flyio_connector/auth.py | 7 +- server/routes/flyio/__init__.py | 12 +-- server/routes/flyio/flyio_routes.py | 21 ++-- server/utils/auth/command_policy.py | 6 +- 7 files changed, 104 insertions(+), 56 deletions(-) diff --git a/client/src/app/flyio/auth/page.tsx b/client/src/app/flyio/auth/page.tsx index 2611d583b..531550a98 100644 --- a/client/src/app/flyio/auth/page.tsx +++ b/client/src/app/flyio/auth/page.tsx @@ -11,11 +11,16 @@ import { useToast } from "@/hooks/use-toast"; import { providerPreferencesService } from '@/lib/services/providerPreferences'; import ConnectorAuthGuard from "@/components/connectors/ConnectorAuthGuard"; +interface FlyioApp { + name: string; + status: string; +} + interface FlyioStatus { connected: boolean; org_slug?: string; tier?: "readonly" | "full"; - app_names?: string[]; + apps?: FlyioApp[]; } export default function FlyioAuthPage() { @@ -28,31 +33,56 @@ export default function FlyioAuthPage() { const [status, setStatus] = useState(null); const { toast } = useToast(); - const checkStatus = useCallback(async () => { - setIsCheckingStatus(true); - try { - const response = await fetch(`/api/proxy/flyio/status?validate=true`); - if (response.ok) { - const data = await response.json(); - setStatus({ - connected: data.connected === true, - org_slug: data.org_slug, - tier: data.tier, - app_names: data.app_names, - }); - } else { - setStatus({ connected: false }); - } - } catch { - setStatus({ connected: false }); - } finally { - setIsCheckingStatus(false); + const applyStatusResponse = useCallback((data: { connected?: boolean; org_slug?: string; tier?: string; apps?: FlyioApp[] }) => { + const connected = data.connected === true; + if (connected) { + localStorage.setItem("isFlyioConnected", "true"); + } else { + localStorage.removeItem("isFlyioConnected"); } + setStatus({ + connected, + org_slug: data.org_slug, + tier: data.tier as FlyioStatus["tier"], + apps: data.apps, + }); }, []); useEffect(() => { - checkStatus(); - }, [checkStatus]); + let cancelled = false; + + async function loadStatus() { + // Fast cached read -- renders UI immediately + try { + const cached = await fetch(`/api/proxy/flyio/status`); + if (!cancelled && cached.ok) { + const data = await cached.json(); + applyStatusResponse(data); + } + } catch { /* ignore */ } + setIsCheckingStatus(false); + + // Background live validation -- silently corrects if token expired + try { + const validated = await fetch(`/api/proxy/flyio/status?validate=true`); + if (!cancelled && validated.ok) { + const data = await validated.json(); + applyStatusResponse(data); + } else if (!cancelled) { + localStorage.removeItem("isFlyioConnected"); + setStatus({ connected: false }); + } + } catch { + if (!cancelled) { + localStorage.removeItem("isFlyioConnected"); + setStatus({ connected: false }); + } + } + } + + loadStatus(); + return () => { cancelled = true; }; + }, [applyStatusResponse]); const handleConnect = async (e: React.FormEvent) => { e.preventDefault(); @@ -85,7 +115,7 @@ export default function FlyioAuthPage() { toast({ title: "Fly.io Connected", - description: `Connected to org "${data.org_slug}" with ${data.tier === "readonly" ? "read-only" : "full"} access. Found ${data.app_names?.length ?? 0} app(s).`, + description: `Connected to org "${data.org_slug}" with ${data.tier === "readonly" ? "read-only" : "full"} access. Found ${data.apps?.length ?? 0} app(s).`, }); setApiToken(""); @@ -94,7 +124,7 @@ export default function FlyioAuthPage() { connected: true, org_slug: data.org_slug, tier: data.tier, - app_names: data.app_names, + apps: data.apps, }); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : 'Failed to connect to Fly.io'; @@ -190,16 +220,28 @@ export default function FlyioAuthPage() { - {status.app_names && status.app_names.length > 0 && ( + {status.apps && status.apps.length > 0 && (

- Apps ({status.app_names.length}) + Apps ({status.apps.length})

- {status.app_names.map((name) => ( -
- - {name} + {status.apps.map((app) => ( +
+
+ + {app.name} +
+ + {app.status} +
))}
diff --git a/server/Dockerfile b/server/Dockerfile index 33f2c26ea..edef95e45 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -207,7 +207,8 @@ RUN ARCH=$(uname -m) && \ RUN curl -fsSL https://tailscale.com/install.sh | sh # Install Fly.io CLI (flyctl) for Fly.io connector -RUN curl -fsSL "https://fly.io/install.sh" | FLYCTL_INSTALL="/usr/local" sh +ARG FLYCTL_VERSION=0.4.57 +RUN curl -fsSL "https://fly.io/install.sh" | FLYCTL_INSTALL="/usr/local" FLYCTL_VERSION=${FLYCTL_VERSION} sh # Install Node.js for MCP servers RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py index 74594f5d0..df25bcf0d 100644 --- a/server/chat/backend/agent/tools/cloud_exec_tool.py +++ b/server/chat/backend/agent/tools/cloud_exec_tool.py @@ -1404,9 +1404,16 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi if not gate.allowed: logger.warning("cloud_exec blocked for user %s (%s): %s", user_id, gate.code, gate.block_reason[:200]) + error_msg = gate.block_reason + if gate.code == "POLICY_DENIED" and "No matching allow rule" in error_msg: + error_msg += ( + f". To allow {prefix or provider} commands, go to Settings > Security > " + "Command Policies and re-apply the 'Observability Only' or " + "'Standard Operations' template, or manually add an allow rule." + ) return json.dumps({ "success": False, - "error": gate.block_reason, + "error": error_msg, "code": gate.code, "final_command": command, "provider": provider.lower(), diff --git a/server/connectors/flyio_connector/auth.py b/server/connectors/flyio_connector/auth.py index 0a65f1feb..d5b5027af 100644 --- a/server/connectors/flyio_connector/auth.py +++ b/server/connectors/flyio_connector/auth.py @@ -35,12 +35,15 @@ def validate_flyio_token(api_token: str, org_slug: str) -> Tuple[bool, Optional[ return False, None, "Failed to connect to Fly.io. Check your token and org slug." tier = "full" if client.has_write_access() else "readonly" - app_names = [a.get("name", a.get("id", "unknown")) for a in apps] + apps_info = [ + {"name": a.get("name", a.get("id", "unknown")), "status": a.get("status", "unknown")} + for a in apps + ] token_info = { "org_slug": org_slug, "tier": tier, - "app_names": app_names, + "apps": apps_info, } return True, token_info, None diff --git a/server/routes/flyio/__init__.py b/server/routes/flyio/__init__.py index 4d054ceb1..7ca293144 100644 --- a/server/routes/flyio/__init__.py +++ b/server/routes/flyio/__init__.py @@ -1,17 +1,7 @@ """Fly.io API routes.""" -from flask import Blueprint, request -from utils.web.cors_utils import create_cors_response +from flask import Blueprint flyio_bp = Blueprint('flyio', __name__) - -@flyio_bp.before_request -def handle_options_request(): - """Handle CORS preflight OPTIONS requests for all Fly.io routes.""" - if request.method == 'OPTIONS': - return create_cors_response() - return None - - from . import flyio_routes # noqa: E402, F401 diff --git a/server/routes/flyio/flyio_routes.py b/server/routes/flyio/flyio_routes.py index b39835669..f95221b61 100644 --- a/server/routes/flyio/flyio_routes.py +++ b/server/routes/flyio/flyio_routes.py @@ -68,18 +68,18 @@ def flyio_connect(user_id): "api_token": api_token, "org_slug": org_slug, "tier": token_info["tier"], - "app_names": token_info["app_names"], + "apps": token_info["apps"], } store_tokens_in_db(user_id, token_data, "flyio") set_connection_status(user_id, "flyio", org_slug, "connected") - logger.info(f"Fly.io connected for user {user_id}, org: {org_slug}, tier: {token_info['tier']}, apps: {len(token_info['app_names'])}") + logger.info(f"Fly.io connected for user {user_id}, org: {org_slug}, tier: {token_info['tier']}, apps: {len(token_info['apps'])}") return jsonify({ "org_slug": org_slug, "tier": token_info["tier"], - "app_names": token_info["app_names"], + "apps": token_info["apps"], }), 200 except Exception as e: @@ -103,24 +103,29 @@ def flyio_status(user_id): org_slug = token_data.get("org_slug") tier = token_data.get("tier", "readonly") - app_names = token_data.get("app_names", []) + apps = token_data.get("apps", []) if request.args.get("validate", "").lower() == "true": api_token = token_data.get("api_token") if not api_token or not org_slug: return jsonify({"connected": False}), 200 - apps = FlyioClient(api_token, org_slug).list_apps() - if apps is None: + live_apps = FlyioClient(api_token, org_slug).list_apps() + if live_apps is None: + delete_user_secret(user_id, "flyio") + set_connection_status(user_id, "flyio", org_slug, "disconnected") return jsonify({"connected": False, "reason": "token_invalid"}), 200 - app_names = [a.get("name", a.get("id", "unknown")) for a in apps] + apps = [ + {"name": a.get("name", a.get("id", "unknown")), "status": a.get("status", "unknown")} + for a in live_apps + ] return jsonify({ "connected": True, "org_slug": org_slug, "tier": tier, - "app_names": app_names, + "apps": apps, }), 200 except Exception as e: diff --git a/server/utils/auth/command_policy.py b/server/utils/auth/command_policy.py index c847a24ff..5c8fd5177 100644 --- a/server/utils/auth/command_policy.py +++ b/server/utils/auth/command_policy.py @@ -670,7 +670,7 @@ def get_policy_templates() -> List[dict]: {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b", "description": "Tailscale read-only operations"}, # Fly.io - {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machines?\s+(list|status)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+show|regions|platform)\b", + {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machine\s+(list|status)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+show|platform|services)\b", "description": "Fly.io read-only operations"}, # Terraform / IaC read-only {"priority": 130, "pattern": r"^(terraform|tofu)\s+(init|plan|validate|fmt|output|show|state\s+(list|show|pull)|version)\b", @@ -753,8 +753,8 @@ def get_policy_templates() -> List[dict]: {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b", "description": "Tailscale read-only operations"}, # Fly.io - {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machines?\s+(list|status)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+show|regions|platform)\b", - "description": "Fly.io read-only operations"}, + {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machine\s+(list|status|restart|stop|start)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+(show|count)|platform|ssh|proxy|config\s+show|secrets\s+list|deploy|services)\b", + "description": "Fly.io standard operations"}, # SSH access {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s", "description": "SSH, SCP, and SFTP access"}, From 8c374d37ce41d64c5af26976cddf358019996787 Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 21:17:48 -0400 Subject: [PATCH 6/8] done --- client/src/app/api/provider-preferences/route.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/app/api/provider-preferences/route.ts b/client/src/app/api/provider-preferences/route.ts index 98e4c9b37..260978e6c 100644 --- a/client/src/app/api/provider-preferences/route.ts +++ b/client/src/app/api/provider-preferences/route.ts @@ -50,7 +50,7 @@ export async function GET() { } // Ensure it's an array of valid providers - const validProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare']; + const validProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare', 'flyio']; if (isOvhEnabled()) { validProviders.push('ovh'); } @@ -93,7 +93,7 @@ export async function POST(request: NextRequest) { const { providers, action = 'set', provider } = body; // Validate input - const validProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare']; + const validProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare', 'flyio']; if (isOvhEnabled()) { validProviders.push('ovh'); } @@ -181,7 +181,7 @@ export async function POST(request: NextRequest) { } // Also track unselected providers for smart auto-select - const allProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare']; + const allProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare', 'flyio']; if (isOvhEnabled()) { allProviders.push('ovh'); } From 6f9a6ef3911f46ec58a5a9279821824be7989e61 Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 22:31:50 -0400 Subject: [PATCH 7/8] address comments --- .../src/app/api/provider-preferences/route.ts | 24 +++++++++---------- server/Dockerfile | 2 +- .../backend/agent/tools/cloud_exec_tool.py | 3 +-- server/utils/auth/command_policy.py | 2 +- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/client/src/app/api/provider-preferences/route.ts b/client/src/app/api/provider-preferences/route.ts index 260978e6c..845bf6cf2 100644 --- a/client/src/app/api/provider-preferences/route.ts +++ b/client/src/app/api/provider-preferences/route.ts @@ -4,6 +4,14 @@ import { isOvhEnabled } from '@/lib/feature-flags'; const API_BASE_URL = process.env.BACKEND_URL +function getValidProviders(): string[] { + const providers = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare', 'flyio']; + if (isOvhEnabled()) { + providers.push('ovh'); + } + return providers; +} + // --------------------------------------------------------------------------- // GET /api/provider-preferences // Gets the user's cloud provider preferences from database @@ -50,11 +58,7 @@ export async function GET() { } // Ensure it's an array of valid providers - const validProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare', 'flyio']; - if (isOvhEnabled()) { - validProviders.push('ovh'); - } - providers = providers.filter(p => validProviders.includes(p)); + providers = providers.filter(p => getValidProviders().includes(p)); return NextResponse.json({ providers, @@ -93,10 +97,7 @@ export async function POST(request: NextRequest) { const { providers, action = 'set', provider } = body; // Validate input - const validProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare', 'flyio']; - if (isOvhEnabled()) { - validProviders.push('ovh'); - } + const validProviders = getValidProviders(); if (action === 'set') { if (!Array.isArray(providers)) { @@ -181,10 +182,7 @@ export async function POST(request: NextRequest) { } // Also track unselected providers for smart auto-select - const allProviders = ['gcp', 'azure', 'aws', 'scaleway', 'tailscale', 'grafana', 'datadog', 'cloudbees', 'newrelic', 'cloudflare', 'flyio']; - if (isOvhEnabled()) { - allProviders.push('ovh'); - } + const allProviders = getValidProviders(); if (action === 'remove' || (action === 'set' && providers.length < allProviders.length)) { const unselectedProviders = allProviders.filter(p => !finalProviders.includes(p)); diff --git a/server/Dockerfile b/server/Dockerfile index edef95e45..2cb6f6a07 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -208,7 +208,7 @@ RUN curl -fsSL https://tailscale.com/install.sh | sh # Install Fly.io CLI (flyctl) for Fly.io connector ARG FLYCTL_VERSION=0.4.57 -RUN curl -fsSL "https://fly.io/install.sh" | FLYCTL_INSTALL="/usr/local" FLYCTL_VERSION=${FLYCTL_VERSION} sh +RUN set -o pipefail && curl -fsSL "https://fly.io/install.sh" | FLYCTL_INSTALL="/usr/local" FLYCTL_VERSION=${FLYCTL_VERSION} sh # Install Node.js for MCP servers RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ diff --git a/server/chat/backend/agent/tools/cloud_exec_tool.py b/server/chat/backend/agent/tools/cloud_exec_tool.py index df25bcf0d..30f7648d4 100644 --- a/server/chat/backend/agent/tools/cloud_exec_tool.py +++ b/server/chat/backend/agent/tools/cloud_exec_tool.py @@ -1408,8 +1408,7 @@ def cloud_exec(provider: str, command: str, user_id: Optional[str] = None, sessi if gate.code == "POLICY_DENIED" and "No matching allow rule" in error_msg: error_msg += ( f". To allow {prefix or provider} commands, go to Settings > Security > " - "Command Policies and re-apply the 'Observability Only' or " - "'Standard Operations' template, or manually add an allow rule." + "Command Policies and re-apply a template or manually add an allow rule." ) return json.dumps({ "success": False, diff --git a/server/utils/auth/command_policy.py b/server/utils/auth/command_policy.py index 5c8fd5177..dd46e3a03 100644 --- a/server/utils/auth/command_policy.py +++ b/server/utils/auth/command_policy.py @@ -753,7 +753,7 @@ def get_policy_templates() -> List[dict]: {"priority": 140, "pattern": r"^tailscale\s+(status|device\s+(list|get)|dns|acl\s+(get|show)|routes|settings|auth-key\s+list)\b", "description": "Tailscale read-only operations"}, # Fly.io - {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machine\s+(list|status|restart|stop|start)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+(show|count)|platform|ssh|proxy|config\s+show|secrets\s+list|deploy|services)\b", + {"priority": 139, "pattern": r"^(fly|flyctl)\s+(apps\s+list|status|machine\s+(list|status|restart|stop|start)|logs|checks|releases|certs\s+list|ips\s+list|volumes?\s+list|scale\s+(show|count)|platform|ssh|proxy|config\s+show|secrets\s+list|services)\b", "description": "Fly.io standard operations"}, # SSH access {"priority": 135, "pattern": r"^(ssh|scp|sftp)\s", From 94648841f2ad2fe56c6fd0db8ad999c92b528179 Mon Sep 17 00:00:00 2001 From: Olivier Trudeau Date: Tue, 2 Jun 2026 23:00:45 -0400 Subject: [PATCH 8/8] adress siddharth comment --- server/chat/backend/agent/tools/flyio_tool.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/chat/backend/agent/tools/flyio_tool.py b/server/chat/backend/agent/tools/flyio_tool.py index 3b4d99f72..7a97bbc0c 100644 --- a/server/chat/backend/agent/tools/flyio_tool.py +++ b/server/chat/backend/agent/tools/flyio_tool.py @@ -12,7 +12,7 @@ from typing import Optional from pydantic import BaseModel, Field -from utils.auth.token_management import get_token_data +from utils.secrets.secret_ref_utils import get_user_token_data from connectors.flyio_connector.api_client import FlyioClient logger = logging.getLogger(__name__) @@ -21,7 +21,7 @@ def is_flyio_connected(user_id: str) -> bool: """Check if Fly.io is connected for a user.""" try: - token_data = get_token_data(user_id, "flyio") + token_data = get_user_token_data(user_id, "flyio") return bool(token_data and "api_token" in token_data) except Exception: return False @@ -35,7 +35,7 @@ class FlyioMetricsQueryArgs(BaseModel): def query_flyio_metrics(query: str, time: Optional[str] = None, user_id: str = None, **kwargs) -> str: """Query Fly.io Prometheus metrics endpoint.""" - token_data = get_token_data(user_id, "flyio") + token_data = get_user_token_data(user_id, "flyio") if not token_data: return json.dumps({"error": "Fly.io not connected. Please connect your Fly.io account first."}) @@ -45,9 +45,13 @@ def query_flyio_metrics(query: str, time: Optional[str] = None, user_id: str = N if not api_token or not org_slug: return json.dumps({"error": "Incomplete Fly.io credentials"}) - client = FlyioClient(api_token, org_slug) + try: + client = FlyioClient(api_token, org_slug) + result = client.query_prometheus(query, time_param=time) + except Exception: + logger.exception("Fly.io Prometheus query failed for: %s", query) + return json.dumps({"error": "Prometheus query failed", "query": query}) - result = client.query_prometheus(query, time_param=time) if result is None: return json.dumps({"error": f"Prometheus query failed for: {query}"})