From a1c91c5ed3c8328bad1cfb2260d5eb9e71173f27 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 21 Jun 2026 13:37:03 -0400 Subject: [PATCH 1/3] Initial infra attempt --- .gitea/workflows/deploy.yml | 46 ++++++++++++ docker-compose.prod.yml | 26 +++++++ infra/Caddyfile | 14 ++++ infra/main.tf | 138 ++++++++++++++++++++++++++++++++++++ infra/outputs.tf | 12 ++++ infra/startup.sh | 55 ++++++++++++++ infra/variables.tf | 48 +++++++++++++ 7 files changed, 339 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 docker-compose.prod.yml create mode 100644 infra/Caddyfile create mode 100644 infra/main.tf create mode 100644 infra/outputs.tf create mode 100644 infra/startup.sh create mode 100644 infra/variables.tf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..3650739 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Deploy + +on: + push: + branches: [main] + +env: + SERVER_IP: ${{ secrets.SERVER_IP }} + SSH_USER: drb + +jobs: + deploy: + name: Deploy to VM + 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 -i /tmp/deploy_key \ + ${{ env.SSH_USER }}@${{ env.SERVER_IP }} << 'ENDSSH' + set -e + cd /opt/drb + + # Pull latest code + git pull origin main + + # Rebuild and restart changed services + docker compose up -d --build --remove-orphans + + # Reload Caddy if Caddyfile changed + sudo systemctl reload caddy + + # Clean up old images + docker image prune -f + ENDSSH + + - name: Verify health + run: | + sleep 15 + curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \ + (echo "Health check failed" && exit 1) 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/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/main.tf b/infra/main.tf new file mode 100644 index 0000000..79eb02a --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,138 @@ +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 + backend "gcs" { + bucket = "drb-tf-state" + prefix = "drb/state" + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +# --------------------------------------------------------------------------- +# 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 — enough for Docker images + mosquitto data + type = "pd-balanced" + } + } + + 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") + + # Allow the VM to pull from Artifact Registry using its service account + service_account { + scopes = ["cloud-platform"] + } + + lifecycle { + # Prevent Terraform from destroying + recreating on metadata changes + ignore_changes = [metadata_startup_script] + } +} + +# --------------------------------------------------------------------------- +# Cloud DNS records +# --------------------------------------------------------------------------- + +resource "google_dns_record_set" "app" { + name = "app.${var.domain}." + type = "A" + ttl = 300 + managed_zone = var.dns_zone_name + rrdatas = [google_compute_address.drb.address] +} + +resource "google_dns_record_set" "api" { + name = "api.${var.domain}." + type = "A" + ttl = 300 + managed_zone = var.dns_zone_name + rrdatas = [google_compute_address.drb.address] +} diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 0000000..33ecbbb --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,12 @@ +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}" +} 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/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..51991cd --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,48 @@ +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 "dns_zone_name" { + description = "Cloud DNS managed zone name" + 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) +} -- 2.52.0 From 33700448bf8397b2913ada35458501572716b217 Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 22 Jun 2026 02:03:36 -0400 Subject: [PATCH 2/3] add Terraform + Ansible infrastructure for GCP deployment Provisions e2-micro VM (us-east1-b, free tier) with static IP, SSH and web firewall rules, Docker + Caddy startup script, and IAM bindings for Firestore and GCS access via ADC. Imports existing drb-calls bucket and c2-server Firestore database into state. Ansible roles handle first-time setup (swap, docker group) and all subsequent deploys via rsync + docker compose, with secrets managed via Ansible Vault. DNS stays on AWS Route 53. --- .gitignore | 12 +++ infra/Makefile | 40 ++++++++ infra/ansible/deploy.yml | 15 +++ infra/ansible/group_vars/all.yml.example | 9 ++ infra/ansible/inventory.ini.example | 8 ++ infra/ansible/roles/deploy/tasks/main.yml | 86 ++++++++++++++++++ .../roles/deploy/templates/Caddyfile.j2 | 13 +++ .../roles/deploy/templates/c2-core.env.j2 | 20 ++++ .../roles/deploy/templates/discord-bot.env.j2 | 5 + .../roles/deploy/templates/frontend.env.j2 | 11 +++ .../roles/deploy/templates/root.env.j2 | 7 ++ infra/ansible/site.yml | 73 +++++++++++++++ infra/ansible/vault.yml.example | 34 +++++++ infra/main.tf | 91 +++++++++++++++---- infra/outputs.tf | 10 ++ infra/terraform.tfvars.example | 24 +++++ infra/variables.tf | 28 +++++- 17 files changed, 461 insertions(+), 25 deletions(-) create mode 100644 infra/Makefile create mode 100644 infra/ansible/deploy.yml create mode 100644 infra/ansible/group_vars/all.yml.example create mode 100644 infra/ansible/inventory.ini.example create mode 100644 infra/ansible/roles/deploy/tasks/main.yml create mode 100644 infra/ansible/roles/deploy/templates/Caddyfile.j2 create mode 100644 infra/ansible/roles/deploy/templates/c2-core.env.j2 create mode 100644 infra/ansible/roles/deploy/templates/discord-bot.env.j2 create mode 100644 infra/ansible/roles/deploy/templates/frontend.env.j2 create mode 100644 infra/ansible/roles/deploy/templates/root.env.j2 create mode 100644 infra/ansible/site.yml create mode 100644 infra/ansible/vault.yml.example create mode 100644 infra/terraform.tfvars.example 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/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..b7a5a81 --- /dev/null +++ b/infra/ansible/roles/deploy/tasks/main.yml @@ -0,0 +1,86 @@ +--- +# Sync code, write secrets, bring the stack up. + +- name: Sync app code from local machine + synchronize: + src: "{{ local_repo_path }}" + dest: "{{ app_dir }}/" + delete: true + recursive: true + rsync_opts: + - "--exclude=.git" + - "--exclude=**/__pycache__" + - "--exclude=**/.env" + - "--exclude=**/gcp-key.json" + - "--exclude=**/node_modules" + - "--exclude=drb-c2-core/gcp-key.json" + - "--exclude=infra/" + become: false # rsync runs as the SSH user, not root + +- name: Set ownership of app directory + file: + path: "{{ app_dir }}" + state: directory + owner: "{{ ssh_user }}" + group: "{{ ssh_user }}" + recurse: true + +# No gcp-key.json needed — the VM authenticates to GCS/Firestore via ADC +# (GCE metadata server). IAM roles are granted by Terraform. + +- name: Template top-level .env (docker-compose MQTT creds) + 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: Bring the stack up (builds images if changed) + community.docker.docker_compose_v2: + project_src: "{{ app_dir }}" + build: always + state: present + pull: never + become: true + environment: + DOCKER_BUILDKIT: "1" + +- name: Prune unused Docker images + community.docker.docker_prune: + images: true + images_filters: + dangling: true 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..e34f230 --- /dev/null +++ b/infra/ansible/roles/deploy/templates/root.env.j2 @@ -0,0 +1,7 @@ +# Top-level docker-compose environment — MQTT credentials for the broker container. +# 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 }} diff --git a/infra/ansible/site.yml b/infra/ansible/site.yml new file mode 100644 index 0000000..2a1897b --- /dev/null +++ b/infra/ansible/site.yml @@ -0,0 +1,73 @@ +--- +# 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: 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..edaa44e --- /dev/null +++ b/infra/ansible/vault.yml.example @@ -0,0 +1,34 @@ +# 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" + +# ── 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 index 79eb02a..73f34a1 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -8,10 +8,11 @@ terraform { } # Store state in GCS — create the bucket manually once before first apply - backend "gcs" { - bucket = "drb-tf-state" - prefix = "drb/state" - } + # Uncomment once GCS bucket permissions are confirmed working. + # backend "gcs" { + # bucket = "drb-tf-state" + # prefix = "drb/state" + # } } provider "google" { @@ -19,6 +20,10 @@ provider "google" { region = var.region } +# Pull live project metadata (number, name) without hardcoding them. +data "google_project" "current" {} + + # --------------------------------------------------------------------------- # Static external IP # --------------------------------------------------------------------------- @@ -87,8 +92,8 @@ resource "google_compute_instance" "drb_server" { boot_disk { initialize_params { image = "debian-cloud/debian-12" - size = 30 # GB — enough for Docker images + mosquitto data - type = "pd-balanced" + size = 30 # GB — free tier covers 30GB pd-standard on e2-micro + type = "pd-standard" } } @@ -106,7 +111,8 @@ resource "google_compute_instance" "drb_server" { # Startup script runs once on first boot to install Docker + Caddy 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 { 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" { - name = "app.${var.domain}." - type = "A" - ttl = 300 - managed_zone = var.dns_zone_name - rrdatas = [google_compute_address.drb.address] +locals { + compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com" } -resource "google_dns_record_set" "api" { - name = "api.${var.domain}." - type = "A" - ttl = 300 - managed_zone = var.dns_zone_name - rrdatas = [google_compute_address.drb.address] +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 index 33ecbbb..6c31660 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -10,3 +10,13 @@ output "app_url" { 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/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 index 51991cd..938101c 100644 --- a/infra/variables.tf +++ b/infra/variables.tf @@ -20,11 +20,6 @@ variable "domain" { type = string } -variable "dns_zone_name" { - description = "Cloud DNS managed zone name" - type = string -} - variable "machine_type" { description = "Compute Engine machine type" type = string @@ -46,3 +41,26 @@ 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" +} -- 2.52.0 From 9fdcad1c4655e3ea85e6756d63642fba3bd9239e Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 22 Jun 2026 02:31:28 -0400 Subject: [PATCH 3/3] deploy via Gitea CI registry; provision GCP infra with Terraform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terraform: e2-micro VM (us-east1-b, free tier), static IP, SSH/web firewall rules, IAM bindings for Firestore + GCS; imports existing drb-calls bucket and c2-server Firestore database into state - Gitea CI: build c2-core, discord-bot, frontend images and push to git.vpn.cusano.net registry; SSH deploy pulls pre-built images (no build on VM) - Ansible: first-time setup only — git clone, env files from vault, Caddyfile, docker login + compose pull + up; no rsync or on-VM builds - docker-compose: add image: ${REGISTRY}/name:latest alongside build: so local dev and CI registry both work - gitignore: add Terraform state, lock, tfvars, ansible secrets --- .gitea/workflows/deploy.yml | 73 +++++++++++++++---- docker-compose.yml | 5 +- infra/ansible/roles/deploy/tasks/main.yml | 55 ++++++-------- .../roles/deploy/templates/root.env.j2 | 5 +- infra/ansible/site.yml | 6 ++ infra/ansible/vault.yml.example | 6 ++ 6 files changed, 100 insertions(+), 50 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3650739..fce2758 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,16 +1,61 @@ -name: Deploy +name: Build & Deploy on: push: branches: [main] env: - SERVER_IP: ${{ secrets.SERVER_IP }} - SSH_USER: drb + # 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: @@ -21,26 +66,24 @@ jobs: - name: Deploy run: | - ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key \ - ${{ env.SSH_USER }}@${{ env.SERVER_IP }} << 'ENDSSH' + 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 - # Pull latest code + # Update compose files + mosquitto config git pull origin main - # Rebuild and restart changed services - docker compose up -d --build --remove-orphans - - # Reload Caddy if Caddyfile changed - sudo systemctl reload caddy - - # Clean up old images + # 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: Verify health + - name: Health check run: | - sleep 15 + sleep 20 curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \ (echo "Health check failed" && exit 1) 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/ansible/roles/deploy/tasks/main.yml b/infra/ansible/roles/deploy/tasks/main.yml index b7a5a81..a10c2af 100644 --- a/infra/ansible/roles/deploy/tasks/main.yml +++ b/infra/ansible/roles/deploy/tasks/main.yml @@ -1,21 +1,14 @@ --- -# Sync code, write secrets, bring the stack up. +# 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: Sync app code from local machine - synchronize: - src: "{{ local_repo_path }}" - dest: "{{ app_dir }}/" - delete: true - recursive: true - rsync_opts: - - "--exclude=.git" - - "--exclude=**/__pycache__" - - "--exclude=**/.env" - - "--exclude=**/gcp-key.json" - - "--exclude=**/node_modules" - - "--exclude=drb-c2-core/gcp-key.json" - - "--exclude=infra/" - become: false # rsync runs as the SSH user, not root +- 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: @@ -25,10 +18,7 @@ group: "{{ ssh_user }}" recurse: true -# No gcp-key.json needed — the VM authenticates to GCS/Firestore via ADC -# (GCE metadata server). IAM roles are granted by Terraform. - -- name: Template top-level .env (docker-compose MQTT creds) +- name: Template top-level .env (docker-compose MQTT creds + registry) template: src: root.env.j2 dest: "{{ app_dir }}/.env" @@ -69,18 +59,19 @@ mode: "0644" notify: Reload Caddy -- name: Bring the stack up (builds images if changed) +- 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 }}" - build: always + files: + - docker-compose.yml + - docker-compose.prod.yml + pull: always + build: never state: present - pull: never - become: true - environment: - DOCKER_BUILDKIT: "1" - -- name: Prune unused Docker images - community.docker.docker_prune: - images: true - images_filters: - dangling: true diff --git a/infra/ansible/roles/deploy/templates/root.env.j2 b/infra/ansible/roles/deploy/templates/root.env.j2 index e34f230..d71cefb 100644 --- a/infra/ansible/roles/deploy/templates/root.env.j2 +++ b/infra/ansible/roles/deploy/templates/root.env.j2 @@ -1,7 +1,10 @@ -# Top-level docker-compose environment — MQTT credentials for the broker container. +# 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 index 2a1897b..2f89a40 100644 --- a/infra/ansible/site.yml +++ b/infra/ansible/site.yml @@ -12,6 +12,12 @@ - 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 diff --git a/infra/ansible/vault.yml.example b/infra/ansible/vault.yml.example index edaa44e..d630361 100644 --- a/infra/ansible/vault.yml.example +++ b/infra/ansible/vault.yml.example @@ -19,6 +19,12 @@ 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: "" -- 2.52.0