Add playground backend and proxy

This commit is contained in:
yaoyao-open-source committed 2026-09-10 17:45:46 -07:00
1 parent bac31b44c1
commit 70be3c37e5
43 files changed
+3140 -4

No files matched your search

@@ -0,0 +1 @@
# Services package
@@ -0,0 +1,144 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from datetime import datetime, timezone
import logging
from typing import Optional
from app.config import settings
from app.schemas.session_schema import SessionRecord, SessionStatus
logger = logging.getLogger("artemis.bigquery")
try:
from google.cloud import bigquery
from google.cloud.exceptions import NotFound
BQ_AVAILABLE = True
except ImportError:
BQ_AVAILABLE = False
class BigQueryMappingService:
"""Manages session metadata and user-to-container mapping records in BigQuery."""
def __init__(self):
self._local_cache: dict[str, SessionRecord] = {}
self._client: Optional["bigquery.Client"] = None
self._table_ref: str = f"{settings.GCP_PROJECT_ID}.{settings.BQ_DATASET}.{settings.BQ_TABLE}"
if BQ_AVAILABLE and not settings.USE_LOCAL_FALLBACK_DB:
try:
self._client = bigquery.Client(project=settings.GCP_PROJECT_ID)
self._ensure_dataset_and_table()
logger.info(f"[BigQuery] Connected to BigQuery table: {self._table_ref}")
except Exception as e:
logger.warning(f"[BigQuery] Failed to initialize BigQuery client: {e}. Falling back to in-memory store.")
self._client = None
else:
logger.info("[BigQuery] Using in-memory fallback database for local/test execution.")
def _ensure_dataset_and_table(self):
"""Ensure the destination dataset and partitioned table exist in BigQuery."""
if not self._client:
return
try:
dataset_ref = bigquery.DatasetReference(settings.GCP_PROJECT_ID, settings.BQ_DATASET)
self._client.get_dataset(dataset_ref)
except NotFound:
dataset = bigquery.Dataset(dataset_ref)
dataset.location = "US"
self._client.create_dataset(dataset, timeout=30)
logger.info(f"[BigQuery] Created dataset {settings.BQ_DATASET}")
schema = [
bigquery.SchemaField("user_id", "STRING", mode="REQUIRED"),
bigquery.SchemaField("session_id", "STRING", mode="REQUIRED"),
bigquery.SchemaField("artemis_container_id", "STRING", mode="NULLABLE"),
bigquery.SchemaField("artemis_container_name", "STRING", mode="NULLABLE"),
bigquery.SchemaField("artemis_internal_ip", "STRING", mode="NULLABLE"),
bigquery.SchemaField("cuttlefish_instance_id", "STRING", mode="NULLABLE"),
bigquery.SchemaField("cuttlefish_adb_port", "INTEGER", mode="NULLABLE"),
bigquery.SchemaField("cuttlefish_webrtc_port", "INTEGER", mode="NULLABLE"),
bigquery.SchemaField("status", "STRING", mode="REQUIRED"),
bigquery.SchemaField("created_at", "TIMESTAMP", mode="REQUIRED"),
bigquery.SchemaField("last_heartbeat_at", "TIMESTAMP", mode="REQUIRED"),
bigquery.SchemaField("expires_at", "TIMESTAMP", mode="REQUIRED"),
bigquery.SchemaField("error_message", "STRING", mode="NULLABLE"),
]
table = bigquery.Table(self._table_ref, schema=schema)
table.time_partitioning = bigquery.TimePartitioning(
type_=bigquery.TimePartitioningType.DAY,
field="created_at",
)
try:
self._client.get_table(self._table_ref)
except NotFound:
self._client.create_table(table)
logger.info(f"[BigQuery] Created partitioned table {self._table_ref}")
async def save_session(self, record: SessionRecord) -> None:
"""Upsert a session record in the cache and BigQuery."""
self._local_cache[record.session_id] = record
if self._client:
try:
row = {
"user_id": record.user_id,
"session_id": record.session_id,
"artemis_container_id": record.artemis_container_id,
"artemis_container_name": record.artemis_container_name,
"artemis_internal_ip": record.artemis_internal_ip,
"cuttlefish_instance_id": record.cuttlefish_instance_id,
"cuttlefish_adb_port": record.cuttlefish_adb_port,
"cuttlefish_webrtc_port": record.cuttlefish_webrtc_port,
"status": record.status.value,
"created_at": record.created_at.isoformat(),
"last_heartbeat_at": record.last_heartbeat_at.isoformat(),
"expires_at": record.expires_at.isoformat(),
"error_message": record.error_message,
}
errors = self._client.insert_rows_json(self._table_ref, [row])
if errors:
logger.error(f"[BigQuery] Insert errors for session {record.session_id}: {errors}")
except Exception as e:
logger.error(f"[BigQuery] Streaming insert failed: {e}")
async def get_session(self, session_id: str) -> SessionRecord | None:
"""Retrieve a session record by ID."""
return self._local_cache.get(session_id)
async def list_user_sessions(self, user_id: str) -> list[SessionRecord]:
"""List active/valid sessions belonging to a specific user."""
return [
rec for rec in self._local_cache.values()
if rec.user_id == user_id and rec.status != SessionStatus.TERMINATED
]
async def list_all_active_sessions(self) -> list[SessionRecord]:
"""List all non-terminated sessions for maintenance/reaping."""
return [
rec for rec in self._local_cache.values()
if rec.status in (SessionStatus.PROVISIONING, SessionStatus.ACTIVE, SessionStatus.IDLE)
]
async def update_status(self, session_id: str, status: SessionStatus, error_message: str | None = None) -> None:
"""Update the status of an existing session record."""
record = self._local_cache.get(session_id)
if record:
record.status = status
record.error_message = error_message
await self.save_session(record)
bigquery_service = BigQueryMappingService()
@@ -0,0 +1,192 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import logging
from dataclasses import dataclass
import httpx
from app.config import settings
logger = logging.getLogger("artemis.orchestrator")
@dataclass
class CuttlefishInstanceInfo:
instance_id: str
adb_port: int
webrtc_port: int
host: str
class CloudOrchestratorService:
"""Manages dynamic discovery, pool allocation, on-demand creation, and binding of Cuttlefish instances."""
def __init__(self):
self._lock = asyncio.Lock()
# session_id -> CuttlefishInstanceInfo
self._allocated_instances: dict[str, CuttlefishInstanceInfo] = {}
# emulator_id (e.g. 'cvd-1') -> session_id
self._emulator_bindings: dict[str, str] = {}
async def list_available_emulators(self) -> list[dict]:
"""Query Cloud Orchestrator for all running CVDs that are not currently bound to an active session."""
bound_emulators = set(self._emulator_bindings.keys())
async with httpx.AsyncClient(timeout=5.0) as client:
try:
resp = await client.get(f"{settings.CLOUD_ORCHESTRATOR_URL}/cvds")
if resp.status_code != 200:
logger.error(f"[Cloud Orchestrator] Failed to fetch CVD list: HTTP {resp.status_code} ({resp.text})")
return []
all_cvds = resp.json().get("cvds", [])
available = []
for cvd in all_cvds:
name = cvd.get("name") or cvd.get("webrtc_device_id")
status = cvd.get("status", "")
# Only pick emulators that are in 'Running' state and not bound to another session
if status.lower() == "running" and name not in bound_emulators:
available.append(cvd)
logger.info(
f"[Cloud Orchestrator] CVD Status -> Total: {len(all_cvds)}, "
f"Available: {[c.get('name') for c in available]}, "
f"Bound: {list(self._emulator_bindings.items())}"
)
return available
except Exception as e:
logger.warning(f"[Cloud Orchestrator] Error listing CVDs from orchestrator: {e}")
return []
async def create_new_cuttlefish_emulator(self, instance_name: str, preset: str = "pixel_7_pro") -> dict:
"""Trigger creation of a new CVD via Host Orchestrator and poll until ready."""
logger.info(f"[Cloud Orchestrator] Provisioning new Cuttlefish emulator '{instance_name}' via Host Orchestrator...")
async with httpx.AsyncClient(timeout=10.0) as client:
create_resp = await client.post(
f"{settings.CLOUD_ORCHESTRATOR_URL}/cvds",
json={
"env_config": {
"instances": [
{
"name": instance_name,
"disk": {
"default_build": "@ab/aosp-android-latest-release/aosp_cf_x86_64_only_phone-userdebug"
},
"vm": {
"cpus": 4,
"memory_mb": 4096,
},
}
]
}
},
)
if create_resp.status_code not in (200, 201):
raise RuntimeError(f"Host Orchestrator rejected CVD creation (HTTP {create_resp.status_code}): {create_resp.text}")
op = create_resp.json()
op_name = op.get("name")
logger.info(f"[Cloud Orchestrator] Creation operation started: {op_name}. Waiting for device boot...")
# Poll operation until done
for attempt in range(60): # Poll up to 120 seconds
await asyncio.sleep(2.0)
try:
op_resp = await client.get(f"{settings.CLOUD_ORCHESTRATOR_URL}/operations/{op_name}")
if op_resp.status_code == 200 and op_resp.json().get("done", False):
logger.info(f"[Cloud Orchestrator] Operation {op_name} successfully finished!")
break
except Exception as e:
logger.debug(f"[Cloud Orchestrator] Polling operation {op_name}: {e}")
# Now fetch the newly booted CVD from the orchestrator list
list_resp = await client.get(f"{settings.CLOUD_ORCHESTRATOR_URL}/cvds")
if list_resp.status_code == 200:
for cvd in list_resp.json().get("cvds", []):
if cvd.get("name") == instance_name or instance_name in cvd.get("name", ""):
logger.info(f"[Cloud Orchestrator] Newly provisioned CVD found: {cvd}")
return cvd
raise RuntimeError(f"CVD '{instance_name}' creation operation finished, but device was not found in running CVD list")
async def create_cuttlefish_instance(self, session_id: str, preset: str = "pixel_7_pro") -> CuttlefishInstanceInfo:
"""
Main entrypoint:
1. Find an available running emulator not bound to any active Artemis session.
2. If none available, provision a new emulator via Host Orchestrator.
3. Dynamically extract the chosen emulator's ADB port (no static/default port assumptions).
4. Bind the emulator to the Artemis session.
"""
async with self._lock:
# Check if this session already has an allocated instance
if session_id in self._allocated_instances:
return self._allocated_instances[session_id]
# 1. Get emulator list that is not used by other artemis containers
available = await self.list_available_emulators()
chosen_cvd = None
if available:
chosen_cvd = available[0]
logger.info(f"[Cloud Orchestrator] Reusing existing free emulator '{chosen_cvd.get('name')}' for session {session_id}.")
else:
# 2. If no emulator available, create one
instance_name = f"cvd-{session_id[:8]}"
chosen_cvd = await self.create_new_cuttlefish_emulator(instance_name, preset=preset)
# 4. Extract dynamic ADB port from the chosen emulator
emulator_name = chosen_cvd.get("name") or chosen_cvd.get("webrtc_device_id")
adb_port = chosen_cvd.get("adb_port")
# If adb_port key is absent or null, dynamically parse adb_serial (e.g. '127.0.0.1:6520')
if not adb_port and "adb_serial" in chosen_cvd:
try:
adb_port = int(chosen_cvd["adb_serial"].split(":")[-1])
except (ValueError, IndexError):
pass
if not adb_port:
raise RuntimeError(f"Could not dynamically determine ADB port for emulator '{emulator_name}': {chosen_cvd}")
adb_port = int(adb_port)
logger.info(f"[Cloud Orchestrator] Dynamic ADB port for '{emulator_name}': {adb_port}")
# 3. Bind the emulator to the Artemis session instance
host = settings.CUTTLEFISH_HOST_GATEWAY
info = CuttlefishInstanceInfo(
instance_id=emulator_name,
adb_port=adb_port,
webrtc_port=settings.CUTTLEFISH_START_WEBRTC_PORT,
host=host,
)
self._allocated_instances[session_id] = info
self._emulator_bindings[emulator_name] = session_id
logger.info(f"[Cloud Orchestrator] Successfully bound emulator '{emulator_name}' (port {adb_port}) to session {session_id}.")
return info
async def destroy_cuttlefish_instance(self, session_id: str) -> None:
"""Unbind emulator from session so it returns to the free pool for other Artemis containers."""
async with self._lock:
info = self._allocated_instances.pop(session_id, None)
if not info:
logger.info(f"[Cloud Orchestrator] No active binding found for session {session_id}")
return
self._emulator_bindings.pop(info.instance_id, None)
logger.info(f"[Cloud Orchestrator] Unbound emulator '{info.instance_id}' from session {session_id}. Emulator returned to pool.")
cloud_orchestrator_service = CloudOrchestratorService()
@@ -0,0 +1,174 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import logging
import os
from typing import Optional
from app.config import settings
logger = logging.getLogger("artemis.docker")
try:
import docker
from docker.errors import DockerException, NotFound
DOCKER_AVAILABLE = True
except ImportError:
DOCKER_AVAILABLE = False
class DockerManagerService:
"""Manages creation, monitoring, and deletion of ephemeral Artemis session containers via Docker Socket."""
def __init__(self):
self._client: Optional["docker.DockerClient"] = None
if DOCKER_AVAILABLE:
try:
self._client = docker.DockerClient(base_url=settings.DOCKER_SOCKET_PATH)
logger.info(f"[Docker Service] Connected to Docker socket at {settings.DOCKER_SOCKET_PATH}")
self._ensure_network()
except Exception as e:
logger.warning(f"[Docker Service] Could not connect to Docker socket: {e}. Running in simulation mode.")
self._client = None
else:
logger.warning("[Docker Service] Docker SDK not available; running in simulation mode.")
def _ensure_network(self) -> None:
"""Ensure the shared user-defined bridge network exists."""
if not self._client:
return
try:
self._client.networks.get(settings.DOCKER_NETWORK)
except NotFound:
logger.info(f"[Docker Service] Creating network {settings.DOCKER_NETWORK}")
self._client.networks.create(settings.DOCKER_NETWORK, driver="bridge")
except Exception as e:
logger.warning(f"[Docker Service] Network check warning: {e}")
async def start_artemis_container(
self,
session_id: str,
cuttlefish_host: str,
cuttlefish_adb_port: int,
) -> dict:
"""Spawn a dedicated Artemis session container connected to the target Cuttlefish emulator."""
container_name = f"{settings.ARTEMIS_CONTAINER_PREFIX}-{session_id}"
adb_target = f"{cuttlefish_host}:{cuttlefish_adb_port}"
logger.info(f"[Docker Service] Launching container '{container_name}' targeting ADB device '{adb_target}'...")
if not self._client:
# Simulated mode for environments without raw Docker socket access
await asyncio.sleep(0.5)
return {
"container_id": f"mock-container-{session_id[:8]}",
"container_name": container_name,
"internal_ip": "172.20.0.5",
}
loop = asyncio.get_event_loop()
def _run_container():
# Check if an existing container with same name exists and remove it
try:
old = self._client.containers.get(container_name)
old.remove(force=True)
except NotFound:
pass
# Assemble container environment
env_vars = {
"SESSION_ID": session_id,
"ADB_DEVICE_SERIAL": adb_target,
"PORT": "8080",
"PYTHONUNBUFFERED": "1",
}
# Automatically forward LLM credentials configured on backend
for key in (
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPEN_ROUTER_API_KEY",
"XAI_API_KEY",
"VISION_API_KEY",
"OPENAI_BASE_URL",
):
val = os.environ.get(key)
if val:
env_vars[key] = val
# Launch Artemis container
container = self._client.containers.run(
image=settings.ARTEMIS_IMAGE,
name=container_name,
detach=True,
network=settings.DOCKER_NETWORK,
extra_hosts={"host.docker.internal": "host-gateway"},
environment=env_vars,
labels={
"artemis.managed": "true",
"artemis.session_id": session_id,
},
restart_policy={"Name": "on-failure", "MaximumRetryCount": 3},
)
# Reload to get network IP
container.reload()
networks = container.attrs.get("NetworkSettings", {}).get("Networks", {})
internal_ip = networks.get(settings.DOCKER_NETWORK, {}).get("IPAddress", "")
# Execute adb connect inside the container to establish connection with Cuttlefish
try:
connect_cmd = f"adb connect {adb_target}"
logger.info(f"[Docker Service] Executing '{connect_cmd}' in {container_name}...")
container.exec_run(connect_cmd)
except Exception as e:
logger.warning(f"[Docker Service] adb connect command notice: {e}")
return {
"container_id": container.id,
"container_name": container_name,
"internal_ip": internal_ip,
}
result = await loop.run_in_executor(None, _run_container)
logger.info(f"[Docker Service] Container {container_name} successfully booted.")
return result
async def stop_artemis_container(self, session_id: str) -> None:
"""Stop and remove an Artemis session container."""
container_name = f"{settings.ARTEMIS_CONTAINER_PREFIX}-{session_id}"
logger.info(f"[Docker Service] Stopping and removing container '{container_name}'...")
if not self._client:
return
loop = asyncio.get_event_loop()
def _remove_container():
try:
container = self._client.containers.get(container_name)
container.stop(timeout=5)
container.remove(force=True)
logger.info(f"[Docker Service] Container {container_name} removed.")
except NotFound:
logger.info(f"[Docker Service] Container {container_name} was already removed.")
except Exception as e:
logger.error(f"[Docker Service] Error removing container {container_name}: {e}")
await loop.run_in_executor(None, _remove_container)
docker_manager = DockerManagerService()
@@ -0,0 +1,207 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from datetime import datetime, timedelta, timezone
import logging
import uuid
from app.config import settings
from app.schemas.session_schema import (
CreateSessionRequest,
HeartbeatResponse,
SessionListItem,
SessionRecord,
SessionResponse,
SessionStatus,
)
from app.services.bigquery_service import bigquery_service
from app.services.cloud_orchestrator_service import cloud_orchestrator_service
from app.services.docker_service import docker_manager
logger = logging.getLogger("artemis.session_manager")
class SessionManagerService:
"""Master orchestrator for multi-tenant Artemis session containers and Cuttlefish AVDs."""
def __init__(self):
self._reaper_task: asyncio.Task | None = None
def start_background_reaper(self):
"""Start the background watchdog task to reap expired sessions."""
if not self._reaper_task or self._reaper_task.done():
self._reaper_task = asyncio.create_task(self._reaper_loop())
logger.info("[Session Manager] Background session reaper loop started.")
async def _reaper_loop(self):
"""Periodically scans for expired sessions and reclaims compute resources."""
while True:
try:
await asyncio.sleep(settings.REAPER_CHECK_INTERVAL_SECONDS)
now = datetime.now(timezone.utc)
active_sessions = await bigquery_service.list_all_active_sessions()
for s in active_sessions:
# Check if session has exceeded expiration or missed heartbeats
if s.expires_at < now:
logger.info(f"[Session Reaper] Session {s.session_id} has expired (TTL elapsed). Tearing down...")
await self.terminate_session(s.session_id, user_id=s.user_id, reason="TTL expired")
elif (now - s.last_heartbeat_at).total_seconds() > settings.SESSION_HEARTBEAT_TIMEOUT_SECONDS:
logger.info(f"[Session Reaper] Session {s.session_id} heartbeat timed out. Tearing down...")
await self.terminate_session(s.session_id, user_id=s.user_id, reason="Heartbeat timeout")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"[Session Reaper] Error in reaper loop: {e}")
async def create_session(self, user_id: str, request: CreateSessionRequest) -> SessionResponse:
"""Provision a new Cuttlefish emulator + Artemis container and link them via ADB."""
session_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
expires_at = now + timedelta(minutes=request.ttl_minutes)
logger.info(f"[Session Manager] Starting session creation for user={user_id}, session_id={session_id}...")
# Initialize record with PROVISIONING status
record = SessionRecord(
user_id=user_id,
session_id=session_id,
status=SessionStatus.PROVISIONING,
created_at=now,
last_heartbeat_at=now,
expires_at=expires_at,
)
await bigquery_service.save_session(record)
try:
# 1. Start Cuttlefish AVD via Cloud Orchestrator
cf_info = await cloud_orchestrator_service.create_cuttlefish_instance(
session_id=session_id,
preset=request.device_preset,
)
record.cuttlefish_instance_id = cf_info.instance_id
record.cuttlefish_adb_port = cf_info.adb_port
record.cuttlefish_webrtc_port = cf_info.webrtc_port
# 2. Start Artemis Container via Docker Socket
docker_info = await docker_manager.start_artemis_container(
session_id=session_id,
cuttlefish_host=cf_info.host,
cuttlefish_adb_port=cf_info.adb_port,
)
record.artemis_container_id = docker_info["container_id"]
record.artemis_container_name = docker_info["container_name"]
record.artemis_internal_ip = docker_info["internal_ip"]
record.status = SessionStatus.ACTIVE
# 3. Save finalized state to BigQuery
await bigquery_service.save_session(record)
logger.info(f"[Session Manager] Session {session_id} is ACTIVE and ready.")
return self._build_session_response(record)
except Exception as e:
logger.error(f"[Session Manager] Failed to create session {session_id}: {e}")
record.status = SessionStatus.ERROR
record.error_message = str(e)
await bigquery_service.save_session(record)
# Clean up partial resources
await self._cleanup_resources(session_id)
raise e
async def get_session(self, session_id: str, user_id: str) -> SessionResponse | None:
"""Fetch session information, ensuring tenant ownership."""
record = await bigquery_service.get_session(session_id)
if not record:
return None
if record.user_id != user_id:
logger.warning(f"[Session Manager] Tenant mismatch: user {user_id} attempted access to session {session_id}")
return None
return self._build_session_response(record)
async def list_user_sessions(self, user_id: str) -> list[SessionListItem]:
"""List all active sessions for the current user."""
records = await bigquery_service.list_user_sessions(user_id)
return [
SessionListItem(
session_id=r.session_id,
status=r.status,
created_at=r.created_at,
expires_at=r.expires_at,
artemis_container_id=r.artemis_container_id,
)
for r in records
]
async def heartbeat(self, session_id: str, user_id: str) -> HeartbeatResponse | None:
"""Extend session TTL and record client liveness."""
record = await bigquery_service.get_session(session_id)
if not record or record.user_id != user_id or record.status == SessionStatus.TERMINATED:
return None
now = datetime.now(timezone.utc)
record.last_heartbeat_at = now
record.expires_at = now + timedelta(minutes=settings.SESSION_TTL_MINUTES)
await bigquery_service.save_session(record)
return HeartbeatResponse(
session_id=session_id,
status=record.status,
last_heartbeat_at=record.last_heartbeat_at,
expires_at=record.expires_at,
)
async def terminate_session(self, session_id: str, user_id: str | None = None, reason: str = "User requested") -> bool:
"""Gracefully terminate container, emulator, and update records."""
record = await bigquery_service.get_session(session_id)
if not record:
return False
if user_id and record.user_id != user_id:
return False
logger.info(f"[Session Manager] Terminating session {session_id} (Reason: {reason})...")
record.status = SessionStatus.TERMINATING
await bigquery_service.save_session(record)
await self._cleanup_resources(session_id)
record.status = SessionStatus.TERMINATED
await bigquery_service.save_session(record)
logger.info(f"[Session Manager] Session {session_id} successfully terminated.")
return True
async def _cleanup_resources(self, session_id: str):
"""Clean up Docker container and Cloud Orchestrator instance."""
await asyncio.gather(
docker_manager.stop_artemis_container(session_id),
cloud_orchestrator_service.destroy_cuttlefish_instance(session_id),
return_exceptions=True,
)
def _build_session_response(self, record: SessionRecord) -> SessionResponse:
"""Construct the client response containing the Nginx-compatible proxy URLs."""
return SessionResponse(
session_id=record.session_id,
status=record.status,
# Routes through Nginx reverse proxy
artemis_api_url=f"/session/{record.session_id}/artemis/",
artemis_stream_url=f"/session/{record.session_id}/artemis/api/stream/active",
cuttlefish_webrtc_url=f"/session/{record.session_id}/cuttlefish/",
cuttlefish_adb_endpoint=f"{settings.CUTTLEFISH_HOST_GATEWAY}:{record.cuttlefish_adb_port}",
created_at=record.created_at,
expires_at=record.expires_at,
)
session_manager = SessionManagerService()