diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..7a2e28c --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,75 @@ +name: Deploy Backend + +# Required GitHub secrets: +# GCP_SA_KEY - Service account JSON key (base64 encoded) +# GCP_VM_IP - External IP of the GCP VM +# GCP_VM_SSH_PRIVATE_KEY - SSH private key for connecting to the VM + +on: + push: + branches: [main] + paths: [backend/**] + workflow_dispatch: + +concurrency: + group: deploy-backend + cancel-in-progress: false + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./backend/... -cover + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build static binary + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o smolterms ./backend/cmd/server/main.go + + - name: Set up SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.GCP_VM_SSH_PRIVATE_KEY }}" > ~/.ssh/id_deploy + chmod 600 ~/.ssh/id_deploy + ssh-keyscan -H ${{ secrets.GCP_VM_IP }} >> ~/.ssh/known_hosts + + - name: Copy binary to VM + run: | + scp -i ~/.ssh/id_deploy smolterms deploy@${{ secrets.GCP_VM_IP }}:/tmp/smolterms + + - name: Deploy and restart service + run: | + ssh -i ~/.ssh/id_deploy deploy@${{ secrets.GCP_VM_IP }} << 'EOF' + sudo mv /tmp/smolterms /opt/smolterms/smolterms + sudo chown smolterms:smolterms /opt/smolterms/smolterms + sudo chmod 755 /opt/smolterms/smolterms + sudo systemctl restart smolterms + EOF + + - name: Health check + run: | + for i in $(seq 1 10); do + if curl -sf http://${{ secrets.GCP_VM_IP }}:8080/api/v1/health; then + echo "Health check passed" + exit 0 + fi + echo "Attempt $i/10 failed, retrying in 3s..." + sleep 3 + done + echo "Health check failed after 30 seconds" + exit 1 diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..4081771 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,179 @@ +# SmolTerms Infrastructure + +Terraform configuration for deploying SmolTerms on GCP's always-free tier (1x e2-micro VM in us-central1). + +## Prerequisites + +- [Terraform CLI](https://developer.hashicorp.com/terraform/install) (>= 1.0) +- [gcloud CLI](https://cloud.google.com/sdk/docs/install) +- GCP account with billing enabled (free tier still requires a billing account) + +## GCP Project Setup + +### 1. Create a GCP Project + +```bash +gcloud projects create YOUR_PROJECT_ID --name="SmolTerms" +gcloud config set project YOUR_PROJECT_ID +``` + +Link a billing account (required even for free tier): + +```bash +gcloud billing accounts list +gcloud billing projects link YOUR_PROJECT_ID --billing-account=BILLING_ACCOUNT_ID +``` + +### 2. Enable Required APIs + +```bash +gcloud services enable compute.googleapis.com +gcloud services enable iam.googleapis.com +``` + +### 3. Authenticate for Terraform + +For local development, use Application Default Credentials: + +```bash +gcloud auth application-default login +``` + +## Deploy with Terraform + +```bash +cd infra/terraform + +# Create your variables file +cp terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars and set your project_id + +terraform init +terraform plan +terraform apply +``` + +After `terraform apply`, the VM external IP is printed as output. Note this IP — it's needed for the GitHub Actions workflow and DNS configuration. + +## Service Account for GitHub Actions + +Terraform creates a `smolterms-deploy` service account with these minimal roles: + +| Role | Purpose | +|------|---------| +| `roles/compute.osAdminLogin` | SSH into the VM via OS Login | +| `roles/compute.viewer` | Read instance metadata (get IP, zone) | +| `roles/iam.serviceAccountUser` | Act as the service account | + +### Create a Service Account Key + +After `terraform apply`, create a JSON key for GitHub Actions: + +```bash +gcloud iam service-accounts keys create sa-key.json \ + --iam-account=smolterms-deploy@YOUR_PROJECT_ID.iam.gserviceaccount.com +``` + +**Important:** This key grants deploy access to your VM. Do not commit it to the repository. Delete it from your local machine after adding it to GitHub. + +### Generate an SSH Key for Deployment + +Create a dedicated SSH key pair for the deploy workflow: + +```bash +ssh-keygen -t ed25519 -C "smolterms-deploy" -f deploy_key -N "" +``` + +Add the **public** key to the VM's authorized keys for a `deploy` user: + +```bash +gcloud compute ssh smolterms --zone=us-central1-a --command=" + sudo useradd -m -s /bin/bash deploy + sudo mkdir -p /home/deploy/.ssh + sudo tee /home/deploy/.ssh/authorized_keys << 'PUBKEY' +$(cat deploy_key.pub) +PUBKEY + sudo chown -R deploy:deploy /home/deploy/.ssh + sudo chmod 700 /home/deploy/.ssh + sudo chmod 600 /home/deploy/.ssh/authorized_keys + echo 'deploy ALL=(ALL) NOPASSWD: /bin/mv, /bin/chown, /bin/chmod, /bin/systemctl' | sudo tee /etc/sudoers.d/deploy +" +``` + +### Add to GitHub Actions Repository Secrets + +Go to your GitHub repo → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** and add: + +| Secret Name | Value | +|-------------|-------| +| `GCP_SA_KEY` | Contents of `sa-key.json` (base64 encoded) | +| `GCP_VM_IP` | VM external IP (from `terraform output instance_ip`) | +| `GCP_VM_SSH_PRIVATE_KEY` | Contents of `deploy_key` (the private key) | + +Environment variables on the VM (set separately, see below): + +| Variable | Purpose | +|----------|---------| +| `ANTHROPIC_API_KEY` | Anthropic API key for LLM analysis | +| `OPENAI_API_KEY` | OpenAI API key for embeddings | + +### Clean Up Local Key Files + +```bash +rm sa-key.json deploy_key deploy_key.pub +``` + +## VM Details + +- **Machine type:** e2-micro (GCP always-free tier) +- **Region/Zone:** us-central1-a (free tier eligible) +- **OS:** Debian 12 +- **Boot disk:** 30 GB pd-standard (free tier limit) +- **Open ports:** 22 (SSH), 8080 (API) + +### Application Layout on VM + +``` +/opt/smolterms/ +├── smolterms # Go binary (deployed by GitHub Actions) +└── .env # Environment variables (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) +``` + +The `smolterms` systemd service runs `/opt/smolterms/smolterms` as the `smolterms` user, reading environment variables from `/opt/smolterms/.env`. + +### Managing the Service + +SSH into the VM: + +```bash +gcloud compute ssh smolterms --zone=us-central1-a +``` + +Then: + +```bash +sudo systemctl status smolterms # Check status +sudo systemctl restart smolterms # Restart after deploy +sudo journalctl -u smolterms -f # Follow logs +``` + +### Setting Environment Variables on the VM + +```bash +sudo tee /opt/smolterms/.env << 'EOF' +ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY=sk-... +PORT=8080 +EOF +sudo chown smolterms:smolterms /opt/smolterms/.env +sudo chmod 600 /opt/smolterms/.env +``` + +## Free Tier Limits + +GCP's always-free tier includes: +- 1x e2-micro VM in us-central1, us-west1, or us-east1 +- 30 GB pd-standard persistent disk +- 1 GB outbound network (to most destinations, excluding China and Australia) + +If you exceed these limits, charges will apply. Monitor usage in the [GCP Console](https://console.cloud.google.com/billing). diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 0000000..3562034 --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfstate +*.tfstate.backup +*.tfvars +!*.tfvars.example diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..182c5fb --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,107 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region + zone = var.zone +} + +# --- Service Account for GitHub Actions CI/CD --- + +resource "google_service_account" "deploy" { + account_id = "smolterms-deploy" + display_name = "SmolTerms Deploy" + description = "Service account for GitHub Actions to deploy SmolTerms via SSH/SCP" +} + +resource "google_project_iam_member" "deploy_os_login" { + project = var.project_id + role = "roles/compute.osAdminLogin" + member = "serviceAccount:${google_service_account.deploy.email}" +} + +resource "google_project_iam_member" "deploy_sa_user" { + project = var.project_id + role = "roles/iam.serviceAccountUser" + member = "serviceAccount:${google_service_account.deploy.email}" +} + +resource "google_project_iam_member" "deploy_instance_viewer" { + project = var.project_id + role = "roles/compute.viewer" + member = "serviceAccount:${google_service_account.deploy.email}" +} + +# --- Network / Firewall --- + +resource "google_compute_firewall" "allow_ssh" { + name = "smolterms-allow-ssh" + network = "default" + + allow { + protocol = "tcp" + ports = ["22"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = ["smolterms"] +} + +resource "google_compute_firewall" "allow_http" { + name = "smolterms-allow-http" + network = "default" + + allow { + protocol = "tcp" + ports = ["8080"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = ["smolterms"] +} + +# --- Compute Instance --- + +resource "google_compute_instance" "smolterms" { + name = "smolterms" + machine_type = "e2-micro" + zone = var.zone + + tags = ["smolterms"] + + boot_disk { + initialize_params { + image = "debian-cloud/debian-12" + size = 30 + type = "pd-standard" + } + } + + network_interface { + network = "default" + + access_config { + # Ephemeral public IP + } + } + + metadata = { + enable-oslogin = "TRUE" + } + + metadata_startup_script = file("${path.module}/startup.sh") + + service_account { + email = google_service_account.deploy.email + scopes = ["cloud-platform"] + } +} diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf new file mode 100644 index 0000000..f35aead --- /dev/null +++ b/infra/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "instance_ip" { + description = "External IP address of the SmolTerms VM" + value = google_compute_instance.smolterms.network_interface[0].access_config[0].nat_ip +} + +output "deploy_service_account_email" { + description = "Email of the deploy service account (for GitHub Actions)" + value = google_service_account.deploy.email +} diff --git a/infra/terraform/startup.sh b/infra/terraform/startup.sh new file mode 100644 index 0000000..49edfa0 --- /dev/null +++ b/infra/terraform/startup.sh @@ -0,0 +1,47 @@ +#!/bin/bash +set -euo pipefail + +# Create smolterms system user (no login shell, no home dir) +if ! id -u smolterms &>/dev/null; then + useradd --system --no-create-home --shell /usr/sbin/nologin smolterms +fi + +# Create application directory +mkdir -p /opt/smolterms +chown smolterms:smolterms /opt/smolterms + +# Create empty .env file if it doesn't exist +if [ ! -f /opt/smolterms/.env ]; then + touch /opt/smolterms/.env + chown smolterms:smolterms /opt/smolterms/.env + chmod 600 /opt/smolterms/.env +fi + +# Install systemd unit file +cat > /etc/systemd/system/smolterms.service <<'EOF' +[Unit] +Description=SmolTerms API Server +After=network.target + +[Service] +Type=simple +User=smolterms +Group=smolterms +WorkingDirectory=/opt/smolterms +ExecStart=/opt/smolterms/smolterms +EnvironmentFile=/opt/smolterms/.env +Restart=on-failure +RestartSec=5 + +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/smolterms + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable smolterms.service diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example new file mode 100644 index 0000000..27b45ac --- /dev/null +++ b/infra/terraform/terraform.tfvars.example @@ -0,0 +1,3 @@ +project_id = "your-gcp-project-id" +# region = "us-central1" # default, free tier eligible +# zone = "us-central1-a" # default, free tier eligible diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..35d1dd7 --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,16 @@ +variable "project_id" { + description = "GCP project ID" + type = string +} + +variable "region" { + description = "GCP region (us-central1 is free tier eligible)" + type = string + default = "us-central1" +} + +variable "zone" { + description = "GCP zone (us-central1-a is free tier eligible)" + type = string + default = "us-central1-a" +}