Compare commits
6 Commits
3defdf18dc
...
fa5f91c0fa
| Author | SHA1 | Date | |
|---|---|---|---|
| fa5f91c0fa | |||
| 57ff9f8ea3 | |||
| 9fdcad1c46 | |||
| 33700448bf | |||
| 4295bdf4d2 | |||
| a1c91c5ed3 |
@@ -0,0 +1,89 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
# REGISTRY secret = "git.vpn.cusano.net/logan" (full image prefix)
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
|
||||
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:
|
||||
name: Deploy to VM
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Write SSH key
|
||||
run: |
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no \
|
||||
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||
-i /tmp/deploy_key \
|
||||
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
||||
set -e
|
||||
cd /opt/drb
|
||||
|
||||
# Update compose files + mosquitto config
|
||||
git pull origin main
|
||||
|
||||
# Pull pre-built images and restart (no build on the VM)
|
||||
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
|
||||
docker image prune -f
|
||||
ENDSSH
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 20
|
||||
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
|
||||
(echo "Health check failed" && exit 1)
|
||||
+12
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
|
||||
drb-frontend/.env
|
||||
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
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Production overrides — used on the VM.
|
||||
# Run with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Differences from dev:
|
||||
# - MQTT port 1883 is NOT published to the host (stays on the Docker bridge).
|
||||
# Edge nodes reach it via WireGuard tunnel to the Docker bridge IP.
|
||||
# - c2-core and frontend ports are only bound to localhost (Caddy proxies them).
|
||||
# - restart: always (instead of unless-stopped) for hard reboots.
|
||||
|
||||
services:
|
||||
mosquitto:
|
||||
restart: always
|
||||
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
|
||||
|
||||
c2-core:
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
|
||||
|
||||
discord-bot:
|
||||
restart: always
|
||||
|
||||
frontend:
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
|
||||
+3
-2
@@ -17,17 +17,17 @@ services:
|
||||
- mosquitto_data:/mosquitto/data
|
||||
|
||||
c2-core:
|
||||
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||
build: ./drb-c2-core
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8000"
|
||||
env_file: ./drb-c2-core/.env
|
||||
volumes:
|
||||
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
|
||||
depends_on:
|
||||
- mosquitto
|
||||
|
||||
discord-bot:
|
||||
image: ${REGISTRY}/discord-bot:${TAG:-latest}
|
||||
build: ./drb-server-discord-bot
|
||||
restart: unless-stopped
|
||||
env_file: ./drb-server-discord-bot/.env
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
- c2-core
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/frontend:${TAG:-latest}
|
||||
build: ./drb-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Managed by CI — deployed to /etc/caddy/Caddyfile on the server.
|
||||
# Caddy handles TLS automatically via Let's Encrypt.
|
||||
|
||||
api.{$DRB_DOMAIN} {
|
||||
reverse_proxy localhost:8888 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
app.{$DRB_DOMAIN} {
|
||||
reverse_proxy localhost:3000 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
terraform {
|
||||
required_version = ">= 1.6"
|
||||
required_providers {
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Store state in GCS — create the bucket manually once before first apply
|
||||
# Uncomment once GCS bucket permissions are confirmed working.
|
||||
# backend "gcs" {
|
||||
# bucket = "drb-tf-state"
|
||||
# prefix = "drb/state"
|
||||
# }
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# Pull live project metadata (number, name) without hardcoding them.
|
||||
data "google_project" "current" {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static external IP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_address" "drb" {
|
||||
name = "drb-server-ip"
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firewall rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_firewall" "allow_web" {
|
||||
name = "drb-allow-web"
|
||||
network = "default"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["80", "443"]
|
||||
}
|
||||
|
||||
source_ranges = ["0.0.0.0/0"]
|
||||
target_tags = ["drb-server"]
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "allow_ssh" {
|
||||
name = "drb-allow-ssh"
|
||||
network = "default"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["22"]
|
||||
}
|
||||
|
||||
# Restrict SSH to your IP(s) and Gitea runner IP
|
||||
source_ranges = var.allowed_ssh_cidrs
|
||||
target_tags = ["drb-server"]
|
||||
}
|
||||
|
||||
# MQTT is NOT exposed externally — edge nodes connect via WireGuard (see below)
|
||||
# If you need to temporarily allow direct MQTT access for testing, uncomment and
|
||||
# restrict source_ranges to your node IPs.
|
||||
#
|
||||
# resource "google_compute_firewall" "allow_mqtt" {
|
||||
# name = "drb-allow-mqtt"
|
||||
# network = "default"
|
||||
# allow {
|
||||
# protocol = "tcp"
|
||||
# ports = ["8883"] # TLS MQTT, not 1883
|
||||
# }
|
||||
# source_ranges = ["YOUR_NODE_CIDR"]
|
||||
# target_tags = ["drb-server"]
|
||||
# }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compute Engine VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_instance" "drb_server" {
|
||||
name = "drb-server"
|
||||
machine_type = var.machine_type
|
||||
zone = var.zone
|
||||
tags = ["drb-server"]
|
||||
|
||||
boot_disk {
|
||||
initialize_params {
|
||||
image = "debian-cloud/debian-12"
|
||||
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
|
||||
type = "pd-standard"
|
||||
}
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
access_config {
|
||||
nat_ip = google_compute_address.drb.address
|
||||
}
|
||||
}
|
||||
|
||||
metadata = {
|
||||
ssh-keys = "${var.ssh_user}:${var.ssh_public_key}"
|
||||
}
|
||||
|
||||
# Startup script runs once on first boot to install Docker + Caddy
|
||||
metadata_startup_script = file("${path.module}/startup.sh")
|
||||
|
||||
# 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 {
|
||||
scopes = ["cloud-platform"]
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
# Prevent Terraform from destroying + recreating on metadata changes
|
||||
ignore_changes = [metadata_startup_script]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
locals {
|
||||
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "drb_firestore" {
|
||||
project = var.project_id
|
||||
role = "roles/datastore.user"
|
||||
member = local.compute_sa
|
||||
}
|
||||
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,22 @@
|
||||
output "server_ip" {
|
||||
value = google_compute_address.drb.address
|
||||
description = "Static external IP of the DRB server VM"
|
||||
}
|
||||
|
||||
output "app_url" {
|
||||
value = "https://app.${var.domain}"
|
||||
}
|
||||
|
||||
output "api_url" {
|
||||
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,55 @@
|
||||
#!/bin/bash
|
||||
# Runs once on first VM boot. Installs Docker, Docker Compose, and Caddy.
|
||||
set -euxo pipefail
|
||||
|
||||
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||
apt-get update -y
|
||||
apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update -y
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
# Allow drb user to run docker
|
||||
usermod -aG docker drb 2>/dev/null || true
|
||||
|
||||
# ── Caddy (reverse proxy + auto TLS) ─────────────────────────────────────────
|
||||
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
> /etc/apt/sources.list.d/caddy-stable.list
|
||||
apt-get update -y
|
||||
apt-get install -y caddy
|
||||
|
||||
# ── App directory — clone repo so CI can git pull + docker compose up ─────────
|
||||
apt-get install -y git
|
||||
mkdir -p /opt/drb
|
||||
# Repo is cloned here by initial setup; CI just git pulls and rebuilds.
|
||||
# Set safe directory for the drb user
|
||||
git config --global --add safe.directory /opt/drb
|
||||
chown -R drb:drb /opt/drb 2>/dev/null || true
|
||||
|
||||
# ── Caddyfile placeholder (CI will write the real one on first deploy) ────────
|
||||
cat > /etc/caddy/Caddyfile <<'CADDY'
|
||||
# This file is managed by CI. Do not edit manually.
|
||||
# It will be replaced on the first deployment.
|
||||
:80 {
|
||||
respond "DRB server — waiting for deployment" 200
|
||||
}
|
||||
CADDY
|
||||
|
||||
systemctl enable caddy
|
||||
systemctl reload caddy
|
||||
|
||||
echo "Startup complete."
|
||||
@@ -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
|
||||
@@ -0,0 +1,66 @@
|
||||
variable "project_id" {
|
||||
description = "GCP project ID"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "GCP region"
|
||||
type = string
|
||||
default = "us-central1"
|
||||
}
|
||||
|
||||
variable "zone" {
|
||||
description = "GCP zone"
|
||||
type = string
|
||||
default = "us-central1-a"
|
||||
}
|
||||
|
||||
variable "domain" {
|
||||
description = "Base domain (e.g. example.com)"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "machine_type" {
|
||||
description = "Compute Engine machine type"
|
||||
type = string
|
||||
default = "e2-small"
|
||||
}
|
||||
|
||||
variable "ssh_user" {
|
||||
description = "SSH username for the VM"
|
||||
type = string
|
||||
default = "drb"
|
||||
}
|
||||
|
||||
variable "ssh_public_key" {
|
||||
description = "SSH public key to authorize on the VM"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "allowed_ssh_cidrs" {
|
||||
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
|
||||
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