Add playground backend and proxy
This commit is contained in:
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()
|
||||
Reference in new issue
Block a user