Localize the web console and persist custom model and capability settings. Use OpenAI Responses exclusively and move runtime limits into environment configuration. Bootstrap uv and the virtual environment on Windows, support optional China mirrors, and package source distributions without environments, build output, or credentials. Update regression tests and repository quality checks for the new behavior.
224 lines
9.3 KiB
Python
224 lines
9.3 KiB
Python
# 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, UTC
|
|
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(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(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(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()
|