From 70be3c37e5d3c0a4647e5cbc017e09a7b9ce11ea Mon Sep 17 00:00:00 2001 From: yaoyao-open-source Date: Thu, 10 Sep 2026 17:45:46 -0700 Subject: [PATCH] Add playground backend and proxy --- playground/artemis_container/README.md | 41 ++- playground/backend_manager/Dockerfile | 44 +++ playground/backend_manager/README.md | 130 ++++++++ playground/backend_manager/app/__init__.py | 1 + .../backend_manager/app/auth/__init__.py | 1 + .../backend_manager/app/auth/jwt_handler.py | 66 ++++ .../backend_manager/app/auth/otp_service.py | 80 +++++ playground/backend_manager/app/config.py | 70 ++++ playground/backend_manager/app/main.py | 84 +++++ .../backend_manager/app/routers/__init__.py | 1 + .../app/routers/auth_router.py | 79 +++++ .../app/routers/health_router.py | 28 ++ .../app/routers/session_router.py | 92 ++++++ .../backend_manager/app/schemas/__init__.py | 1 + .../app/schemas/auth_schema.py | 50 +++ .../app/schemas/session_schema.py | 76 +++++ .../backend_manager/app/services/__init__.py | 1 + .../app/services/bigquery_service.py | 144 +++++++++ .../services/cloud_orchestrator_service.py | 192 +++++++++++ .../app/services/docker_service.py | 174 ++++++++++ .../app/services/session_manager.py | 207 ++++++++++++ playground/backend_manager/app/static/app.js | 299 +++++++++++++++++ .../backend_manager/app/static/index.html | 87 +++++ .../backend_manager/app/static/style.css | 305 ++++++++++++++++++ playground/backend_manager/docker-compose.yml | 51 +++ playground/backend_manager/requirements.txt | 11 + playground/deploy_to_cos.sh | 134 ++++++++ playground/nginx_container/.dockerignore | 5 + playground/nginx_container/Dockerfile | 49 +++ playground/nginx_container/README.md | 127 ++++++++ playground/nginx_container/certs/.gitkeep | 1 + .../certs/generate_dev_certs.sh | 35 ++ .../conf/conf.d/artemis_ssl.conf | 118 +++++++ .../conf/conf.d/http_redirect.conf | 37 +++ .../conf/includes/proxy_headers.conf | 25 ++ .../conf/includes/sse_params.conf | 18 ++ .../conf/includes/websocket_params.conf | 16 + playground/nginx_container/conf/nginx.conf | 92 ++++++ playground/nginx_container/docker-compose.yml | 44 +++ .../nginx_container/docker-entrypoint.sh | 46 +++ playground/nginx_container/html/502.json | 6 + playground/nginx_container/html/504.json | 6 + playground/nginx_container/html/index.html | 70 ++++ 43 files changed, 3140 insertions(+), 4 deletions(-) create mode 100644 playground/backend_manager/Dockerfile create mode 100644 playground/backend_manager/README.md create mode 100644 playground/backend_manager/app/__init__.py create mode 100644 playground/backend_manager/app/auth/__init__.py create mode 100644 playground/backend_manager/app/auth/jwt_handler.py create mode 100644 playground/backend_manager/app/auth/otp_service.py create mode 100644 playground/backend_manager/app/config.py create mode 100644 playground/backend_manager/app/main.py create mode 100644 playground/backend_manager/app/routers/__init__.py create mode 100644 playground/backend_manager/app/routers/auth_router.py create mode 100644 playground/backend_manager/app/routers/health_router.py create mode 100644 playground/backend_manager/app/routers/session_router.py create mode 100644 playground/backend_manager/app/schemas/__init__.py create mode 100644 playground/backend_manager/app/schemas/auth_schema.py create mode 100644 playground/backend_manager/app/schemas/session_schema.py create mode 100644 playground/backend_manager/app/services/__init__.py create mode 100644 playground/backend_manager/app/services/bigquery_service.py create mode 100644 playground/backend_manager/app/services/cloud_orchestrator_service.py create mode 100644 playground/backend_manager/app/services/docker_service.py create mode 100644 playground/backend_manager/app/services/session_manager.py create mode 100644 playground/backend_manager/app/static/app.js create mode 100644 playground/backend_manager/app/static/index.html create mode 100644 playground/backend_manager/app/static/style.css create mode 100644 playground/backend_manager/docker-compose.yml create mode 100644 playground/backend_manager/requirements.txt create mode 100755 playground/deploy_to_cos.sh create mode 100644 playground/nginx_container/.dockerignore create mode 100644 playground/nginx_container/Dockerfile create mode 100644 playground/nginx_container/README.md create mode 100644 playground/nginx_container/certs/.gitkeep create mode 100644 playground/nginx_container/certs/generate_dev_certs.sh create mode 100644 playground/nginx_container/conf/conf.d/artemis_ssl.conf create mode 100644 playground/nginx_container/conf/conf.d/http_redirect.conf create mode 100644 playground/nginx_container/conf/includes/proxy_headers.conf create mode 100644 playground/nginx_container/conf/includes/sse_params.conf create mode 100644 playground/nginx_container/conf/includes/websocket_params.conf create mode 100644 playground/nginx_container/conf/nginx.conf create mode 100644 playground/nginx_container/docker-compose.yml create mode 100644 playground/nginx_container/docker-entrypoint.sh create mode 100644 playground/nginx_container/html/502.json create mode 100644 playground/nginx_container/html/504.json create mode 100644 playground/nginx_container/html/index.html diff --git a/playground/artemis_container/README.md b/playground/artemis_container/README.md index 93a5f31..99fd825 100644 --- a/playground/artemis_container/README.md +++ b/playground/artemis_container/README.md @@ -6,20 +6,40 @@ Dedicated, session-isolated container image for running the Artemis Autonomous A ## ๐Ÿ“Œ Architecture & Lifecycle -When a user initiates a session via the [Backend Manager](../backend_manager/README.md), the backend dynamically spawns an instance of this container named `artemis-session-` 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-` on the shared `artemis-net` bridge network. + +```mermaid +flowchart LR + subgraph COS_Host["COS VM Host Network"] + CVD["Cuttlefish AVD Emulator\n(Port: 6520+N)"] + end + + subgraph ArtemisContainer["Artemis Session Container (artemis-session-:id)"] + Entrypoint["docker-entrypoint.sh\n(adb connect host.docker.internal:6520)"] + Server["Artemis Server (:8080)\n- apps.admin_console.server\n- Agent Task Loop\n- SSE Stream (/stream/events)"] + end + + subgraph NginxProxy["Nginx Reverse Proxy"] + Ingress["/session/:id/artemis/*"] + end + + Entrypoint -->|ADB TCP Link| CVD + Server -->|Screen Capture & Dynamic Touch Events| CVD + Ingress -->|Dynamic Proxy (127.0.0.11)| Server +``` --- ## ๐Ÿ”‘ Key Features 1. **Automated ADB Bridging**: - - The container entrypoint automatically connects to the target Cuttlefish instance with automatic retry handling. + - The container entrypoint ([docker-entrypoint.sh](file:///usr/local/google/home/yaoyaogoogle/develop/artemis/playground/artemis_container/docker-entrypoint.sh)) automatically connects to the target Cuttlefish instance using `$ADB_DEVICE_SERIAL` (e.g. `host.docker.internal:6520`) with automatic retry handling. 2. **Multimodal Perception & Toolchain**: - - Packaged with Android Platform Tools, ffmpeg, OpenCV libraries, and Python. + - Packaged with Android Platform Tools (`adb`), `ffmpeg`, OpenCV libraries (`libgl1`, `libglib2.0-0`), and Python 3.12. 3. **Real-time Event Streaming**: - - Hosts the Artemis Admin Console server exposing SSE and WebSocket endpoints for live streaming agent reasoning, thought steps, and action execution directly to the frontend. + - Hosts the Artemis Admin Console server exposing SSE and WebSocket endpoints (`/stream/events`) for live streaming agent reasoning, thought steps, and action execution directly to the frontend. 4. **Dynamic Selector & Coordinate Fallback Engine**: - Drives real-time automated navigation on the connected Cuttlefish emulator using Artemis's multimodal reasoning pipeline. @@ -39,6 +59,19 @@ playground/artemis_container/ --- +## โš™๏ธ Environment Variables + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `SESSION_ID` | `standalone-dev` | Unique session UUID passed by the Backend Manager | +| `ADB_DEVICE_SERIAL` | `host.docker.internal:6520` | TCP host and port of the target Cuttlefish emulator | +| `PORT` | `8080` | Port for Artemis Admin & Stream server | +| `PYTHONUNBUFFERED` | `1` | Ensures immediate stdout logging | +| `GEMINI_API_KEY` | - | Google Gemini API key for agent reasoning | +| `OPENAI_API_KEY` | - | Optional OpenAI API key | + +--- + ## ๐Ÿš€ Building & Running ### 1. Build the Artemis Container Image diff --git a/playground/backend_manager/Dockerfile b/playground/backend_manager/Dockerfile new file mode 100644 index 0000000..0cb3767 --- /dev/null +++ b/playground/backend_manager/Dockerfile @@ -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"] diff --git a/playground/backend_manager/README.md b/playground/backend_manager/README.md new file mode 100644 index 0000000..b5f03c9 --- /dev/null +++ b/playground/backend_manager/README.md @@ -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-` 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 `). +* `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 +``` diff --git a/playground/backend_manager/app/__init__.py b/playground/backend_manager/app/__init__.py new file mode 100644 index 0000000..8f9b809 --- /dev/null +++ b/playground/backend_manager/app/__init__.py @@ -0,0 +1 @@ +# Package root diff --git a/playground/backend_manager/app/auth/__init__.py b/playground/backend_manager/app/auth/__init__.py new file mode 100644 index 0000000..4834484 --- /dev/null +++ b/playground/backend_manager/app/auth/__init__.py @@ -0,0 +1 @@ +# Auth package diff --git a/playground/backend_manager/app/auth/jwt_handler.py b/playground/backend_manager/app/auth/jwt_handler.py new file mode 100644 index 0000000..f1b13dc --- /dev/null +++ b/playground/backend_manager/app/auth/jwt_handler.py @@ -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 diff --git a/playground/backend_manager/app/auth/otp_service.py b/playground/backend_manager/app/auth/otp_service.py new file mode 100644 index 0000000..d4a7439 --- /dev/null +++ b/playground/backend_manager/app/auth/otp_service.py @@ -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() diff --git a/playground/backend_manager/app/config.py b/playground/backend_manager/app/config.py new file mode 100644 index 0000000..9a9a7c6 --- /dev/null +++ b/playground/backend_manager/app/config.py @@ -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() diff --git a/playground/backend_manager/app/main.py b/playground/backend_manager/app/main.py new file mode 100644 index 0000000..031952a --- /dev/null +++ b/playground/backend_manager/app/main.py @@ -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, + ) diff --git a/playground/backend_manager/app/routers/__init__.py b/playground/backend_manager/app/routers/__init__.py new file mode 100644 index 0000000..873f7bb --- /dev/null +++ b/playground/backend_manager/app/routers/__init__.py @@ -0,0 +1 @@ +# Routers package diff --git a/playground/backend_manager/app/routers/auth_router.py b/playground/backend_manager/app/routers/auth_router.py new file mode 100644 index 0000000..21f23ce --- /dev/null +++ b/playground/backend_manager/app/routers/auth_router.py @@ -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", + ) diff --git a/playground/backend_manager/app/routers/health_router.py b/playground/backend_manager/app/routers/health_router.py new file mode 100644 index 0000000..b7ebb6f --- /dev/null +++ b/playground/backend_manager/app/routers/health_router.py @@ -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, + } diff --git a/playground/backend_manager/app/routers/session_router.py b/playground/backend_manager/app/routers/session_router.py new file mode 100644 index 0000000..f28864e --- /dev/null +++ b/playground/backend_manager/app/routers/session_router.py @@ -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"} diff --git a/playground/backend_manager/app/schemas/__init__.py b/playground/backend_manager/app/schemas/__init__.py new file mode 100644 index 0000000..8d2fd85 --- /dev/null +++ b/playground/backend_manager/app/schemas/__init__.py @@ -0,0 +1 @@ +# Schemas package diff --git a/playground/backend_manager/app/schemas/auth_schema.py b/playground/backend_manager/app/schemas/auth_schema.py new file mode 100644 index 0000000..b873867 --- /dev/null +++ b/playground/backend_manager/app/schemas/auth_schema.py @@ -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" diff --git a/playground/backend_manager/app/schemas/session_schema.py b/playground/backend_manager/app/schemas/session_schema.py new file mode 100644 index 0000000..013ea28 --- /dev/null +++ b/playground/backend_manager/app/schemas/session_schema.py @@ -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" diff --git a/playground/backend_manager/app/services/__init__.py b/playground/backend_manager/app/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/playground/backend_manager/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/playground/backend_manager/app/services/bigquery_service.py b/playground/backend_manager/app/services/bigquery_service.py new file mode 100644 index 0000000..4fb1b78 --- /dev/null +++ b/playground/backend_manager/app/services/bigquery_service.py @@ -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() diff --git a/playground/backend_manager/app/services/cloud_orchestrator_service.py b/playground/backend_manager/app/services/cloud_orchestrator_service.py new file mode 100644 index 0000000..6b22efa --- /dev/null +++ b/playground/backend_manager/app/services/cloud_orchestrator_service.py @@ -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() diff --git a/playground/backend_manager/app/services/docker_service.py b/playground/backend_manager/app/services/docker_service.py new file mode 100644 index 0000000..16fe129 --- /dev/null +++ b/playground/backend_manager/app/services/docker_service.py @@ -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() diff --git a/playground/backend_manager/app/services/session_manager.py b/playground/backend_manager/app/services/session_manager.py new file mode 100644 index 0000000..7822d80 --- /dev/null +++ b/playground/backend_manager/app/services/session_manager.py @@ -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() diff --git a/playground/backend_manager/app/static/app.js b/playground/backend_manager/app/static/app.js new file mode 100644 index 0000000..3947d43 --- /dev/null +++ b/playground/backend_manager/app/static/app.js @@ -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 = `
${sender}
${text}
`; + agentLogs.appendChild(entry); + agentLogs.scrollTop = agentLogs.scrollHeight; +} diff --git a/playground/backend_manager/app/static/index.html b/playground/backend_manager/app/static/index.html new file mode 100644 index 0000000..13eeb7a --- /dev/null +++ b/playground/backend_manager/app/static/index.html @@ -0,0 +1,87 @@ + + + + + + Artemis Cloud Workspace + + + + +
+
+ ARTEMIS +

Autonomous Mobile Agent Platform

+
+
+ + +
+
+ + + + + +
+ +
+
+
+ ๐Ÿ“ฑ Cuttlefish AVD Live Stream +
+ Disconnected +
+
+
+ + +
+ + + +

Start a session to stream emulator

+
+
+
+
+ + +
+
+
+ ๐Ÿค– Artemis Agent Stream +
+ Idle +
+
+
+
System
+
Welcome to Artemis. Log in and launch a session to start autonomous testing.
+
+
+
+ + +
+
+
+ + + + diff --git a/playground/backend_manager/app/static/style.css b/playground/backend_manager/app/static/style.css new file mode 100644 index 0000000..c0b469e --- /dev/null +++ b/playground/backend_manager/app/static/style.css @@ -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; +} diff --git a/playground/backend_manager/docker-compose.yml b/playground/backend_manager/docker-compose.yml new file mode 100644 index 0000000..3941049 --- /dev/null +++ b/playground/backend_manager/docker-compose.yml @@ -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 diff --git a/playground/backend_manager/requirements.txt b/playground/backend_manager/requirements.txt new file mode 100644 index 0000000..6fcc6a3 --- /dev/null +++ b/playground/backend_manager/requirements.txt @@ -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 diff --git a/playground/deploy_to_cos.sh b/playground/deploy_to_cos.sh new file mode 100755 index 0000000..642e5b8 --- /dev/null +++ b/playground/deploy_to_cos.sh @@ -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 [ZONE] [PROJECT] +if [ "$#" -lt 1 ]; then + echo "Usage: $0 [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 "=================================================================" diff --git a/playground/nginx_container/.dockerignore b/playground/nginx_container/.dockerignore new file mode 100644 index 0000000..8b3858a --- /dev/null +++ b/playground/nginx_container/.dockerignore @@ -0,0 +1,5 @@ +.git +.gitignore +certs/*.crt +certs/*.key +*.md diff --git a/playground/nginx_container/Dockerfile b/playground/nginx_container/Dockerfile new file mode 100644 index 0000000..930cc12 --- /dev/null +++ b/playground/nginx_container/Dockerfile @@ -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;"] diff --git a/playground/nginx_container/README.md b/playground/nginx_container/README.md new file mode 100644 index 0000000..a21094c --- /dev/null +++ b/playground/nginx_container/README.md @@ -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-` 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/(?[a-zA-Z0-9_-]+)/artemis/(?.*)$ { + 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 + ``` diff --git a/playground/nginx_container/certs/.gitkeep b/playground/nginx_container/certs/.gitkeep new file mode 100644 index 0000000..b3eb48b --- /dev/null +++ b/playground/nginx_container/certs/.gitkeep @@ -0,0 +1 @@ +# Keep certs directory in repository while ignoring .crt and .key files diff --git a/playground/nginx_container/certs/generate_dev_certs.sh b/playground/nginx_container/certs/generate_dev_certs.sh new file mode 100644 index 0000000..ac261e6 --- /dev/null +++ b/playground/nginx_container/certs/generate_dev_certs.sh @@ -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}" diff --git a/playground/nginx_container/conf/conf.d/artemis_ssl.conf b/playground/nginx_container/conf/conf.d/artemis_ssl.conf new file mode 100644 index 0000000..4862b8f --- /dev/null +++ b/playground/nginx_container/conf/conf.d/artemis_ssl.conf @@ -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-:8080 + # -------------------------------------------------------------------------- + location ~ ^/session/(?[a-zA-Z0-9_-]+)/artemis/stream/(?.*)$ { + 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-:8080 + # -------------------------------------------------------------------------- + location ~ ^/session/(?[a-zA-Z0-9_-]+)/artemis/(?.*)$ { + 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/(?[a-zA-Z0-9_-]+)/cuttlefish/(?.*)$ { + # 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; + } +} diff --git a/playground/nginx_container/conf/conf.d/http_redirect.conf b/playground/nginx_container/conf/conf.d/http_redirect.conf new file mode 100644 index 0000000..3e5e8d1 --- /dev/null +++ b/playground/nginx_container/conf/conf.d/http_redirect.conf @@ -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; + } +} diff --git a/playground/nginx_container/conf/includes/proxy_headers.conf b/playground/nginx_container/conf/includes/proxy_headers.conf new file mode 100644 index 0000000..5fdb350 --- /dev/null +++ b/playground/nginx_container/conf/includes/proxy_headers.conf @@ -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; diff --git a/playground/nginx_container/conf/includes/sse_params.conf b/playground/nginx_container/conf/includes/sse_params.conf new file mode 100644 index 0000000..7c9c183 --- /dev/null +++ b/playground/nginx_container/conf/includes/sse_params.conf @@ -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; diff --git a/playground/nginx_container/conf/includes/websocket_params.conf b/playground/nginx_container/conf/includes/websocket_params.conf new file mode 100644 index 0000000..edaf3d0 --- /dev/null +++ b/playground/nginx_container/conf/includes/websocket_params.conf @@ -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; diff --git a/playground/nginx_container/conf/nginx.conf b/playground/nginx_container/conf/nginx.conf new file mode 100644 index 0000000..de7c7ee --- /dev/null +++ b/playground/nginx_container/conf/nginx.conf @@ -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; +} diff --git a/playground/nginx_container/docker-compose.yml b/playground/nginx_container/docker-compose.yml new file mode 100644 index 0000000..d771bcc --- /dev/null +++ b/playground/nginx_container/docker-compose.yml @@ -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 diff --git a/playground/nginx_container/docker-entrypoint.sh b/playground/nginx_container/docker-entrypoint.sh new file mode 100644 index 0000000..6858684 --- /dev/null +++ b/playground/nginx_container/docker-entrypoint.sh @@ -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 "$@" diff --git a/playground/nginx_container/html/502.json b/playground/nginx_container/html/502.json new file mode 100644 index 0000000..14ed3f8 --- /dev/null +++ b/playground/nginx_container/html/502.json @@ -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" +} diff --git a/playground/nginx_container/html/504.json b/playground/nginx_container/html/504.json new file mode 100644 index 0000000..046f8e5 --- /dev/null +++ b/playground/nginx_container/html/504.json @@ -0,0 +1,6 @@ +{ + "status": 504, + "error": "gateway_timeout", + "message": "The upstream Artemis container or device service took too long to respond.", + "timestamp": "auto" +} diff --git a/playground/nginx_container/html/index.html b/playground/nginx_container/html/index.html new file mode 100644 index 0000000..575df81 --- /dev/null +++ b/playground/nginx_container/html/index.html @@ -0,0 +1,70 @@ + + + + + + Artemis Reverse Proxy + + + +
+
+ Ingress Proxy Active +
+

Artemis Cloud Platform

+

The reverse proxy is operational and listening on ports 80 & 443. Connect your backend service to begin routing user sessions and Cuttlefish devices.

+
+ +