Add playground backend and proxy
This commit is contained in:
1 parent
bac31b44c1
commit
70be3c37e5
43 files changed
+3140
-4
No files matched your search
@@ -6,20 +6,40 @@ Dedicated, session-isolated container image for running the Artemis Autonomous A
|
|||||||
|
|
||||||
## 📌 Architecture & Lifecycle
|
## 📌 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
|
## 🔑 Key Features
|
||||||
|
|
||||||
1. **Automated ADB Bridging**:
|
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**:
|
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**:
|
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**:
|
4. **Dynamic Selector & Coordinate Fallback Engine**:
|
||||||
- Drives real-time automated navigation on the connected Cuttlefish emulator using Artemis's multimodal reasoning pipeline.
|
- 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
|
## 🚀 Building & Running
|
||||||
|
|
||||||
### 1. Build the Artemis Container Image
|
### 1. Build the Artemis Container Image
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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 & 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
|
||||||
Executable
+134
@@ -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 "================================================================="
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
certs/*.crt
|
||||||
|
certs/*.key
|
||||||
|
*.md
|
||||||
@@ -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;"]
|
||||||
@@ -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 "$@"
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 & 443. Connect your backend service to begin routing user sessions and Cuttlefish devices.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user