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

+44
View File
@@ -0,0 +1,44 @@
# 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 python:3.12-slim
WORKDIR /app
# Install system dependencies & curl for healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install Python requirements
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source code
COPY app/ /app/app/
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
ENV PORT=8000
ENV CLOUD_ORCHESTRATOR_URL=http://cloud-orchestrator:2081
ENV CUTTLEFISH_HOST_GATEWAY=cloud-orchestrator
EXPOSE 8000
# Health check to ensure API is responsive
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/healthz || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+130
View File
@@ -0,0 +1,130 @@
# 🚀 Artemis Backend Manager Container
The central orchestration, API gateway, authentication, and container lifecycle service for running Artemis and Cuttlefish on Google Cloud **Container-Optimized OS (COS)**.
---
## 📌 Core Responsibilities
1. **Authentication & OTP Callbacks**:
- Manages user verification (`POST /api/v1/auth/otp/send` and `POST /api/v1/auth/otp/verify`).
- Issues signed JWT access tokens for secure tenant separation.
- Provides session validation hooks for Nginx ingress (`/api/v1/auth/validate-session`).
2. **Docker Socket Container Orchestration**:
- Communicates with `/var/run/docker.sock` to dynamically spawn `artemis-session-<session_id>` containers on the shared `artemis-net` bridge network.
- Injects runtime environment variables (`SESSION_ID`, `ADB_DEVICE_SERIAL`, `PORT=8080`).
- Automatically executes `adb connect` inside the container to pair it with the Cuttlefish emulator.
3. **Cloud Orchestrator & Cuttlefish Integration**:
- Dispatches API requests to the Cloud Orchestrator service running on COS to spin up dedicated Cuttlefish AVDs.
- Binds allocated ADB ports (`6520+N`) and WebRTC signaling ports (`8443+N`).
4. **BigQuery Mapping Store**:
- Tracks metadata linking `user_id`, `session_id`, `artemis_container_id`, `cuttlefish_adb_port`, `cuttlefish_webrtc_port`, and timestamps.
- Includes automatic partitioned table creation on BigQuery (`artemis_orchestration.sessions_registry`) and an in-memory fallback for local dev.
5. **Static Frontend UI Host**:
- Serves the dual-pane web application (Cuttlefish WebRTC stream player + Artemis agent thought workspace) at `/`.
6. **Automatic Session Reaper & Watchdog**:
- Background periodic worker checks active sessions every 30s.
- Automatically destroys orphaned containers and AVDs when the session TTL expires or heartbeat fails.
---
## 📁 Directory Structure
```
playground/backend_manager/
├── Dockerfile # Python 3.12 slim image with health check
├── docker-compose.yml # Compose spec with /var/run/docker.sock mount
├── requirements.txt # FastAPI, Pydantic, Docker SDK, BigQuery, JWT
├── README.md # Detailed documentation
└── app/
├── main.py # FastAPI app entrypoint with lifespan manager
├── config.py # Environment configuration settings
├── auth/
│ ├── jwt_handler.py # JWT encoding/decoding & get_current_user dependency
│ └── otp_service.py # OTP generation, storage, and verification
├── services/
│ ├── docker_service.py # Docker socket manager (start/stop containers)
│ ├── cloud_orchestrator_service.py # Cloud Orchestrator Cuttlefish client
│ ├── bigquery_service.py # BigQuery mapping repository with local fallback
│ └── session_manager.py # Master session lifecycle & reaper coordinator
├── routers/
│ ├── auth_router.py # OTP and JWT endpoints
│ ├── session_router.py # Session creation, polling, heartbeat, and deletion
│ └── health_router.py # Liveness probe endpoint (/healthz)
├── schemas/
│ ├── auth_schema.py # Auth request/response schemas
│ └── session_schema.py # Session & mapping schemas
└── static/
├── index.html # Dual-pane glassmorphic web workspace
├── style.css # Aurora design system styling
└── app.js # Frontend client logic & streaming hooks
```
---
## 🔌 REST API Endpoints
### Authentication
* `POST /api/v1/auth/otp/send` — Request a 6-digit OTP code (`{"identifier": "user@example.com"}`).
* `POST /api/v1/auth/otp/verify` — Verify OTP code and receive signed JWT token (`{"identifier": "...", "code": "123456"}`).
* `GET /api/v1/auth/me` — Return profile of the current authenticated user (`Authorization: Bearer <jwt>`).
* `GET /api/v1/auth/validate-session?session_id=...` — Validate user authorization for a specific session.
### Sessions & Container Management
* `POST /api/v1/sessions/create` — Provision Cuttlefish AVD + Artemis container, link ADB, and register mapping.
* `GET /api/v1/sessions` — List all active sessions for the current user.
* `GET /api/v1/sessions/{session_id}` — Get session status and reverse-proxy stream endpoints.
* `POST /api/v1/sessions/{session_id}/heartbeat` — Send client heartbeat to extend session TTL.
* `DELETE /api/v1/sessions/{session_id}` — Terminate container and release Cuttlefish instance.
### Health Probe
* `GET /healthz` — Returns `200 OK` with service health status.
---
## ⚙️ Environment Variables
| Variable | Default | Description |
| :--- | :--- | :--- |
| `PORT` | `8000` | HTTP port to listen on |
| `APP_ENV` | `development` | Environment mode (`development` / `production`) |
| `JWT_SECRET_KEY` | `artemis-...` | Secret key for signing JWT session tokens |
| `DOCKER_SOCKET_PATH` | `unix://var/run/docker.sock` | Path to host Docker daemon socket |
| `DOCKER_NETWORK` | `artemis-net` | Shared bridge network for dynamic container routing |
| `ARTEMIS_IMAGE` | `artemis:latest` | Docker image to spawn for Artemis instances |
| `CLOUD_ORCHESTRATOR_URL` | `http://host.docker.internal:1443` | Endpoint of the Cloud Orchestrator service |
| `CUTTLEFISH_HOST_GATEWAY` | `host.docker.internal` | Host IP / gateway for ADB TCP connections |
| `GOOGLE_CLOUD_PROJECT` | `artemis-cloud-project` | GCP project ID for BigQuery dataset |
| `USE_LOCAL_FALLBACK_DB` | `true` | Enables in-memory DB fallback if BigQuery is unconfigured |
| `SESSION_TTL_MINUTES` | `60` | Default session lifespan before automatic teardown |
---
## 🚀 Running the Container
### 1. Build Image
```bash
docker build -t artemis-backend-manager:latest .
```
### 2. Run with Docker Compose
```bash
docker-compose up -d
```
### 3. Run Standalone with Docker on COS
```bash
docker run -d \
--name backend \
--restart unless-stopped \
--network artemis-net \
--add-host host.docker.internal:host-gateway \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 8000:8000 \
artemis-backend-manager:latest
```
@@ -0,0 +1 @@
# Package root
@@ -0,0 +1 @@
# Auth package
@@ -0,0 +1,66 @@
# 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, timedelta, timezone
from fastapi import Depends, HTTPException, Security, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from app.config import settings
security = HTTPBearer(auto_error=False)
def create_access_token(user_id: str, extra_claims: dict | None = None) -> str:
"""Generate a signed JWT access token."""
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {
"sub": user_id,
"exp": expire,
"iat": datetime.now(timezone.utc),
"iss": "artemis-backend-manager",
}
if extra_claims:
to_encode.update(extra_claims)
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
"""Decode and validate a JWT access token."""
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
return payload
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid or expired credentials: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(credentials: HTTPAuthorizationCredentials | None = Security(security)) -> str:
"""FastAPI dependency to extract and verify the current user_id."""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization Header",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(credentials.credentials)
user_id: str | None = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Malformed token payload (missing subject)",
)
return user_id
@@ -0,0 +1,80 @@
# 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, timedelta, timezone
import logging
import secrets
from app.config import settings
logger = logging.getLogger("artemis.otp")
class OTPService:
"""Manages generation, storage, dispatch, and verification of One-Time Passwords."""
def __init__(self):
# In-memory storage: identifier -> {"code": str, "expires_at": datetime}
self._store: dict[str, dict] = {}
def generate_otp(self, identifier: str) -> str:
"""Generate a secure 6-digit OTP code and record its expiration."""
# Clean identifier
ident = identifier.strip().lower()
# If DEV_MOCK_OTP is configured, use it for local/test environments
if settings.DEV_MOCK_OTP:
code = settings.DEV_MOCK_OTP
else:
code = f"{secrets.randbelow(900000) + 100000}"
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.OTP_EXPIRE_SECONDS)
self._store[ident] = {
"code": code,
"expires_at": expires_at,
}
# Dispatch via SMS / Email provider (mocked logger in template)
logger.info(f"[OTP Service] Generated OTP for {ident}: [{code}] (Expires at: {expires_at.isoformat()})")
return code
def verify_otp(self, identifier: str, code: str) -> bool:
"""Verify an OTP code against stored records and consume it."""
ident = identifier.strip().lower()
record = self._store.get(ident)
# Allow fallback mock code for local testing
if settings.DEV_MOCK_OTP and code == settings.DEV_MOCK_OTP:
logger.info(f"[OTP Service] Development mock OTP accepted for {ident}")
self._store.pop(ident, None)
return True
if not record:
logger.warning(f"[OTP Service] No active OTP found for {ident}")
return False
if datetime.now(timezone.utc) > record["expires_at"]:
logger.warning(f"[OTP Service] OTP for {ident} has expired")
self._store.pop(ident, None)
return False
if secrets.compare_digest(record["code"], code.strip()):
logger.info(f"[OTP Service] OTP successfully verified for {ident}")
self._store.pop(ident, None) # Consume code
return True
logger.warning(f"[OTP Service] Invalid OTP attempt for {ident}")
return False
otp_service = OTPService()
+70
View File
@@ -0,0 +1,70 @@
# 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 os
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Application & Server Settings
APP_NAME: str = "Artemis Backend Manager"
APP_ENV: str = "development"
HOST: str = "0.0.0.0"
PORT: int = 8000
DEBUG: bool = True
# Security & JWT Token Config
JWT_SECRET_KEY: str = "artemis-cos-production-super-secret-key-change-in-prod"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 24 hours
# OTP Authentication Config
OTP_EXPIRE_SECONDS: int = 300 # 5 minutes
DEV_MOCK_OTP: str = "123456" # Bypass code for dev/test environments
# Docker Socket & Ephemeral Container Settings
DOCKER_SOCKET_PATH: str = "unix://var/run/docker.sock"
ARTEMIS_IMAGE: str = "artemis:latest"
DOCKER_NETWORK: str = "artemis-net"
ARTEMIS_CONTAINER_PREFIX: str = "artemis-session"
ARTEMIS_CPU_LIMIT: float = 2.0
ARTEMIS_MEMORY_LIMIT: str = "2g"
# Cloud Orchestrator & Cuttlefish Host Settings
CLOUD_ORCHESTRATOR_URL: str = os.getenv("CLOUD_ORCHESTRATOR_URL", "http://cloud-orchestrator:2081")
CUTTLEFISH_HOST_GATEWAY: str = os.getenv("CUTTLEFISH_HOST_GATEWAY", "cloud-orchestrator")
CUTTLEFISH_START_ADB_PORT: int = 6520
CUTTLEFISH_START_WEBRTC_PORT: int = 8443
# Google Cloud BigQuery Settings
GCP_PROJECT_ID: str = os.getenv("GOOGLE_CLOUD_PROJECT", "artemis-cloud-project")
BQ_DATASET: str = "artemis_orchestration"
BQ_TABLE: str = "sessions_registry"
USE_LOCAL_FALLBACK_DB: bool = True # In-memory fallback if BigQuery credentials are unset
# Session Lifecycle & Auto-Reaper
SESSION_TTL_MINUTES: int = 60
SESSION_HEARTBEAT_TIMEOUT_SECONDS: int = 120
REAPER_CHECK_INTERVAL_SECONDS: int = 30
# Paths
STATIC_DIR: Path = Path(__file__).resolve().parent / "static"
class Config:
env_file = ".env"
extra = "allow"
settings = Settings()
+84
View File
@@ -0,0 +1,84 @@
# 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 contextlib import asynccontextmanager
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.routers import auth_router, health_router, session_router
from app.services.session_manager import session_manager
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("artemis.backend")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application startup and shutdown hooks."""
logger.info(f"Starting {settings.APP_NAME} in [{settings.APP_ENV}] mode...")
# Start the background session expiration watchdog
session_manager.start_background_reaper()
yield
logger.info(f"Shutting down {settings.APP_NAME}...")
app = FastAPI(
title=settings.APP_NAME,
description="Backend Management & Orchestration Service for Artemis on Container OS",
version="1.0.0",
lifespan=lifespan,
)
# Enable CORS for local development and Nginx proxying
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Register API Routers
app.include_router(health_router.router)
app.include_router(auth_router.router)
app.include_router(session_router.router)
# Mount static asset directory
if settings.STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(settings.STATIC_DIR)), name="static")
@app.get("/", include_in_schema=False)
async def serve_spa_root():
"""Serve the root Single Page Application."""
index_file = settings.STATIC_DIR / "index.html"
if index_file.exists():
return FileResponse(str(index_file))
return {"message": f"{settings.APP_NAME} is active. API documentation at /docs"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.DEBUG,
)
@@ -0,0 +1 @@
# Routers package
@@ -0,0 +1,79 @@
# 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 fastapi import APIRouter, Depends, HTTPException, Query, status
from app.auth.jwt_handler import create_access_token, get_current_user
from app.auth.otp_service import otp_service
from app.config import settings
from app.schemas.auth_schema import (
OTPRequest,
OTPRequestResponse,
OTPVerify,
SessionValidationResponse,
TokenResponse,
UserResponse,
)
router = APIRouter(prefix="/api/v1/auth", tags=["Authentication & Verification"])
@router.post("/otp/send", response_model=OTPRequestResponse)
async def request_otp(payload: OTPRequest):
"""Request an OTP verification code sent to phone/email."""
code = otp_service.generate_otp(payload.identifier)
return OTPRequestResponse(
message="Verification code dispatched successfully",
identifier=payload.identifier,
expires_in_seconds=settings.OTP_EXPIRE_SECONDS,
)
@router.post("/otp/verify", response_model=TokenResponse)
async def verify_otp(payload: OTPVerify):
"""Verify OTP callback and return signed JWT session token."""
valid = otp_service.verify_otp(payload.identifier, payload.code)
if not valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification code",
)
# Issue JWT token
token = create_access_token(user_id=payload.identifier)
return TokenResponse(
access_token=token,
token_type="bearer",
user_id=payload.identifier,
expires_in=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
)
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(user_id: str = Depends(get_current_user)):
"""Retrieve authenticated user identity."""
return UserResponse(user_id=user_id, authenticated=True)
@router.get("/validate-session", response_model=SessionValidationResponse)
async def validate_session_ingress(
session_id: str = Query(..., description="Target session UUID"),
user_id: str = Depends(get_current_user),
):
"""Used by Nginx auth_request to confirm user has permission for a specific session."""
return SessionValidationResponse(
valid=True,
user_id=user_id,
session_id=session_id,
message="Access authorized",
)
@@ -0,0 +1,28 @@
# 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 fastapi import APIRouter
from app.config import settings
router = APIRouter(tags=["Health"])
@router.get("/healthz")
async def health_check():
"""Health check endpoint for container orchestrator and reverse proxy probes."""
return {
"status": "healthy",
"app": settings.APP_NAME,
"env": settings.APP_ENV,
}
@@ -0,0 +1,92 @@
# 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 fastapi import APIRouter, Depends, HTTPException, status
from app.auth.jwt_handler import get_current_user
from app.schemas.session_schema import (
CreateSessionRequest,
HeartbeatResponse,
SessionListItem,
SessionResponse,
)
from app.services.session_manager import session_manager
router = APIRouter(prefix="/api/v1/sessions", tags=["Session & Container Management"])
@router.post("/create", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
async def create_new_session(
payload: CreateSessionRequest,
user_id: str = Depends(get_current_user),
):
"""Create a new session, provision Cuttlefish AVD and Artemis container, and establish ADB link."""
try:
session = await session_manager.create_session(user_id=user_id, request=payload)
return session
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create session: {str(e)}",
)
@router.get("", response_model=list[SessionListItem])
async def list_user_sessions(user_id: str = Depends(get_current_user)):
"""List all active sessions belonging to the authenticated user."""
return await session_manager.list_user_sessions(user_id)
@router.get("/{session_id}", response_model=SessionResponse)
async def get_session_details(
session_id: str,
user_id: str = Depends(get_current_user),
):
"""Retrieve details and URLs for an existing session."""
session = await session_manager.get_session(session_id, user_id)
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found or unauthorized",
)
return session
@router.post("/{session_id}/heartbeat", response_model=HeartbeatResponse)
async def session_heartbeat(
session_id: str,
user_id: str = Depends(get_current_user),
):
"""Extend session expiration and report client liveness."""
res = await session_manager.heartbeat(session_id, user_id)
if not res:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Active session {session_id} not found",
)
return res
@router.delete("/{session_id}", status_code=status.HTTP_200_OK)
async def terminate_session(
session_id: str,
user_id: str = Depends(get_current_user),
):
"""Terminate the session, destroy Artemis container and Cuttlefish AVD."""
success = await session_manager.terminate_session(session_id, user_id=user_id, reason="User terminated")
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found or unauthorized",
)
return {"message": f"Session {session_id} successfully terminated"}
@@ -0,0 +1 @@
# Schemas package
@@ -0,0 +1,50 @@
# 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 pydantic import BaseModel, Field
class OTPRequest(BaseModel):
identifier: str = Field(..., description="User phone number or email address", example="user@example.com")
class OTPRequestResponse(BaseModel):
message: str
identifier: str
expires_in_seconds: int
class OTPVerify(BaseModel):
identifier: str = Field(..., description="User phone number or email address", example="user@example.com")
code: str = Field(..., description="6-digit verification code", example="123456")
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
expires_in: int
class UserResponse(BaseModel):
user_id: str
authenticated: bool
roles: list[str] = ["developer"]
class SessionValidationResponse(BaseModel):
valid: bool
user_id: str | None = None
session_id: str | None = None
message: str = "Authorized"
@@ -0,0 +1,76 @@
# 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
from enum import Enum
from pydantic import BaseModel, Field
class SessionStatus(str, Enum):
PROVISIONING = "PROVISIONING"
ACTIVE = "ACTIVE"
IDLE = "IDLE"
TERMINATING = "TERMINATING"
TERMINATED = "TERMINATED"
ERROR = "ERROR"
class CreateSessionRequest(BaseModel):
device_preset: str = Field(default="pixel_7_pro", description="Target Cuttlefish device configuration")
initial_task: str | None = Field(default=None, description="Optional initial prompt/task for Artemis")
ttl_minutes: int = Field(default=60, ge=5, le=480, description="Session time to live in minutes")
class SessionRecord(BaseModel):
user_id: str
session_id: str
artemis_container_id: str | None = None
artemis_container_name: str | None = None
artemis_internal_ip: str | None = None
cuttlefish_instance_id: str | None = None
cuttlefish_adb_port: int | None = None
cuttlefish_webrtc_port: int | None = None
status: SessionStatus = SessionStatus.PROVISIONING
created_at: datetime
last_heartbeat_at: datetime
expires_at: datetime
error_message: str | None = None
class SessionResponse(BaseModel):
session_id: str
status: SessionStatus
artemis_api_url: str
artemis_stream_url: str
cuttlefish_webrtc_url: str
cuttlefish_adb_endpoint: str
created_at: datetime
expires_at: datetime
message: str = "Session is ready"
class SessionListItem(BaseModel):
session_id: str
status: SessionStatus
created_at: datetime
expires_at: datetime
artemis_container_id: str | None
class HeartbeatResponse(BaseModel):
session_id: str
status: SessionStatus
last_heartbeat_at: datetime
expires_at: datetime
message: str = "Heartbeat acknowledged"
@@ -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()
@@ -0,0 +1,299 @@
// Artemis Cloud Client Workspace Application
let authToken = localStorage.getItem("artemis_jwt_token");
let currentUser = null;
let currentSession = null;
let heartbeatInterval = null;
let eventSource = null;
// DOM Elements
const authModal = document.getElementById("authModal");
const otpStep1 = document.getElementById("otpStep1");
const otpStep2 = document.getElementById("otpStep2");
const authIdentifier = document.getElementById("authIdentifier");
const otpCode = document.getElementById("otpCode");
const sendOtpBtn = document.getElementById("sendOtpBtn");
const verifyOtpBtn = document.getElementById("verifyOtpBtn");
const userBadge = document.getElementById("userBadge");
const newSessionBtn = document.getElementById("newSessionBtn");
const deviceStatus = document.getElementById("deviceStatus");
const agentStatus = document.getElementById("agentStatus");
const agentLogs = document.getElementById("agentLogs");
const streamPlaceholder = document.getElementById("streamPlaceholder");
const streamText = document.getElementById("streamText");
const taskInput = document.getElementById("taskInput");
const sendTaskBtn = document.getElementById("sendTaskBtn");
// Check Authentication on Page Load
window.addEventListener("DOMContentLoaded", async () => {
if (authToken) {
await verifyTokenAndInit();
} else {
showAuthModal();
}
});
function showAuthModal() {
authModal.classList.remove("hidden");
otpStep1.classList.remove("hidden");
otpStep2.classList.add("hidden");
}
function hideAuthModal() {
authModal.classList.add("hidden");
}
// 1. Request OTP
sendOtpBtn.addEventListener("click", async () => {
const ident = authIdentifier.value.trim();
if (!ident) return alert("Please enter email or phone");
sendOtpBtn.disabled = true;
sendOtpBtn.innerText = "Sending Code...";
try {
const res = await fetch("/api/v1/auth/otp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: ident }),
});
const data = await res.json();
if (res.ok) {
otpStep1.classList.add("hidden");
otpStep2.classList.remove("hidden");
otpCode.focus();
} else {
alert(data.detail || "Failed to send OTP");
}
} catch (err) {
alert("Network error requesting OTP");
} finally {
sendOtpBtn.disabled = false;
sendOtpBtn.innerText = "Send Verification Code";
}
});
// 2. Verify OTP Callback
verifyOtpBtn.addEventListener("click", async () => {
const ident = authIdentifier.value.trim();
const code = otpCode.value.trim();
if (!code) return alert("Please enter the verification code");
verifyOtpBtn.disabled = true;
verifyOtpBtn.innerText = "Verifying...";
try {
const res = await fetch("/api/v1/auth/otp/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: ident, code: code }),
});
const data = await res.json();
if (res.ok && data.access_token) {
authToken = data.access_token;
localStorage.setItem("artemis_jwt_token", authToken);
hideAuthModal();
await verifyTokenAndInit();
} else {
alert(data.detail || "Invalid verification code");
}
} catch (err) {
alert("Network error verifying OTP");
} finally {
verifyOtpBtn.disabled = false;
verifyOtpBtn.innerText = "Verify & Launch";
}
});
// Validate Token & Initialize User Session
async function verifyTokenAndInit() {
try {
const res = await fetch("/api/v1/auth/me", {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!res.ok) throw new Error("Unauthorized");
const user = await res.json();
currentUser = user.user_id;
userBadge.innerText = `👤 ${currentUser}`;
userBadge.classList.remove("hidden");
newSessionBtn.classList.remove("hidden");
// Check for existing sessions or create new
await checkActiveSessions();
} catch (e) {
localStorage.removeItem("artemis_jwt_token");
authToken = null;
showAuthModal();
}
}
// 3. Create or Resume Session
newSessionBtn.addEventListener("click", () => createSession());
async function checkActiveSessions() {
const res = await fetch("/api/v1/sessions", {
headers: { Authorization: `Bearer ${authToken}` },
});
if (res.ok) {
const sessions = await res.json();
if (sessions.length > 0) {
await attachSession(sessions[0].session_id);
return;
}
}
// Automatically create session if none active
await createSession();
}
async function createSession() {
appendLog("System", "Provisioning Cuttlefish emulator and starting Artemis container...");
deviceStatus.innerText = "Provisioning...";
agentStatus.innerText = "Starting...";
try {
const res = await fetch("/api/v1/sessions/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ device_preset: "pixel_7_pro", ttl_minutes: 60 }),
});
if (!res.ok) throw new Error("Failed to create session");
const session = await res.json();
await attachSession(session.session_id);
} catch (err) {
appendLog("Error", `Session creation failed: ${err.message}`);
deviceStatus.innerText = "Error";
agentStatus.innerText = "Error";
}
}
async function attachSession(sessionId) {
const res = await fetch(`/api/v1/sessions/${sessionId}`, {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!res.ok) return;
currentSession = await res.json();
appendLog("System", `Attached to Session ${currentSession.session_id.substring(0, 8)}`);
appendLog("System", `ADB Connected to ${currentSession.cuttlefish_adb_endpoint}`);
deviceStatus.innerText = "Active (Connected)";
agentStatus.innerText = "Ready";
// Connect live device screen stream from Artemis
initDeviceStream(currentSession);
// Start client heartbeat
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => sendHeartbeat(sessionId), 30000);
// Connect to Artemis SSE Stream via Nginx route
connectArtemisStream(currentSession.artemis_stream_url);
}
function initDeviceStream(session) {
const cuttlefishFrame = document.getElementById("cuttlefishFrame");
const streamImg = document.getElementById("deviceStreamImg");
const streamPlaceholder = document.getElementById("streamPlaceholder");
if (streamPlaceholder) streamPlaceholder.classList.add("hidden");
// Load interactive Cuttlefish WebRTC interface directly into the phone frame
if (cuttlefishFrame) {
cuttlefishFrame.classList.remove("hidden");
if (streamImg) streamImg.classList.add("hidden");
// Use forwarded port 9445 if accessed via 9443, or relative proxy path
cuttlefishFrame.src = window.location.port === "9443"
? "https://localhost:9445/"
: `${session.cuttlefish_webrtc_url}`;
} else if (streamImg) {
streamImg.classList.remove("hidden");
streamImg.src = `${session.artemis_api_url}api/stream/device-live`;
}
}
function connectArtemisStream(streamUrl) {
if (eventSource) eventSource.close();
try {
eventSource = new EventSource(streamUrl);
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
appendLog(payload.type || "Agent", payload.message || payload.step || event.data);
} catch {
appendLog("Agent", event.data);
}
};
eventSource.onerror = () => {
// Stream reconnection fallback
};
} catch (e) {
console.warn("SSE connection pending container boot.");
}
}
async function sendHeartbeat(sessionId) {
try {
const res = await fetch(`/api/v1/sessions/${sessionId}/heartbeat`, {
method: "POST",
headers: { Authorization: `Bearer ${authToken}` },
});
if (res.status === 404) {
// Session was terminated or cleared on backend; cancel polling
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
}
} catch (e) {}
}
// 4. Dispatch Task to Artemis
sendTaskBtn.addEventListener("click", () => sendTask());
taskInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") sendTask();
});
async function sendTask() {
const prompt = taskInput.value.trim();
if (!prompt || !currentSession) return;
taskInput.value = "";
appendLog("User", prompt);
agentStatus.innerText = "Reasoning...";
try {
// Send task through Nginx routed Artemis /api/run endpoint
const res = await fetch(`${currentSession.artemis_api_url}api/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ goal: prompt }),
});
if (res.ok) {
appendLog("System", "Task successfully dispatched to Artemis agent.");
agentStatus.innerText = "Executing...";
} else {
const err = await res.json().catch(() => ({ detail: res.statusText }));
appendLog("System", `Error starting task: ${err.detail || res.statusText}`);
agentStatus.innerText = "Ready";
}
} catch (e) {
appendLog("System", `Connection error: ${e.message}`);
agentStatus.innerText = "Ready";
}
}
function appendLog(sender, text) {
const entry = document.createElement("div");
entry.className = `log-entry ${sender.toLowerCase()}`;
entry.innerHTML = `<div class="meta">${sender}</div><div>${text}</div>`;
agentLogs.appendChild(entry);
agentLogs.scrollTop = agentLogs.scrollHeight;
}
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artemis Cloud Workspace</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- Header -->
<header class="app-header">
<div class="logo-group">
<span class="logo-badge">ARTEMIS</span>
<h1 class="title">Autonomous Mobile Agent Platform</h1>
</div>
<div class="header-actions">
<div id="userBadge" class="user-tag hidden">Not Logged In</div>
<button id="newSessionBtn" class="btn-primary hidden" style="width: auto; padding: 8px 16px;">+ New Session</button>
</div>
</header>
<!-- Auth Modal -->
<div id="authModal" class="modal-overlay">
<div class="auth-card">
<h2>Developer Login</h2>
<p>Enter your phone or email to receive a verification OTP code.</p>
<div id="otpStep1" class="input-group">
<input type="text" id="authIdentifier" class="input-field" placeholder="Email or Phone (e.g. user@example.com)">
<button id="sendOtpBtn" class="btn-primary">Send Verification Code</button>
</div>
<div id="otpStep2" class="input-group hidden">
<input type="text" id="otpCode" class="input-field" placeholder="6-digit OTP Code (dev: 123456)">
<button id="verifyOtpBtn" class="btn-primary">Verify &amp; Launch</button>
</div>
</div>
</div>
<!-- Main Dual-Pane Workspace -->
<main class="workspace-container">
<!-- Left Pane: Cuttlefish Live Stream -->
<section class="pane">
<div class="pane-header">
<div class="pane-title">
<span>📱</span> Cuttlefish AVD Live Stream
</div>
<span id="deviceStatus" class="status-pill">Disconnected</span>
</div>
<div class="device-viewport">
<div class="device-frame">
<img id="deviceStreamImg" class="device-screen-live hidden" alt="Live Device Screen">
<iframe id="cuttlefishFrame" class="device-screen-live hidden" allow="autoplay"></iframe>
<div id="streamPlaceholder" class="screen-placeholder">
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"/>
</svg>
<p id="streamText">Start a session to stream emulator</p>
</div>
</div>
</div>
</section>
<!-- Right Pane: Artemis Agent Workspace -->
<section class="pane">
<div class="pane-header">
<div class="pane-title">
<span>🤖</span> Artemis Agent Stream
</div>
<span id="agentStatus" class="status-pill">Idle</span>
</div>
<div id="agentLogs" class="agent-logs">
<div class="log-entry">
<div class="meta">System</div>
<div>Welcome to Artemis. Log in and launch a session to start autonomous testing.</div>
</div>
</div>
<div class="chat-input-area">
<input type="text" id="taskInput" class="chat-input" placeholder="Give Artemis a command (e.g. 'Open Maps and find directions to SFO')...">
<button id="sendTaskBtn" class="btn-primary" style="width: auto; padding: 0 20px;">Run</button>
</div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>
@@ -0,0 +1,305 @@
/* Artemis Aurora & Glassmorphism Design System */
:root {
--bg-primary: #0b0f19;
--bg-card: rgba(23, 32, 54, 0.7);
--bg-card-border: rgba(255, 255, 255, 0.08);
--accent-cyan: #38bdf8;
--accent-purple: #a855f7;
--accent-green: #10b981;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
background-color: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Header */
.app-header {
height: 64px;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--bg-card-border);
background: rgba(11, 15, 25, 0.85);
backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 100;
}
.logo-group {
display: flex;
align-items: center;
gap: 12px;
}
.logo-badge {
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
color: #fff;
font-weight: 800;
font-size: 14px;
padding: 4px 10px;
border-radius: 8px;
}
.title {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.5px;
}
.header-actions {
display: flex;
align-items: center;
gap: 16px;
}
.user-tag {
font-size: 14px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.05);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--bg-card-border);
}
/* Auth Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.auth-card {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: 20px;
padding: 36px;
width: 100%;
max-width: 440px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
text-align: center;
}
.auth-card h2 {
font-size: 22px;
margin-bottom: 8px;
}
.auth-card p {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
}
.input-field {
width: 100%;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--bg-card-border);
border-radius: 10px;
color: #fff;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.input-field:focus {
border-color: var(--accent-cyan);
}
.btn-primary {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #0284c7, #6366f1);
color: #fff;
font-size: 15px;
font-weight: 600;
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:active {
transform: scale(0.98);
}
/* Dual Pane Layout */
.workspace-container {
flex: 1;
display: flex;
height: calc(100vh - 64px);
padding: 16px;
gap: 16px;
}
.pane {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.pane-header {
height: 52px;
padding: 0 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--bg-card-border);
background: rgba(0, 0, 0, 0.2);
}
.pane-title {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.status-pill {
font-size: 12px;
padding: 2px 8px;
border-radius: 12px;
background: rgba(16, 185, 129, 0.15);
color: var(--accent-green);
font-weight: 500;
}
/* Left Pane: Device Stream */
.device-viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #000;
position: relative;
}
.screen-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--text-muted);
}
.device-frame {
max-height: 90%;
max-width: 90%;
aspect-ratio: 9/19.5;
background: #111;
border: 4px solid #334155;
border-radius: 28px;
overflow: hidden;
box-shadow: 0 15px 30px rgba(0,0,0,0.6);
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.device-frame video,
.device-frame img,
.device-frame iframe,
.device-screen-live {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
border: none;
}
/* Right Pane: Artemis Workspace */
.agent-logs {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.log-entry {
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--bg-card-border);
border-radius: 10px;
padding: 12px;
font-size: 14px;
}
.log-entry.thought {
border-left: 3px solid var(--accent-cyan);
}
.log-entry.action {
border-left: 3px solid var(--accent-purple);
}
.log-entry .meta {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chat-input-area {
padding: 16px;
border-top: 1px solid var(--bg-card-border);
background: rgba(0, 0, 0, 0.2);
display: flex;
gap: 10px;
}
.chat-input {
flex: 1;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--bg-card-border);
border-radius: 10px;
color: #fff;
font-size: 14px;
outline: none;
}
.hidden {
display: none !important;
}
@@ -0,0 +1,51 @@
# 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.
version: '3.8'
services:
backend-manager:
build:
context: .
dockerfile: Dockerfile
container_name: backend
restart: unless-stopped
ports:
- "8000:8000"
volumes:
# Mount host Docker socket for spawning ephemeral Artemis containers
- /var/run/docker.sock:/var/run/docker.sock
environment:
- APP_ENV=production
- PORT=8000
- DOCKER_SOCKET_PATH=unix://var/run/docker.sock
- DOCKER_NETWORK=artemis-net
- ARTEMIS_IMAGE=artemis:latest
- CLOUD_ORCHESTRATOR_URL=http://cloud-orchestrator:2081
- CUTTLEFISH_HOST_GATEWAY=cloud-orchestrator
- USE_LOCAL_FALLBACK_DB=true
networks:
- artemis-net
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 15s
timeout: 3s
retries: 3
networks:
artemis-net:
name: artemis-net
driver: bridge
@@ -0,0 +1,11 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.9.0
pydantic-settings>=2.5.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
docker>=7.1.0
google-cloud-bigquery>=3.26.0
httpx>=0.27.2
python-multipart>=0.0.12
aiofiles>=24.1.0