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

+37 -4
View File
@@ -6,20 +6,40 @@ Dedicated, session-isolated container image for running the Artemis Autonomous A
## 📌 Architecture & Lifecycle
When a user initiates a session via the [Backend Manager](../backend_manager/README.md), the backend dynamically spawns an instance of this container named `artemis-session-<session_id>` on the shared `artemis-net` bridge network.
When a user initiates a session via the [Backend Manager](file:///usr/local/google/home/yaoyaogoogle/develop/artemis/playground/backend_manager/README.md), the backend dynamically spawns an instance of this container named `artemis-session-<session_id>` on the shared `artemis-net` bridge network.
```mermaid
flowchart LR
subgraph COS_Host["COS VM Host Network"]
CVD["Cuttlefish AVD Emulator\n(Port: 6520+N)"]
end
subgraph ArtemisContainer["Artemis Session Container (artemis-session-:id)"]
Entrypoint["docker-entrypoint.sh\n(adb connect host.docker.internal:6520)"]
Server["Artemis Server (:8080)\n- apps.admin_console.server\n- Agent Task Loop\n- SSE Stream (/stream/events)"]
end
subgraph NginxProxy["Nginx Reverse Proxy"]
Ingress["/session/:id/artemis/*"]
end
Entrypoint -->|ADB TCP Link| CVD
Server -->|Screen Capture & Dynamic Touch Events| CVD
Ingress -->|Dynamic Proxy (127.0.0.11)| Server
```
---
## 🔑 Key Features
1. **Automated ADB Bridging**:
- The container entrypoint automatically connects to the target Cuttlefish instance with automatic retry handling.
- The container entrypoint ([docker-entrypoint.sh](file:///usr/local/google/home/yaoyaogoogle/develop/artemis/playground/artemis_container/docker-entrypoint.sh)) automatically connects to the target Cuttlefish instance using `$ADB_DEVICE_SERIAL` (e.g. `host.docker.internal:6520`) with automatic retry handling.
2. **Multimodal Perception & Toolchain**:
- Packaged with Android Platform Tools, ffmpeg, OpenCV libraries, and Python.
- Packaged with Android Platform Tools (`adb`), `ffmpeg`, OpenCV libraries (`libgl1`, `libglib2.0-0`), and Python 3.12.
3. **Real-time Event Streaming**:
- Hosts the Artemis Admin Console server exposing SSE and WebSocket endpoints for live streaming agent reasoning, thought steps, and action execution directly to the frontend.
- Hosts the Artemis Admin Console server exposing SSE and WebSocket endpoints (`/stream/events`) for live streaming agent reasoning, thought steps, and action execution directly to the frontend.
4. **Dynamic Selector & Coordinate Fallback Engine**:
- Drives real-time automated navigation on the connected Cuttlefish emulator using Artemis's multimodal reasoning pipeline.
@@ -39,6 +59,19 @@ playground/artemis_container/
---
## ⚙️ Environment Variables
| Variable | Default | Description |
| :--- | :--- | :--- |
| `SESSION_ID` | `standalone-dev` | Unique session UUID passed by the Backend Manager |
| `ADB_DEVICE_SERIAL` | `host.docker.internal:6520` | TCP host and port of the target Cuttlefish emulator |
| `PORT` | `8080` | Port for Artemis Admin & Stream server |
| `PYTHONUNBUFFERED` | `1` | Ensures immediate stdout logging |
| `GEMINI_API_KEY` | - | Google Gemini API key for agent reasoning |
| `OPENAI_API_KEY` | - | Optional OpenAI API key |
---
## 🚀 Building & Running
### 1. Build the Artemis Container Image
+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
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# 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.
set -euo pipefail
# Usage: ./playground/deploy_to_cos.sh <COS_VM_NAME> [ZONE] [PROJECT]
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <COS_VM_NAME> [ZONE] [PROJECT]"
echo ""
echo "Example:"
echo " $0 my-cos-instance us-central1-a"
echo " $0 my-cos-instance us-central1-a my-gcp-project"
exit 1
fi
VM_NAME="$1"
ZONE="${2:-}"
PROJECT="${3:-}"
SSH_OPTS=()
if [ -n "$ZONE" ]; then
SSH_OPTS+=(--zone="$ZONE")
fi
if [ -n "$PROJECT" ]; then
SSH_OPTS+=(--project="$PROJECT")
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
echo "================================================================="
echo " Deploying Artemis to Container-Optimized OS (COS) VM"
echo " Target VM : ${VM_NAME}"
echo " Zone : ${ZONE:-'(default)'}"
echo " Project : ${PROJECT:-'(default)'}"
echo "================================================================="
# ----------------------------------------------------------------------
# Step 1: Build Docker Images on local gLinux workstation (linux/amd64)
# ----------------------------------------------------------------------
echo ""
echo "[Step 1/3] Building Docker images locally on gLinux (linux/amd64)..."
echo " -> Building artemis:latest..."
docker build --platform linux/amd64 -f playground/artemis_container/Dockerfile -t artemis:latest .
echo " -> Building artemis-backend-manager:latest..."
docker build --platform linux/amd64 -t artemis-backend-manager:latest playground/backend_manager
echo " -> Building artemis-nginx-proxy:latest..."
docker build --platform linux/amd64 -t artemis-nginx-proxy:latest playground/nginx_container
echo "[Step 1/3] All images successfully built locally."
# ----------------------------------------------------------------------
# Step 2: Stream & Load Docker Images into COS VM via SSH Pipe
# ----------------------------------------------------------------------
echo ""
echo "[Step 2/3] Transferring and loading images into COS VM via compressed SSH stream..."
stream_image() {
local img="$1"
echo " -> Transferring ${img}..."
docker save "$img" | gzip -1 | gcloud compute ssh "$VM_NAME" "${SSH_OPTS[@]}" -- "gunzip | docker load"
}
stream_image "artemis:latest"
stream_image "artemis-backend-manager:latest"
stream_image "artemis-nginx-proxy:latest"
echo "[Step 2/3] All images loaded into COS VM Docker daemon."
# ----------------------------------------------------------------------
# Step 3: Launch / Restart Artemis Services on the COS VM
# ----------------------------------------------------------------------
echo ""
echo "[Step 3/3] Starting containers on COS VM..."
gcloud compute ssh "$VM_NAME" "${SSH_OPTS[@]}" -- bash -s << 'EOF'
set -euo pipefail
echo " [COS] Ensuring shared Docker network (artemis-net)..."
docker network create artemis-net 2>/dev/null || true
echo " [COS] Stopping previous container instances if running..."
docker rm -f backend artemis-nginx-proxy 2>/dev/null || true
echo " [COS] Starting Backend Manager container..."
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 \
-e APP_ENV=production \
-e PORT=8000 \
-e DOCKER_NETWORK=artemis-net \
-e ARTEMIS_IMAGE=artemis:latest \
-e CLOUD_ORCHESTRATOR_URL=http://cloud-orchestrator:2081 \
-e CUTTLEFISH_HOST_GATEWAY=cloud-orchestrator \
-e USE_LOCAL_FALLBACK_DB=true \
artemis-backend-manager:latest
echo " [COS] Starting Nginx Reverse Proxy container..."
docker run -d \
--name artemis-nginx-proxy \
--restart unless-stopped \
--network artemis-net \
--add-host host.docker.internal:host-gateway \
-p 80:80 \
-p 443:443 \
artemis-nginx-proxy:latest
echo " [COS] Verifying active containers..."
docker ps --filter "network=artemis-net"
EOF
echo ""
echo "================================================================="
echo " Deployment Complete! 🚀"
echo " Artemis Reverse Proxy is listening on ports 80 & 443 of ${VM_NAME}."
echo "================================================================="
+5
View File
@@ -0,0 +1,5 @@
.git
.gitignore
certs/*.crt
certs/*.key
*.md
+49
View File
@@ -0,0 +1,49 @@
# 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 nginx:1.27-alpine
# Install openssl for auto-generating self-signed dev certificates and curl for healthchecks
RUN apk add --no-cache openssl curl bash
# Create directory structure
RUN mkdir -p /etc/nginx/conf.d \
/etc/nginx/includes \
/etc/nginx/certs \
/usr/share/nginx/html
# Copy main Nginx configuration
COPY conf/nginx.conf /etc/nginx/nginx.conf
# Clear base image default configs and copy custom configs
RUN rm -rf /etc/nginx/conf.d/*
COPY conf/conf.d/ /etc/nginx/conf.d/
COPY conf/includes/ /etc/nginx/includes/
# Copy custom error templates and fallback static files
COPY html/ /usr/share/nginx/html/
# Copy entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Expose standard HTTP and HTTPS ports
EXPOSE 80 443
# Healthcheck to ensure Nginx is responding
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD curl -k -f https://localhost/healthz || curl -f http://localhost:80/healthz || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+127
View File
@@ -0,0 +1,127 @@
# 🌐 Artemis Ingress Reverse Proxy Container
Production-ready, high-performance Nginx Reverse Proxy container designed for running Artemis on a **Container-Optimized OS (COS)** VM in Google Cloud.
---
## 📌 Architecture & Routing Matrix
The proxy acts as the single TLS/SSL termination gateway on standard ports (`80` $\rightarrow$ `443`), dynamically routing traffic to persistent platform services and on-demand ephemeral Artemis session containers without needing Nginx reload or restart:
| Path Prefix | Destination Upstream | Protocol | Purpose |
| :--- | :--- | :--- | :--- |
| `GET /healthz` | Nginx Internal | HTTP/HTTPS | Ingress health check probe |
| `/` | `http://backend:8000/` | HTTP/1.1 | Static frontend Single Page Application (React/Vue/Angular) |
| `/api/*` | `http://backend:8000/api/*` | HTTP/1.1 / WS | Management API, OTP auth verification, session lifecycle |
| `/session/{id}/artemis/stream/*` | `http://artemis-session-{id}:8080/stream/*` | **SSE / WebSocket** | Real-time agent thought stream (zero buffering, 1h timeout) |
| `/session/{id}/artemis/*` | `http://artemis-session-{id}:8080/*` | HTTP/1.1 / WS | Artemis REST API and task execution endpoints |
| `/session/{id}/cuttlefish/*` | `http://host.docker.internal:8443/*` | **WebRTC / WS** | Cuttlefish WebRTC signaling & interactive device controls |
---
## 📁 File Structure
```
playground/nginx_container/
├── Dockerfile # Nginx alpine image with openssl and health checks
├── docker-entrypoint.sh # Auto-generates dev certificates & tests nginx syntax
├── docker-compose.yml # Compose specification with artemis-net bridge network
├── .dockerignore
├── README.md # Detailed operational documentation
├── certs/
│ ├── generate_dev_certs.sh # Manual SSL certificate generation helper
│ └── .gitkeep
├── conf/
│ ├── nginx.conf # Master Nginx configuration (JSON logging, WebSocket map)
│ ├── conf.d/
│ │ ├── http_redirect.conf # Port 80 redirect to Port 443 + ACME challenge
│ │ └── artemis_ssl.conf # Port 443 SSL server block & dynamic Docker DNS routing
│ └── includes/
│ ├── proxy_headers.conf # Standard forwarding headers (Host, X-Real-IP, X-Forwarded-*)
│ ├── websocket_params.conf# WebSocket upgrade headers & 3600s timeouts
│ └── sse_params.conf # SSE streaming parameters (proxy_buffering off)
└── html/
├── index.html # Fallback landing page
├── 502.json # JSON error response for provisioning containers
└── 504.json # JSON error response for timeouts
```
---
## 🚀 Quick Start
### 1. Build the Docker Image
```bash
docker build -t artemis-nginx-proxy:latest .
```
### 2. Run with Docker Compose
```bash
docker-compose up -d
```
### 3. Run Standalone on COS VM
```bash
# Create shared bridge network if not exists
docker network create artemis-net || true
# Run Nginx Reverse Proxy Container
docker run -d \
--name artemis-nginx-proxy \
--restart unless-stopped \
--network artemis-net \
--add-host host.docker.internal:host-gateway \
-p 80:80 \
-p 443:443 \
-v $(pwd)/certs:/etc/nginx/certs:ro \
artemis-nginx-proxy:latest
```
---
## 🔑 Key Features & Technical Highlights
### 1. Dynamic Zero-Reload Upstream Resolution
When a user launches a new session, the backend starts a new Docker container named `artemis-session-<session_id>` on the `artemis-net` network.
Using Docker's embedded DNS server (`127.0.0.11 valid=5s`) combined with variable-based `proxy_pass`:
```nginx
resolver 127.0.0.11 valid=5s ipv6=off;
location ~ ^/session/(?<session_id>[a-zA-Z0-9_-]+)/artemis/(?<artemis_path>.*)$ {
set $artemis_host "artemis-session-$session_id";
proxy_pass http://$artemis_host:8080/$artemis_path$is_args$args;
}
```
Nginx resolves the container hostname at **request-time**, allowing session containers to be created and destroyed without touching or reloading Nginx configuration.
### 2. Real-Time Streaming (SSE & WebSocket)
- **SSE Streams** (`/stream/*`): Uses `proxy_buffering off;`, `proxy_cache off;`, and `chunked_transfer_encoding on;` so the browser receives agent reasoning tokens with zero buffering delay.
- **WebRTC Signaling & WebSockets**: Employs `map $http_upgrade $connection_upgrade` with 1-hour idle timeouts (`proxy_read_timeout 3600s;`).
### 3. Automated SSL/TLS Management
- **Development/Staging**: The container entrypoint automatically generates a self-signed certificate if no certificates exist in `/etc/nginx/certs/`.
- **Production**: Mount your Let's Encrypt or GCP managed certificates to `/etc/nginx/certs/tls.crt` and `/etc/nginx/certs/tls.key`.
### 4. Resilient Error Handling
If a requested session container is still booting or unavailable, Nginx returns a clean JSON error response (`502.json` / `504.json`) instead of standard Nginx HTML error pages, enabling the frontend UI to gracefully poll or display a loading spinner.
---
## 🧪 Verification & Testing
1. **Verify Health Endpoint**:
```bash
curl -k https://localhost/healthz
# Output: OK
```
2. **Test Dynamic Routing**:
```bash
# Launch a mock session container
docker run -d --name artemis-session-test123 --network artemis-net hashicorp/http-echo -text="Hello from Session test123" -listen=:8080
# Test via Nginx proxy
curl -k https://localhost/session/test123/artemis/
# Output: Hello from Session test123
```
@@ -0,0 +1 @@
# Keep certs directory in repository while ignoring .crt and .key files
@@ -0,0 +1,35 @@
#!/bin/bash
# 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.
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CERT_FILE="${DIR}/tls.crt"
KEY_FILE="${DIR}/tls.key"
echo "Generating self-signed SSL certificates for development..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "${KEY_FILE}" \
-out "${CERT_FILE}" \
-subj "/CN=localhost/O=Artemis Development/C=US" \
-addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1"
chmod 600 "${KEY_FILE}"
chmod 644 "${CERT_FILE}"
echo "Development certificates generated:"
echo " Private Key: ${KEY_FILE}"
echo " Certificate: ${CERT_FILE}"
@@ -0,0 +1,118 @@
# 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.
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
http2 on;
server_name _;
# TLS / SSL Configuration
ssl_certificate /etc/nginx/certs/tls.crt;
ssl_certificate_key /etc/nginx/certs/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# Security Headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Embedded Docker DNS Resolver for dynamic container discovery
# 127.0.0.11 is Docker's internal DNS server
resolver 127.0.0.11 valid=5s ipv6=off;
resolver_timeout 5s;
# Health check endpoint
location = /healthz {
access_log off;
default_type text/plain;
return 200 "OK\n";
}
# --------------------------------------------------------------------------
# 1. Backend Core Management API (Auth, OTP, Session Lifecycle)
# --------------------------------------------------------------------------
location /api/ {
set $backend_upstream "backend:8000";
proxy_pass http://$backend_upstream;
include /etc/nginx/includes/proxy_headers.conf;
include /etc/nginx/includes/websocket_params.conf;
}
# --------------------------------------------------------------------------
# 2. Dynamic Artemis Session Containers: Real-time Trace & Event Stream (SSE/WS)
# Target container: artemis-session-<session_id>:8080
# --------------------------------------------------------------------------
location ~ ^/session/(?<session_id>[a-zA-Z0-9_-]+)/artemis/stream/(?<stream_path>.*)$ {
set $artemis_host "artemis-session-$session_id";
proxy_pass http://$artemis_host:8080/stream/$stream_path$is_args$args;
include /etc/nginx/includes/sse_params.conf;
include /etc/nginx/includes/websocket_params.conf;
}
# --------------------------------------------------------------------------
# 3. Dynamic Artemis Session Containers: REST APIs & Admin Console
# Target container: artemis-session-<session_id>:8080
# --------------------------------------------------------------------------
location ~ ^/session/(?<session_id>[a-zA-Z0-9_-]+)/artemis/(?<artemis_path>.*)$ {
set $artemis_host "artemis-session-$session_id";
proxy_pass http://$artemis_host:8080/$artemis_path$is_args$args;
include /etc/nginx/includes/proxy_headers.conf;
include /etc/nginx/includes/websocket_params.conf;
}
# --------------------------------------------------------------------------
# 4. Cuttlefish Emulator WebRTC Signaling & Stream Proxy
# Proxies signaling requests to the Cloud Orchestrator / CVD daemon
# --------------------------------------------------------------------------
location ~ ^/session/(?<session_id>[a-zA-Z0-9_-]+)/cuttlefish/(?<cf_path>.*)$ {
# Host gateway communication or cloud-orchestrator container
set $cuttlefish_upstream "host.docker.internal:8443";
proxy_pass http://$cuttlefish_upstream/$cf_path$is_args$args;
include /etc/nginx/includes/proxy_headers.conf;
include /etc/nginx/includes/websocket_params.conf;
}
# --------------------------------------------------------------------------
# 5. Static Frontend Single Page Application (Served via Backend or Static Dist)
# --------------------------------------------------------------------------
location / {
set $backend_upstream "backend:8000";
proxy_pass http://$backend_upstream;
include /etc/nginx/includes/proxy_headers.conf;
}
# --------------------------------------------------------------------------
# 6. Custom JSON Error Pages for Ephemeral Container Boot & Teardown States
# --------------------------------------------------------------------------
error_page 502 /502.json;
error_page 504 /504.json;
location = /502.json {
root /usr/share/nginx/html;
internal;
default_type application/json;
}
location = /504.json {
root /usr/share/nginx/html;
internal;
default_type application/json;
}
}
@@ -0,0 +1,37 @@
# 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.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Health check endpoint over plain HTTP
location = /healthz {
access_log off;
default_type text/plain;
return 200 "OK\n";
}
# ACME Challenge endpoint for automated Let's Encrypt SSL renewal
location /.well-known/acme-challenge/ {
root /var/www/certbot;
allow all;
}
# Enforce HTTPS redirect for all incoming web traffic
location / {
return 301 https://$host$request_uri;
}
}
@@ -0,0 +1,25 @@
# 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.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# Buffer tuning for standard API calls
proxy_buffers 8 64k;
proxy_buffer_size 128k;
proxy_busy_buffers_size 128k;
@@ -0,0 +1,18 @@
# 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.
# Critical directives for real-time Server-Sent Events (SSE) streaming
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
@@ -0,0 +1,16 @@
# 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.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
@@ -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.
user nginx;
worker_processes auto;
worker_rlimit_nofile 65535;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# High-performance event I/O settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Client payload size (allows large artifact, image, APK uploads)
client_max_body_size 100M;
client_body_buffer_size 128k;
# Enhanced JSON-structured access logging for easy parsing & cloud monitoring
log_format artemis_json escape=json '{'
'"time_local":"$time_local",'
'"remote_addr":"$remote_addr",'
'"request_method":"$request_method",'
'"request_uri":"$request_uri",'
'"status":$status,'
'"body_bytes_sent":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_response_time":"$upstream_response_time",'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"http_referrer":"$http_referer",'
'"http_user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.log artemis_json;
# Global proxy defaults
proxy_http_version 1.1;
proxy_connect_timeout 10s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
# Gzip compression for static assets and API payloads
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/x-javascript
application/xml
application/xml+rss
image/svg+xml;
# WebSocket Upgrade Header Mapping
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Load modular server configurations
include /etc/nginx/conf.d/*.conf;
}
@@ -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.
version: '3.8'
services:
nginx-proxy:
build:
context: .
dockerfile: Dockerfile
container_name: artemis-nginx-proxy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
# Mount certificates from host (or let entrypoint generate dev certs)
- ./certs:/etc/nginx/certs:ro
networks:
- artemis-net
extra_hosts:
# Allows reaching Cuttlefish / Cloud Orchestrator running on the COS host
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-k", "-f", "https://localhost/healthz"]
interval: 15s
timeout: 3s
retries: 3
networks:
artemis-net:
name: artemis-net
driver: bridge
@@ -0,0 +1,46 @@
#!/bin/bash
# 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.
set -e
CERT_DIR="/etc/nginx/certs"
CERT_FILE="${CERT_DIR}/tls.crt"
KEY_FILE="${CERT_DIR}/tls.key"
echo "[Nginx Entrypoint] Initializing Artemis Ingress Proxy..."
# Check if SSL certificates exist; if not, auto-generate a self-signed fallback cert
if [ ! -f "$CERT_FILE" ] || [ ! -f "$KEY_FILE" ]; then
echo "[Nginx Entrypoint] No SSL certificate found at ${CERT_FILE}. Generating self-signed certificate for development/staging..."
mkdir -p "${CERT_DIR}"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "${KEY_FILE}" \
-out "${CERT_FILE}" \
-subj "/CN=localhost/O=Artemis/C=US" \
-addext "subjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1" \
2>/dev/null
chmod 600 "${KEY_FILE}"
chmod 644 "${CERT_FILE}"
echo "[Nginx Entrypoint] Self-signed certificate generated successfully."
else
echo "[Nginx Entrypoint] SSL certificate verified at ${CERT_FILE}."
fi
# Test Nginx configuration validity before starting
echo "[Nginx Entrypoint] Verifying Nginx configuration syntax..."
nginx -t
echo "[Nginx Entrypoint] Starting Nginx daemon..."
exec "$@"
+6
View File
@@ -0,0 +1,6 @@
{
"status": 502,
"error": "bad_gateway",
"message": "The requested Artemis session container or Cuttlefish device is provisioning, booting, or currently unreachable. Please retry shortly.",
"timestamp": "auto"
}
+6
View File
@@ -0,0 +1,6 @@
{
"status": 504,
"error": "gateway_timeout",
"message": "The upstream Artemis container or device service took too long to respond.",
"timestamp": "auto"
}
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artemis Reverse Proxy</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: #0f172a;
color: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 20px;
box-sizing: border-box;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 16px;
padding: 40px;
max-width: 540px;
width: 100%;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
text-align: center;
}
h1 {
font-size: 24px;
margin-bottom: 12px;
color: #38bdf8;
}
p {
color: #94a3b8;
font-size: 15px;
line-height: 1.6;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: #064e3b;
color: #34d399;
border-radius: 9999px;
font-size: 13px;
font-weight: 600;
margin-bottom: 20px;
}
.dot {
width: 8px;
height: 8px;
background: #10b981;
border-radius: 50%;
}
</style>
</head>
<body>
<div class="card">
<div class="status">
<span class="dot"></span> Ingress Proxy Active
</div>
<h1>Artemis Cloud Platform</h1>
<p>The reverse proxy is operational and listening on ports 80 &amp; 443. Connect your backend service to begin routing user sessions and Cuttlefish devices.</p>
</div>
</body>
</html>