Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fdcad1c46 | |||
| 33700448bf | |||
| 4295bdf4d2 | |||
| 18d96193ab |
+58
-15
@@ -1,16 +1,61 @@
|
|||||||
name: Deploy
|
name: Build & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SERVER_IP: ${{ secrets.SERVER_IP }}
|
# REGISTRY secret = "git.vpn.cusano.net/logan" (full image prefix)
|
||||||
SSH_USER: drb
|
REGISTRY: ${{ secrets.REGISTRY }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build & push images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.vpn.cusano.net
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.BUILD_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & push c2-core
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./drb-c2-core
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/c2-core:latest
|
||||||
|
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build & push discord-bot
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./drb-server-discord-bot
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/discord-bot:latest
|
||||||
|
${{ env.REGISTRY }}/discord-bot:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build & push frontend
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: ./drb-frontend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/frontend:latest
|
||||||
|
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to VM
|
name: Deploy to VM
|
||||||
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -21,26 +66,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: |
|
run: |
|
||||||
ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key \
|
ssh -o StrictHostKeyChecking=no \
|
||||||
${{ env.SSH_USER }}@${{ env.SERVER_IP }} << 'ENDSSH'
|
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||||
|
-i /tmp/deploy_key \
|
||||||
|
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
||||||
set -e
|
set -e
|
||||||
cd /opt/drb
|
cd /opt/drb
|
||||||
|
|
||||||
# Pull latest code
|
# Update compose files + mosquitto config
|
||||||
git pull origin main
|
git pull origin main
|
||||||
|
|
||||||
# Rebuild and restart changed services
|
# Pull pre-built images and restart (no build on the VM)
|
||||||
docker compose up -d --build --remove-orphans
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
# Reload Caddy if Caddyfile changed
|
|
||||||
sudo systemctl reload caddy
|
|
||||||
|
|
||||||
# Clean up old images
|
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
ENDSSH
|
ENDSSH
|
||||||
|
|
||||||
- name: Verify health
|
- name: Health check
|
||||||
run: |
|
run: |
|
||||||
sleep 15
|
sleep 20
|
||||||
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
|
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
|
||||||
(echo "Health check failed" && exit 1)
|
(echo "Health check failed" && exit 1)
|
||||||
|
|||||||
+12
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
|
|||||||
drb-frontend/.env
|
drb-frontend/.env
|
||||||
drb-c2-core/gcp-key.json
|
drb-c2-core/gcp-key.json
|
||||||
|
|
||||||
|
# Terraform
|
||||||
|
infra/.terraform/
|
||||||
|
infra/terraform.tfstate
|
||||||
|
infra/terraform.tfstate.backup
|
||||||
|
infra/terraform.tfstate.*.backup
|
||||||
|
infra/.terraform.lock.hcl
|
||||||
|
infra/terraform.tfvars
|
||||||
|
infra/tf.log
|
||||||
|
infra/ansible/inventory.ini
|
||||||
|
infra/ansible/group_vars/all.yml
|
||||||
|
infra/ansible/vault.yml
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
+3
-2
@@ -17,17 +17,17 @@ services:
|
|||||||
- mosquitto_data:/mosquitto/data
|
- mosquitto_data:/mosquitto/data
|
||||||
|
|
||||||
c2-core:
|
c2-core:
|
||||||
|
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||||
build: ./drb-c2-core
|
build: ./drb-c2-core
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8888:8000"
|
- "8888:8000"
|
||||||
env_file: ./drb-c2-core/.env
|
env_file: ./drb-c2-core/.env
|
||||||
volumes:
|
|
||||||
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- mosquitto
|
- mosquitto
|
||||||
|
|
||||||
discord-bot:
|
discord-bot:
|
||||||
|
image: ${REGISTRY}/discord-bot:${TAG:-latest}
|
||||||
build: ./drb-server-discord-bot
|
build: ./drb-server-discord-bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: ./drb-server-discord-bot/.env
|
env_file: ./drb-server-discord-bot/.env
|
||||||
@@ -35,6 +35,7 @@ services:
|
|||||||
- c2-core
|
- c2-core
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: ${REGISTRY}/frontend:${TAG:-latest}
|
||||||
build: ./drb-frontend
|
build: ./drb-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ class Settings(BaseSettings):
|
|||||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||||
service_key: Optional[str] = None
|
service_key: Optional[str] = None
|
||||||
|
|
||||||
# CORS — comma-separated list of allowed origins, or "*" for all
|
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||||
|
upload_max_bytes: int = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||||
|
# Defaults to "*" for local development only.
|
||||||
cors_origins: list[str] = ["*"]
|
cors_origins: list[str] = ["*"]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import HTTPException, Security
|
from fastapi import HTTPException, Security
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
|
|||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
if settings.service_key and token == settings.service_key:
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||||
return {"service": True}
|
return {"service": True}
|
||||||
try:
|
try:
|
||||||
return firebase_auth.verify_id_token(token)
|
return firebase_auth.verify_id_token(token)
|
||||||
@@ -42,3 +45,72 @@ async def require_admin_token(
|
|||||||
if not decoded.get("admin"):
|
if not decoded.get("admin"):
|
||||||
raise HTTPException(status_code=403, detail="Admin access required")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
async def require_service_key(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Accept only the internal service key — used for bot-only endpoints."""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
|
if not settings.service_key:
|
||||||
|
raise HTTPException(status_code=503, detail="Service key not configured")
|
||||||
|
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
||||||
|
raise HTTPException(status_code=403, detail="Service key required")
|
||||||
|
return {"service": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def require_service_key_or_admin(
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
|
) -> dict:
|
||||||
|
"""Accept either the internal service key or a Firebase admin token.
|
||||||
|
|
||||||
|
Used for endpoints that the Discord bot (service key) and dashboard admins
|
||||||
|
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
||||||
|
"""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
|
token = credentials.credentials
|
||||||
|
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||||
|
return {"service": True}
|
||||||
|
try:
|
||||||
|
decoded = firebase_auth.verify_id_token(token)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
if not decoded.get("admin"):
|
||||||
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Simple in-memory sliding-window rate limiter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Not persistent across restarts; good enough for a single-instance deployment.
|
||||||
|
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
||||||
|
|
||||||
|
class _RateLimiter:
|
||||||
|
def __init__(self, max_calls: int, window_seconds: int):
|
||||||
|
self.max_calls = max_calls
|
||||||
|
self.window = window_seconds
|
||||||
|
self._log: dict[str, deque] = defaultdict(deque)
|
||||||
|
|
||||||
|
def check(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
q = self._log[key]
|
||||||
|
while q and now - q[0] > self.window:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= self.max_calls:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Rate limit exceeded. Please wait before trying again.",
|
||||||
|
)
|
||||||
|
q.append(now)
|
||||||
|
|
||||||
|
|
||||||
|
# Shared limiter instances
|
||||||
|
# trip chat: 20 requests per user per 5 minutes
|
||||||
|
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
||||||
|
# per-incident summarize: 5 per incident per 10 minutes
|
||||||
|
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||||
|
# vocabulary bootstrap: 2 per system per hour
|
||||||
|
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ from app.config import settings
|
|||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
|
||||||
async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||||
|
"""Return a safe GCS object name derived from the call_id.
|
||||||
|
|
||||||
|
We ignore the client-supplied filename entirely and derive the name from the
|
||||||
|
call_id (which we control) to prevent path traversal via crafted filenames.
|
||||||
|
The original extension is preserved only if it's a known audio type.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||||
|
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
||||||
|
ext = ".mp3"
|
||||||
|
return f"{call_id}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||||
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
||||||
if not settings.gcs_bucket:
|
if not settings.gcs_bucket:
|
||||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
||||||
@@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
|||||||
client = storage.Client()
|
client = storage.Client()
|
||||||
signing_creds = None
|
signing_creds = None
|
||||||
bucket = client.bucket(settings.gcs_bucket)
|
bucket = client.bucket(settings.gcs_bucket)
|
||||||
blob = bucket.blob(f"calls/{filename}")
|
safe_name = _safe_audio_filename(filename, call_id)
|
||||||
|
blob = bucket.blob(f"calls/{safe_name}")
|
||||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||||
if signing_creds:
|
if signing_creds:
|
||||||
return blob.generate_signed_url(
|
return blob.generate_signed_url(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||||
from app.models import IncidentCreate, IncidentUpdate
|
from app.models import IncidentCreate, IncidentUpdate
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
||||||
|
|
||||||
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/summarize")
|
@router.post("/summarize")
|
||||||
async def summarize_all_stale(background_tasks: BackgroundTasks):
|
async def summarize_all_stale(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
||||||
from app.internal.summarizer import _run_summary_pass
|
from app.internal.summarizer import _run_summary_pass
|
||||||
background_tasks.add_task(_run_summary_pass)
|
background_tasks.add_task(_run_summary_pass)
|
||||||
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{incident_id}/summarize")
|
@router.post("/{incident_id}/summarize")
|
||||||
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks):
|
async def summarize_incident(
|
||||||
|
incident_id: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
decoded: dict = Depends(require_service_or_firebase_token),
|
||||||
|
):
|
||||||
"""Immediately run the summarizer for a specific incident."""
|
"""Immediately run the summarizer for a specific incident."""
|
||||||
from app.internal.summarizer import _summarize_incident
|
from app.internal.summarizer import _summarize_incident
|
||||||
inc = await fstore.doc_get("incidents", incident_id)
|
inc = await fstore.doc_get("incidents", incident_id)
|
||||||
if not inc:
|
if not inc:
|
||||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
|
# Rate limit by incident ID to prevent repeated expensive LLM calls
|
||||||
|
summarize_limiter.check(incident_id)
|
||||||
background_tasks.add_task(_summarize_incident, inc)
|
background_tasks.add_task(_summarize_incident, inc)
|
||||||
return {"ok": True, "incident_id": incident_id}
|
return {"ok": True, "incident_id": incident_id}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
|
|||||||
from app.models import CommandPayload
|
from app.models import CommandPayload
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.mqtt_handler import mqtt_handler
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
||||||
from app.routers.tokens import assign_token, release_token
|
from app.routers.tokens import assign_token, release_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
||||||
@@ -55,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{node_id}/command")
|
@router.post("/{node_id}/command")
|
||||||
async def send_command(node_id: str, cmd: CommandPayload):
|
async def send_command(
|
||||||
|
node_id: str,
|
||||||
|
cmd: CommandPayload,
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
|
):
|
||||||
node = await fstore.doc_get("nodes", node_id)
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
@@ -108,6 +112,7 @@ async def assign_system(
|
|||||||
system_id: str,
|
system_id: str,
|
||||||
hardware_preset: str = Query("rtl-sdr-v3"),
|
hardware_preset: str = Query("rtl-sdr-v3"),
|
||||||
ppm_override: Optional[float] = Query(None),
|
ppm_override: Optional[float] = Query(None),
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Assign a system to a node. Fetches the system config from Firestore
|
Assign a system to a node. Fetches the system config from Firestore
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from app.models import SystemCreate, SystemRecord
|
from app.models import SystemCreate, SystemRecord
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token, bootstrap_limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/systems", tags=["systems"])
|
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_system(body: SystemCreate):
|
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||||
system_id = str(uuid.uuid4())
|
system_id = str(uuid.uuid4())
|
||||||
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
||||||
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
||||||
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{system_id}")
|
@router.put("/{system_id}")
|
||||||
async def update_system(system_id: str, body: SystemCreate):
|
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{system_id}", status_code=204)
|
@router.delete("/{system_id}", status_code=204)
|
||||||
async def delete_system(system_id: str):
|
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
|
|||||||
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
||||||
|
|
||||||
@router.put("/{system_id}/ai-flags")
|
@router.put("/{system_id}/ai-flags")
|
||||||
async def update_system_ai_flags(system_id: str, body: AiFlagsBody):
|
async def update_system_ai_flags(
|
||||||
|
system_id: str,
|
||||||
|
body: AiFlagsBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Set per-system AI flag overrides. Only fields included in the body are
|
Set per-system AI flag overrides. Only fields included in the body are
|
||||||
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
||||||
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{system_id}/ten-codes")
|
@router.put("/{system_id}/ten-codes")
|
||||||
async def update_ten_codes(system_id: str, body: TenCodesBody):
|
async def update_ten_codes(
|
||||||
|
system_id: str,
|
||||||
|
body: TenCodesBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Replace the ten-code dictionary for a system."""
|
"""Replace the ten-code dictionary for a system."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
|
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
|
||||||
async def bootstrap_vocabulary(system_id: str):
|
async def bootstrap_vocabulary(
|
||||||
|
system_id: str,
|
||||||
|
decoded: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
|
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
bootstrap_limiter.check(system_id)
|
||||||
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
||||||
terms = await bootstrap_system_vocabulary(system_id)
|
terms = await bootstrap_system_vocabulary(system_id)
|
||||||
return {"added": len(terms), "terms": terms}
|
return {"added": len(terms), "terms": terms}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/terms")
|
@router.post("/{system_id}/vocabulary/terms")
|
||||||
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
async def add_vocabulary_term(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Manually add a term to the approved vocabulary."""
|
"""Manually add a term to the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{system_id}/vocabulary/terms")
|
@router.delete("/{system_id}/vocabulary/terms")
|
||||||
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
async def remove_vocabulary_term(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Remove a term from the approved vocabulary."""
|
"""Remove a term from the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/pending/approve")
|
@router.post("/{system_id}/vocabulary/pending/approve")
|
||||||
async def approve_pending(system_id: str, body: VocabularyTermBody):
|
async def approve_pending(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Move a pending induction suggestion into the approved vocabulary."""
|
"""Move a pending induction suggestion into the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/pending/dismiss")
|
@router.post("/{system_id}/vocabulary/pending/dismiss")
|
||||||
async def dismiss_pending(system_id: str, body: VocabularyTermBody):
|
async def dismiss_pending(
|
||||||
|
system_id: str,
|
||||||
|
body: VocabularyTermBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Dismiss a pending induction suggestion without adding it."""
|
"""Dismiss a pending induction suggestion without adding it."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||||
|
|
||||||
@@ -22,13 +23,13 @@ async def list_tokens():
|
|||||||
"""List all tokens. The actual token string is masked for safety."""
|
"""List all tokens. The actual token string is masked for safety."""
|
||||||
tokens = await fstore.collection_list("bot_tokens")
|
tokens = await fstore.collection_list("bot_tokens")
|
||||||
return [
|
return [
|
||||||
{**t, "token": t["token"][:10] + "…" + t["token"][-4:]}
|
{**t, "token": "•••" + t["token"][-4:]}
|
||||||
for t in tokens
|
for t in tokens
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def add_token(body: TokenCreate):
|
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
|
||||||
token_id = str(uuid.uuid4())
|
token_id = str(uuid.uuid4())
|
||||||
doc = {
|
doc = {
|
||||||
"token_id": token_id,
|
"token_id": token_id,
|
||||||
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/flush", status_code=200)
|
@router.post("/flush", status_code=200)
|
||||||
async def flush_tokens():
|
async def flush_tokens(_: dict = Depends(require_admin_token)):
|
||||||
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
|
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
|
||||||
def _find():
|
def _find():
|
||||||
from app.internal.firestore import db
|
from app.internal.firestore import db
|
||||||
@@ -61,7 +62,11 @@ async def flush_tokens():
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
||||||
async def set_preferred_system(token_id: str, system_id: str):
|
async def set_preferred_system(
|
||||||
|
token_id: str,
|
||||||
|
system_id: str,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Mark this token as the preferred bot for a system.
|
Mark this token as the preferred bot for a system.
|
||||||
When a discord_join is issued for any node in that system, this token
|
When a discord_join is issued for any node in that system, this token
|
||||||
@@ -89,7 +94,7 @@ async def set_preferred_system(token_id: str, system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{token_id}", status_code=204)
|
@router.delete("/{token_id}", status_code=204)
|
||||||
async def delete_token(token_id: str):
|
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
||||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, "Token not found.")
|
raise HTTPException(404, "Token not found.")
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import json
|
|||||||
import httpx
|
import httpx
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.models import TripCreate, TripEventCreate, AttendeeAction
|
from app.models import TripCreate, TripEventCreate, AttendeeAction
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
from app.internal.auth import (
|
||||||
|
require_service_or_firebase_token,
|
||||||
|
require_service_key,
|
||||||
|
require_service_key_or_admin,
|
||||||
|
trip_chat_limiter,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||||
|
|
||||||
@@ -180,7 +186,7 @@ async def get_trip(trip_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{trip_id}")
|
@router.delete("/{trip_id}")
|
||||||
async def delete_trip(trip_id: str):
|
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -192,7 +198,12 @@ async def delete_trip(trip_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{trip_id}/join")
|
@router.post("/{trip_id}/join")
|
||||||
async def join_trip(trip_id: str, body: AttendeeAction):
|
async def join_trip(
|
||||||
|
trip_id: str,
|
||||||
|
body: AttendeeAction,
|
||||||
|
_: dict = Depends(require_service_key),
|
||||||
|
):
|
||||||
|
"""Join a trip as an attendee. Only the Discord bot (service key) may call this."""
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -203,7 +214,12 @@ async def join_trip(trip_id: str, body: AttendeeAction):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{trip_id}/leave")
|
@router.post("/{trip_id}/leave")
|
||||||
async def leave_trip(trip_id: str, body: AttendeeAction):
|
async def leave_trip(
|
||||||
|
trip_id: str,
|
||||||
|
body: AttendeeAction,
|
||||||
|
_: dict = Depends(require_service_key),
|
||||||
|
):
|
||||||
|
"""Leave a trip. Only the Discord bot (service key) may call this."""
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -253,7 +269,11 @@ async def create_event(trip_id: str, body: TripEventCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{trip_id}/events/{event_id}")
|
@router.delete("/{trip_id}/events/{event_id}")
|
||||||
async def delete_event(trip_id: str, event_id: str):
|
async def delete_event(
|
||||||
|
trip_id: str,
|
||||||
|
event_id: str,
|
||||||
|
_: dict = Depends(require_service_key_or_admin),
|
||||||
|
):
|
||||||
event = await fstore.doc_get("trip_events", event_id)
|
event = await fstore.doc_get("trip_events", event_id)
|
||||||
if not event or event.get("trip_id") != trip_id:
|
if not event or event.get("trip_id") != trip_id:
|
||||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||||
@@ -262,7 +282,13 @@ async def delete_event(trip_id: str, event_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{trip_id}/events/{event_id}/join")
|
@router.post("/{trip_id}/events/{event_id}/join")
|
||||||
async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
|
async def join_event(
|
||||||
|
trip_id: str,
|
||||||
|
event_id: str,
|
||||||
|
body: AttendeeAction,
|
||||||
|
_: dict = Depends(require_service_key),
|
||||||
|
):
|
||||||
|
"""Join an event. Only the Discord bot (service key) may call this."""
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -278,7 +304,13 @@ async def join_event(trip_id: str, event_id: str, body: AttendeeAction):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{trip_id}/events/{event_id}/leave")
|
@router.post("/{trip_id}/events/{event_id}/leave")
|
||||||
async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
|
async def leave_event(
|
||||||
|
trip_id: str,
|
||||||
|
event_id: str,
|
||||||
|
body: AttendeeAction,
|
||||||
|
_: dict = Depends(require_service_key),
|
||||||
|
):
|
||||||
|
"""Leave an event. Only the Discord bot (service key) may call this."""
|
||||||
event = await fstore.doc_get("trip_events", event_id)
|
event = await fstore.doc_get("trip_events", event_id)
|
||||||
if not event or event.get("trip_id") != trip_id:
|
if not event or event.get("trip_id") != trip_id:
|
||||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||||
@@ -293,10 +325,18 @@ async def leave_event(trip_id: str, event_id: str, body: AttendeeAction):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/{trip_id}/chat")
|
@router.post("/{trip_id}/chat")
|
||||||
async def trip_chat(trip_id: str, body: ChatRequest):
|
async def trip_chat(
|
||||||
|
trip_id: str,
|
||||||
|
body: ChatRequest,
|
||||||
|
decoded: dict = Depends(require_service_or_firebase_token),
|
||||||
|
):
|
||||||
if not settings.openai_api_key:
|
if not settings.openai_api_key:
|
||||||
raise HTTPException(503, "OpenAI not configured.")
|
raise HTTPException(503, "OpenAI not configured.")
|
||||||
|
|
||||||
|
# Rate limit by caller identity
|
||||||
|
caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown")
|
||||||
|
trip_chat_limiter.check(f"{caller_key}:{trip_id}")
|
||||||
|
|
||||||
trip = await fstore.doc_get("trips", trip_id)
|
trip = await fstore.doc_get("trips", trip_id)
|
||||||
if not trip:
|
if not trip:
|
||||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||||
@@ -306,10 +346,20 @@ async def trip_chat(trip_id: str, body: ChatRequest):
|
|||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
oai = AsyncOpenAI(api_key=settings.openai_api_key)
|
oai = AsyncOpenAI(api_key=settings.openai_api_key)
|
||||||
|
|
||||||
|
# Strip history to only user/assistant roles to prevent prompt injection
|
||||||
|
safe_history = [
|
||||||
|
{"role": m.role, "content": m.content}
|
||||||
|
for m in body.history[-20:]
|
||||||
|
if m.role in ("user", "assistant")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Truncate message to prevent oversized single requests
|
||||||
|
user_message = body.message[:2000]
|
||||||
|
|
||||||
messages: list[dict] = [
|
messages: list[dict] = [
|
||||||
{"role": "system", "content": _build_system_prompt(trip, events)},
|
{"role": "system", "content": _build_system_prompt(trip, events)},
|
||||||
*[{"role": m.role, "content": m.content} for m in body.history[-20:]],
|
*safe_history,
|
||||||
{"role": "user", "content": body.message},
|
{"role": "user", "content": user_message},
|
||||||
]
|
]
|
||||||
|
|
||||||
suggestions: list[dict] = []
|
suggestions: list[dict] = []
|
||||||
@@ -340,7 +390,10 @@ async def trip_chat(trip_id: str, body: ChatRequest):
|
|||||||
args = json.loads(tc.function.arguments)
|
args = json.loads(tc.function.arguments)
|
||||||
|
|
||||||
if tc.function.name == "search_places":
|
if tc.function.name == "search_places":
|
||||||
results = await _places_search(args.get("query", ""), args.get("near", ""))
|
# Limit query string lengths before hitting the Maps API
|
||||||
|
query = str(args.get("query", ""))[:200]
|
||||||
|
near = str(args.get("near", ""))[:200]
|
||||||
|
results = await _places_search(query, near)
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tc.id,
|
"tool_call_id": tc.id,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|||||||
from app.internal.storage import upload_audio
|
from app.internal.storage import upload_audio
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter(tags=["upload"])
|
router = APIRouter(tags=["upload"])
|
||||||
|
|
||||||
@@ -43,9 +44,10 @@ async def upload_call_audio(
|
|||||||
data = await file.read()
|
data = await file.read()
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(400, "Empty file.")
|
raise HTTPException(400, "Empty file.")
|
||||||
|
if len(data) > settings.upload_max_bytes:
|
||||||
|
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||||
|
|
||||||
filename = file.filename
|
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||||
audio_url = await upload_audio(data, filename)
|
|
||||||
|
|
||||||
if audio_url:
|
if audio_url:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.PHONY: tf-init tf-plan tf-apply tf-destroy deploy setup-ansible
|
||||||
|
|
||||||
|
ANSIBLE_DIR = ansible
|
||||||
|
INVENTORY = $(ANSIBLE_DIR)/inventory.ini
|
||||||
|
|
||||||
|
# ── Terraform ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
tf-init:
|
||||||
|
terraform init
|
||||||
|
|
||||||
|
tf-plan:
|
||||||
|
terraform plan
|
||||||
|
|
||||||
|
tf-apply:
|
||||||
|
terraform apply
|
||||||
|
@echo ""
|
||||||
|
@echo "Server IP: $$(terraform output -raw server_ip)"
|
||||||
|
@echo "Update $(INVENTORY) with this IP, then run: make deploy"
|
||||||
|
|
||||||
|
tf-destroy:
|
||||||
|
@echo "WARNING: This will destroy the VM and all data on it."
|
||||||
|
@read -p "Type 'yes' to confirm: " confirm && [ "$$confirm" = "yes" ] && terraform destroy
|
||||||
|
|
||||||
|
# ── Ansible ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# First-time setup: waits for Docker, clones repo, starts stack.
|
||||||
|
setup:
|
||||||
|
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/site.yml --ask-vault-pass
|
||||||
|
|
||||||
|
# Update deploy: sync code + restart changed containers. Run this after every push.
|
||||||
|
deploy:
|
||||||
|
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/deploy.yml --ask-vault-pass
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ip:
|
||||||
|
@terraform output -raw server_ip
|
||||||
|
|
||||||
|
ssh:
|
||||||
|
ssh drb@$$(terraform output -raw server_ip)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
# Lightweight update deploy — runs in ~60s.
|
||||||
|
# Use this for every code push after the initial site.yml run.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass
|
||||||
|
|
||||||
|
- name: Deploy DRB update
|
||||||
|
hosts: drb
|
||||||
|
become: true
|
||||||
|
vars_files:
|
||||||
|
- vault.yml
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- deploy
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Copy to group_vars/all.yml — safe to commit (no secrets here).
|
||||||
|
|
||||||
|
domain: example.com # must match Terraform var.domain
|
||||||
|
app_dir: /opt/drb
|
||||||
|
ssh_user: drb
|
||||||
|
|
||||||
|
# Path to the local repo root on your machine (used for rsync).
|
||||||
|
# Trailing slash is intentional — rsync copies contents, not the folder itself.
|
||||||
|
local_repo_path: "/path/to/Version 5C/Server/"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Copy to inventory.ini and replace SERVER_IP with the Terraform output.
|
||||||
|
# Get it with: cd ../terraform && terraform output server_ip
|
||||||
|
|
||||||
|
[drb]
|
||||||
|
SERVER_IP ansible_user=drb ansible_ssh_private_key_file=~/.ssh/id_ed25519
|
||||||
|
|
||||||
|
[drb:vars]
|
||||||
|
ansible_python_interpreter=/usr/bin/python3
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
|
||||||
|
# Images are built and pushed by Gitea CI — this role never builds on the VM.
|
||||||
|
|
||||||
|
- name: Clone repo (skipped if already present)
|
||||||
|
git:
|
||||||
|
repo: "{{ repo_url }}"
|
||||||
|
dest: "{{ app_dir }}"
|
||||||
|
version: main
|
||||||
|
update: false
|
||||||
|
become: false
|
||||||
|
|
||||||
|
- name: Set ownership of app directory
|
||||||
|
file:
|
||||||
|
path: "{{ app_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
recurse: true
|
||||||
|
|
||||||
|
- name: Template top-level .env (docker-compose MQTT creds + registry)
|
||||||
|
template:
|
||||||
|
src: root.env.j2
|
||||||
|
dest: "{{ app_dir }}/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Template c2-core .env
|
||||||
|
template:
|
||||||
|
src: c2-core.env.j2
|
||||||
|
dest: "{{ app_dir }}/drb-c2-core/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Template discord-bot .env
|
||||||
|
template:
|
||||||
|
src: discord-bot.env.j2
|
||||||
|
dest: "{{ app_dir }}/drb-server-discord-bot/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Template frontend .env
|
||||||
|
template:
|
||||||
|
src: frontend.env.j2
|
||||||
|
dest: "{{ app_dir }}/drb-frontend/.env"
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Deploy Caddyfile
|
||||||
|
template:
|
||||||
|
src: Caddyfile.j2
|
||||||
|
dest: /etc/caddy/Caddyfile
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
notify: Reload Caddy
|
||||||
|
|
||||||
|
- name: Log in to container registry
|
||||||
|
command: >
|
||||||
|
docker login {{ vault_registry_host }}
|
||||||
|
-u {{ vault_registry_user }}
|
||||||
|
-p {{ vault_registry_token }}
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Pull pre-built images and start stack
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ app_dir }}"
|
||||||
|
files:
|
||||||
|
- docker-compose.yml
|
||||||
|
- docker-compose.prod.yml
|
||||||
|
pull: always
|
||||||
|
build: never
|
||||||
|
state: present
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Managed by Ansible — do not edit manually on the server.
|
||||||
|
|
||||||
|
api.{{ domain }} {
|
||||||
|
reverse_proxy localhost:8888 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.{{ domain }} {
|
||||||
|
reverse_proxy localhost:3000 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# drb-c2-core environment — Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
MQTT_BROKER=mosquitto
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER={{ vault_mqtt_c2_user }}
|
||||||
|
MQTT_PASS={{ vault_mqtt_c2_pass }}
|
||||||
|
|
||||||
|
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
|
||||||
|
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
|
||||||
|
FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||||
|
GCS_BUCKET={{ vault_gcs_bucket }}
|
||||||
|
|
||||||
|
OPENAI_API_KEY={{ vault_openai_api_key }}
|
||||||
|
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
||||||
|
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||||
|
|
||||||
|
SERVICE_KEY={{ vault_service_key }}
|
||||||
|
NODE_API_KEY={{ vault_node_api_key }}
|
||||||
|
|
||||||
|
CORS_ORIGINS=["https://app.{{ domain }}"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# drb-server-discord-bot environment — Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
DISCORD_TOKEN={{ vault_discord_token }}
|
||||||
|
C2_URL=http://c2-core:8000
|
||||||
|
C2_SERVICE_KEY={{ vault_service_key }}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# drb-frontend environment — Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
NEXT_PUBLIC_C2_URL=https://api.{{ domain }}
|
||||||
|
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY={{ vault_firebase_api_key }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN={{ vault_firebase_auth_domain }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_PROJECT_ID={{ vault_firebase_project_id }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET={{ vault_firebase_storage_bucket }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID={{ vault_firebase_messaging_sender_id }}
|
||||||
|
NEXT_PUBLIC_FIREBASE_APP_ID={{ vault_firebase_app_id }}
|
||||||
|
NEXT_PUBLIC_FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Top-level docker-compose environment — MQTT credentials and registry prefix.
|
||||||
|
# Managed by Ansible. Do not edit manually.
|
||||||
|
|
||||||
|
MQTT_C2_USER={{ vault_mqtt_c2_user }}
|
||||||
|
MQTT_C2_PASS={{ vault_mqtt_c2_pass }}
|
||||||
|
MQTT_NODE_USER={{ vault_mqtt_node_user }}
|
||||||
|
MQTT_NODE_PASS={{ vault_mqtt_node_pass }}
|
||||||
|
|
||||||
|
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
|
||||||
|
REGISTRY={{ vault_registry }}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
# Full first-time setup: waits for the VM's startup.sh to finish installing
|
||||||
|
# Docker, then deploys the stack. Safe to re-run — all tasks are idempotent.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ansible-playbook -i inventory.ini site.yml --ask-vault-pass
|
||||||
|
|
||||||
|
- name: Bootstrap + deploy DRB server
|
||||||
|
hosts: drb
|
||||||
|
become: true
|
||||||
|
vars_files:
|
||||||
|
- vault.yml
|
||||||
|
|
||||||
|
pre_tasks:
|
||||||
|
- name: Install rsync
|
||||||
|
apt:
|
||||||
|
name: rsync
|
||||||
|
state: present
|
||||||
|
update_cache: false
|
||||||
|
|
||||||
|
- name: Wait for Docker (startup.sh runs async on first boot)
|
||||||
|
command: docker info
|
||||||
|
register: _docker
|
||||||
|
until: _docker.rc == 0
|
||||||
|
retries: 30
|
||||||
|
delay: 10
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Create 2 GB swap file
|
||||||
|
command: fallocate -l 2G /swapfile
|
||||||
|
args:
|
||||||
|
creates: /swapfile
|
||||||
|
|
||||||
|
- name: Set swap file permissions
|
||||||
|
file:
|
||||||
|
path: /swapfile
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
|
- name: Format swap file
|
||||||
|
command: mkswap /swapfile
|
||||||
|
register: _mkswap
|
||||||
|
changed_when: _mkswap.rc == 0
|
||||||
|
|
||||||
|
- name: Enable swap
|
||||||
|
command: swapon /swapfile
|
||||||
|
register: _swapon
|
||||||
|
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
|
||||||
|
changed_when: _swapon.rc == 0
|
||||||
|
|
||||||
|
- name: Persist swap in fstab
|
||||||
|
lineinfile:
|
||||||
|
path: /etc/fstab
|
||||||
|
line: "/swapfile none swap sw 0 0"
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Set swappiness to 10 (use swap only under pressure)
|
||||||
|
sysctl:
|
||||||
|
name: vm.swappiness
|
||||||
|
value: "10"
|
||||||
|
sysctl_set: true
|
||||||
|
state: present
|
||||||
|
reload: true
|
||||||
|
|
||||||
|
- name: Add deploy user to docker group
|
||||||
|
user:
|
||||||
|
name: "{{ ssh_user }}"
|
||||||
|
groups: docker
|
||||||
|
append: true
|
||||||
|
|
||||||
|
- name: Create app directory
|
||||||
|
file:
|
||||||
|
path: "{{ app_dir }}"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ ssh_user }}"
|
||||||
|
group: "{{ ssh_user }}"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- deploy
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Template for your Ansible Vault secrets file.
|
||||||
|
# Copy to vault.yml, fill in values, then encrypt:
|
||||||
|
# ansible-vault encrypt vault.yml
|
||||||
|
# Edit later with:
|
||||||
|
# ansible-vault edit vault.yml
|
||||||
|
|
||||||
|
# ── MQTT ─────────────────────────────────────────────────────────────────────
|
||||||
|
vault_mqtt_c2_user: drb-c2-core
|
||||||
|
vault_mqtt_c2_pass: "CHANGE_ME"
|
||||||
|
vault_mqtt_node_user: drb-node
|
||||||
|
vault_mqtt_node_pass: "CHANGE_ME"
|
||||||
|
|
||||||
|
# ── C2 Core ───────────────────────────────────────────────────────────────────
|
||||||
|
vault_service_key: "" # openssl rand -hex 32
|
||||||
|
vault_node_api_key: "" # openssl rand -hex 32
|
||||||
|
vault_openai_api_key: ""
|
||||||
|
vault_google_maps_api_key: ""
|
||||||
|
vault_gemini_api_key: ""
|
||||||
|
vault_gcs_bucket: "your-gcs-bucket-name"
|
||||||
|
vault_firestore_database: "c2-server"
|
||||||
|
|
||||||
|
# ── Gitea Container Registry ──────────────────────────────────────────────────
|
||||||
|
vault_registry_host: "git.vpn.cusano.net"
|
||||||
|
vault_registry_user: "logan"
|
||||||
|
vault_registry_token: "" # Gitea access token with package:write scope
|
||||||
|
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
|
||||||
|
|
||||||
|
# ── Discord Bot ───────────────────────────────────────────────────────────────
|
||||||
|
vault_discord_token: ""
|
||||||
|
|
||||||
|
# ── Frontend (Firebase) ───────────────────────────────────────────────────────
|
||||||
|
vault_firebase_api_key: ""
|
||||||
|
vault_firebase_auth_domain: ""
|
||||||
|
vault_firebase_project_id: ""
|
||||||
|
vault_firebase_storage_bucket: ""
|
||||||
|
vault_firebase_messaging_sender_id: ""
|
||||||
|
vault_firebase_app_id: ""
|
||||||
|
|
||||||
|
# No GCP key needed — the VM uses Application Default Credentials via the
|
||||||
|
# GCE metadata server. Terraform grants the required IAM roles at apply time.
|
||||||
+71
-20
@@ -8,10 +8,11 @@ terraform {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Store state in GCS — create the bucket manually once before first apply
|
# Store state in GCS — create the bucket manually once before first apply
|
||||||
backend "gcs" {
|
# Uncomment once GCS bucket permissions are confirmed working.
|
||||||
bucket = "drb-tf-state"
|
# backend "gcs" {
|
||||||
prefix = "drb/state"
|
# bucket = "drb-tf-state"
|
||||||
}
|
# prefix = "drb/state"
|
||||||
|
# }
|
||||||
}
|
}
|
||||||
|
|
||||||
provider "google" {
|
provider "google" {
|
||||||
@@ -19,6 +20,10 @@ provider "google" {
|
|||||||
region = var.region
|
region = var.region
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Pull live project metadata (number, name) without hardcoding them.
|
||||||
|
data "google_project" "current" {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Static external IP
|
# Static external IP
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -87,8 +92,8 @@ resource "google_compute_instance" "drb_server" {
|
|||||||
boot_disk {
|
boot_disk {
|
||||||
initialize_params {
|
initialize_params {
|
||||||
image = "debian-cloud/debian-12"
|
image = "debian-cloud/debian-12"
|
||||||
size = 30 # GB — enough for Docker images + mosquitto data
|
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
|
||||||
type = "pd-balanced"
|
type = "pd-standard"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +111,8 @@ resource "google_compute_instance" "drb_server" {
|
|||||||
# Startup script runs once on first boot to install Docker + Caddy
|
# Startup script runs once on first boot to install Docker + Caddy
|
||||||
metadata_startup_script = file("${path.module}/startup.sh")
|
metadata_startup_script = file("${path.module}/startup.sh")
|
||||||
|
|
||||||
# Allow the VM to pull from Artifact Registry using its service account
|
# The default compute service account with cloud-platform scope gives the VM
|
||||||
|
# full access to GCS and Firestore in the same project — no key file needed.
|
||||||
service_account {
|
service_account {
|
||||||
scopes = ["cloud-platform"]
|
scopes = ["cloud-platform"]
|
||||||
}
|
}
|
||||||
@@ -118,21 +124,66 @@ resource "google_compute_instance" "drb_server" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cloud DNS records
|
# IAM — grant the VM's default compute SA access to Firestore and GCS.
|
||||||
|
# Since Firebase/GCS already live in the same project, no key file is needed —
|
||||||
|
# the VM authenticates via the metadata server (ADC).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
resource "google_dns_record_set" "app" {
|
locals {
|
||||||
name = "app.${var.domain}."
|
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
|
||||||
type = "A"
|
|
||||||
ttl = 300
|
|
||||||
managed_zone = var.dns_zone_name
|
|
||||||
rrdatas = [google_compute_address.drb.address]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "google_dns_record_set" "api" {
|
resource "google_project_iam_member" "drb_firestore" {
|
||||||
name = "api.${var.domain}."
|
project = var.project_id
|
||||||
type = "A"
|
role = "roles/datastore.user"
|
||||||
ttl = 300
|
member = local.compute_sa
|
||||||
managed_zone = var.dns_zone_name
|
|
||||||
rrdatas = [google_compute_address.drb.address]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resource "google_project_iam_member" "drb_gcs" {
|
||||||
|
project = var.project_id
|
||||||
|
role = "roles/storage.objectAdmin"
|
||||||
|
member = local.compute_sa
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Firestore database — import existing, manages schema/settings going forward
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
id = "projects/${var.project_id}/databases/${var.firestore_database}"
|
||||||
|
to = google_firestore_database.c2
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_firestore_database" "c2" {
|
||||||
|
project = var.project_id
|
||||||
|
name = var.firestore_database
|
||||||
|
location_id = var.firestore_location
|
||||||
|
type = "FIRESTORE_NATIVE"
|
||||||
|
|
||||||
|
# Prevent accidental deletion of the live database
|
||||||
|
deletion_policy = "DELETE"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GCS bucket — audio recordings. Import existing bucket.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import {
|
||||||
|
id = var.audio_bucket_name
|
||||||
|
to = google_storage_bucket.audio
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "google_storage_bucket" "audio" {
|
||||||
|
project = var.project_id
|
||||||
|
name = var.audio_bucket_name
|
||||||
|
location = var.audio_bucket_location
|
||||||
|
uniform_bucket_level_access = true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DNS — managed in AWS Route 53 (cusano.net is there).
|
||||||
|
# After terraform apply, add these A records in Route 53:
|
||||||
|
# app.drb.cusano.net → server_ip output
|
||||||
|
# api.drb.cusano.net → server_ip output
|
||||||
|
# Or use a single wildcard: *.drb.cusano.net → server_ip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -10,3 +10,13 @@ output "app_url" {
|
|||||||
output "api_url" {
|
output "api_url" {
|
||||||
value = "https://api.${var.domain}"
|
value = "https://api.${var.domain}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output "project_number" {
|
||||||
|
value = data.google_project.current.number
|
||||||
|
description = "GCP project number (useful for service account references)"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "ssh_command" {
|
||||||
|
value = "ssh ${var.ssh_user}@${google_compute_address.drb.address}"
|
||||||
|
description = "SSH command to reach the server (should rarely be needed)"
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Copy to terraform.tfvars and fill in values.
|
||||||
|
# terraform.tfvars is gitignored — never commit it.
|
||||||
|
|
||||||
|
project_id = "your-gcp-project-id" # gcloud config get-value project
|
||||||
|
region = "us-central1"
|
||||||
|
zone = "us-central1-a"
|
||||||
|
|
||||||
|
domain = "drb.cusano.net" # DNS is on AWS Route 53 — add A records manually after apply
|
||||||
|
|
||||||
|
machine_type = "e2-standard-2" # 2 vCPU / 8 GB — adjust if needed
|
||||||
|
|
||||||
|
ssh_user = "drb"
|
||||||
|
ssh_public_key = "ssh-ed25519 AAAA... user@host" # cat ~/.ssh/id_ed25519.pub
|
||||||
|
|
||||||
|
# Your IP + any CI runner IPs that need SSH access
|
||||||
|
allowed_ssh_cidrs = ["YOUR_IP/32"]
|
||||||
|
|
||||||
|
# Existing GCS bucket for audio recordings (bucket must already exist — imported into state)
|
||||||
|
audio_bucket_name = "your-audio-bucket-name"
|
||||||
|
audio_bucket_location = "US-CENTRAL1" # must match existing bucket location exactly — check GCP console
|
||||||
|
|
||||||
|
# Existing Firestore database ID and location (imported into state)
|
||||||
|
firestore_database = "c2-server"
|
||||||
|
firestore_location = "nam5" # nam5 = us-central, eur3 = europe, us-east1 = us-east
|
||||||
+23
-5
@@ -20,11 +20,6 @@ variable "domain" {
|
|||||||
type = string
|
type = string
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "dns_zone_name" {
|
|
||||||
description = "Cloud DNS managed zone name"
|
|
||||||
type = string
|
|
||||||
}
|
|
||||||
|
|
||||||
variable "machine_type" {
|
variable "machine_type" {
|
||||||
description = "Compute Engine machine type"
|
description = "Compute Engine machine type"
|
||||||
type = string
|
type = string
|
||||||
@@ -46,3 +41,26 @@ variable "allowed_ssh_cidrs" {
|
|||||||
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
|
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
|
||||||
type = list(string)
|
type = list(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
variable "audio_bucket_name" {
|
||||||
|
description = "Existing GCS bucket name for call audio recordings"
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "audio_bucket_location" {
|
||||||
|
description = "GCS bucket location — must match the existing bucket's location exactly"
|
||||||
|
type = string
|
||||||
|
default = "US-CENTRAL1"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "firestore_database" {
|
||||||
|
description = "Firestore database ID (e.g. c2-server)"
|
||||||
|
type = string
|
||||||
|
default = "c2-server"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "firestore_location" {
|
||||||
|
description = "Firestore multi-region location (nam5 = us-central, eur3 = europe)"
|
||||||
|
type = string
|
||||||
|
default = "nam5"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user