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" +}