diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..fce2758 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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) diff --git a/.gitignore b/.gitignore index add193e..6583778 100644 --- a/.gitignore +++ b/.gitignore @@ -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] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..a1b8223 --- /dev/null +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index a7b1118..3509f53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..6220bb8 --- /dev/null +++ b/infra/Caddyfile @@ -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} + } +} diff --git a/infra/Makefile b/infra/Makefile new file mode 100644 index 0000000..666a629 --- /dev/null +++ b/infra/Makefile @@ -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) diff --git a/infra/ansible/deploy.yml b/infra/ansible/deploy.yml new file mode 100644 index 0000000..47af1fd --- /dev/null +++ b/infra/ansible/deploy.yml @@ -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 diff --git a/infra/ansible/group_vars/all.yml.example b/infra/ansible/group_vars/all.yml.example new file mode 100644 index 0000000..f8df182 --- /dev/null +++ b/infra/ansible/group_vars/all.yml.example @@ -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/" diff --git a/infra/ansible/inventory.ini.example b/infra/ansible/inventory.ini.example new file mode 100644 index 0000000..653840d --- /dev/null +++ b/infra/ansible/inventory.ini.example @@ -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 diff --git a/infra/ansible/roles/deploy/tasks/main.yml b/infra/ansible/roles/deploy/tasks/main.yml new file mode 100644 index 0000000..a10c2af --- /dev/null +++ b/infra/ansible/roles/deploy/tasks/main.yml @@ -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 diff --git a/infra/ansible/roles/deploy/templates/Caddyfile.j2 b/infra/ansible/roles/deploy/templates/Caddyfile.j2 new file mode 100644 index 0000000..d3c5be7 --- /dev/null +++ b/infra/ansible/roles/deploy/templates/Caddyfile.j2 @@ -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} + } +} diff --git a/infra/ansible/roles/deploy/templates/c2-core.env.j2 b/infra/ansible/roles/deploy/templates/c2-core.env.j2 new file mode 100644 index 0000000..7e37a96 --- /dev/null +++ b/infra/ansible/roles/deploy/templates/c2-core.env.j2 @@ -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 }}"] diff --git a/infra/ansible/roles/deploy/templates/discord-bot.env.j2 b/infra/ansible/roles/deploy/templates/discord-bot.env.j2 new file mode 100644 index 0000000..41284f9 --- /dev/null +++ b/infra/ansible/roles/deploy/templates/discord-bot.env.j2 @@ -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 }} diff --git a/infra/ansible/roles/deploy/templates/frontend.env.j2 b/infra/ansible/roles/deploy/templates/frontend.env.j2 new file mode 100644 index 0000000..0f2b721 --- /dev/null +++ b/infra/ansible/roles/deploy/templates/frontend.env.j2 @@ -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 }} diff --git a/infra/ansible/roles/deploy/templates/root.env.j2 b/infra/ansible/roles/deploy/templates/root.env.j2 new file mode 100644 index 0000000..d71cefb --- /dev/null +++ b/infra/ansible/roles/deploy/templates/root.env.j2 @@ -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 }} diff --git a/infra/ansible/site.yml b/infra/ansible/site.yml new file mode 100644 index 0000000..2f89a40 --- /dev/null +++ b/infra/ansible/site.yml @@ -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 diff --git a/infra/ansible/vault.yml.example b/infra/ansible/vault.yml.example new file mode 100644 index 0000000..d630361 --- /dev/null +++ b/infra/ansible/vault.yml.example @@ -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. diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..73f34a1 --- /dev/null +++ b/infra/main.tf @@ -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 +# --------------------------------------------------------------------------- diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 0000000..6c31660 --- /dev/null +++ b/infra/outputs.tf @@ -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)" +} diff --git a/infra/startup.sh b/infra/startup.sh new file mode 100644 index 0000000..8030a65 --- /dev/null +++ b/infra/startup.sh @@ -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." diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..4ee540b --- /dev/null +++ b/infra/terraform.tfvars.example @@ -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 diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..938101c --- /dev/null +++ b/infra/variables.tf @@ -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" +}