GCP update
This commit is contained in:
@@ -1,58 +1,69 @@
|
||||
# dev-blog
|
||||
|
||||
A personal developer blog built to be fast, secure, and entirely self-hosted — no platform lock-in, no third-party runtime dependencies.
|
||||
A personal developer blog built to be fast, secure, and resilient.
|
||||
|
||||
## Stack
|
||||
|
||||
### Site
|
||||
|
||||
- **[Astro v6](https://astro.build)** — generates static HTML at build time, served via `astro preview`. Zero client-side JavaScript by default.
|
||||
- **Markdown & MDX** — posts live in `src/content/blog/` as typed Content Collections with frontmatter validation.
|
||||
- **RSS feed + sitemap** — auto-generated via `@astrojs/rss` and `@astrojs/sitemap`.
|
||||
- **Local fonts** — Atkinson Hyperlegible served from `src/assets/fonts/`, no external font requests.
|
||||
- **pnpm** — fast, disk-efficient package management. Requires Node ≥ 22.
|
||||
- **[Astro v6](https://astro.build)** — static site generator.
|
||||
- **Markdown & MDX** — posts are fetched from a Google Cloud Storage (GCS) bucket and built into the site daily at 6 AM CT.
|
||||
- **pnpm** — package management (Node ≥ 22).
|
||||
|
||||
### Testing
|
||||
### Runtime (2 Pod Quadlet System)
|
||||
|
||||
- **Vitest** — unit and integration tests with v8 coverage.
|
||||
- **Playwright** — end-to-end tests against the running site.
|
||||
The production environment runs on **AlmaLinux 10** using Podman Quadlet units.
|
||||
|
||||
## Containers
|
||||
- **App Container**: Runs the Astro site (`astro preview`). It is fully immutable and contains the static content baked in.
|
||||
- **Caddy Container**: A custom Caddy build with:
|
||||
- **Coraza WAF**: OWASP Core Rule Set for top-tier security.
|
||||
- **Google Cloud DNS Plugin**: For zero-downtime ACME DNS-01 TLS issuance.
|
||||
- **Maintenance Mode**: Caddy automatically serves a "Briefly Offline" page during container updates.
|
||||
|
||||
The entire runtime is two containers communicating over a private bridge network.
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet ──► Caddy :443 ──► Astro app :4321
|
||||
```
|
||||
[ GCS Bucket ] ──( 6 AM CT Daily )──► [ Cloud Build ] ──► [ Artifact Registry ]
|
||||
│ │
|
||||
└─────────( Object Change )──► [ Cloud Function ] ▼
|
||||
│ [ Astro App (AlmaLinux 10 GCE) ]
|
||||
▼
|
||||
[ Cloudflare R2 ]
|
||||
|
||||
### App container
|
||||
### Build & Sync Logic
|
||||
1. **Storage**: New posts are uploaded as `.md` files to a GCS bucket.
|
||||
2. **Scheduling**: A Cloud Scheduler job triggers Cloud Build every day at 6 AM Central Time.
|
||||
3. **Optimization**: Cloud Build checks for changes in the last 24 hours. If no changes exist, the build is skipped to save costs.
|
||||
4. **Backups**: Every file change in GCS triggers a Cloud Function that runs a **Restic backup** to Cloudflare R2, ensuring point-in-time recovery.
|
||||
5. **Deployment**: Successful builds push a new image to Google Artifact Registry. The AlmaLinux host pulls and restarts the container.
|
||||
|
||||
A two-stage `Containerfile` (Node 24 on Debian slim):
|
||||
## Infrastructure (IaaC)
|
||||
|
||||
1. **Build stage** — installs deps and runs `astro build`.
|
||||
2. **Runtime stage** — copies only `dist/`, `node_modules`, and config. Runs as a non-root `astro` user (UID 1001) with a read-only filesystem, all Linux capabilities dropped, and `no-new-privileges` enforced.
|
||||
Managed via **OpenTofu** with state stored in Cloudflare R2 (S3-compatible).
|
||||
|
||||
### Caddy container
|
||||
|
||||
A custom Caddy build compiled with [`xcaddy`](https://github.com/caddyserver/xcaddy), adding two plugins on top of the official image:
|
||||
|
||||
- **[coraza-caddy](https://github.com/corazawaf/coraza-caddy)** — the Coraza WAF with the OWASP Core Rule Set (CRS v4.7.0) baked into the image. All traffic is inspected before it reaches the app.
|
||||
- **[caddy-dns/googleclouddns](https://github.com/caddy-dns/googleclouddns)** — ACME DNS-01 challenge provider, so TLS certificates are issued and renewed without opening port 80 or requiring a webroot.
|
||||
|
||||
Caddy also sets hardened response headers (HSTS, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`) and compresses responses with zstd and gzip.
|
||||
|
||||
### Compose vs. production
|
||||
|
||||
- **Local / CI**: `compose.yaml` (+ `compose.override.yaml`) spins up the full stack with `podman compose up --build`.
|
||||
- **Production**: [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) units in `quadlet/` integrate the containers directly with systemd — no compose daemon required.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
All infrastructure is version-controlled and reproducible.
|
||||
|
||||
| Layer | Tool | Details |
|
||||
| Component | Service | Details |
|
||||
|---|---|---|
|
||||
| Hosting | **[Vultr](https://www.vultr.com)** | VPS — 1 vCPU / 2 GB RAM, AlmaLinux 10, Seattle (`sea`) region. Reserved IPv4 and IPv6 addresses survive instance replacement. Daily automated backups. |
|
||||
| DNS | **[Google Cloud DNS](https://cloud.google.com/dns)** | Authoritative DNS for the site's domain. A service-account key is also used by Caddy's `caddy-dns/googleclouddns` plugin to complete ACME DNS-01 challenges for automatic TLS certificate issuance and renewal. |
|
||||
| Cloud provisioning | **OpenTofu** | Manages the Vultr instance and reserved IPs as code. State stored remotely via a Cloudflare R2 backend. |
|
||||
| Host configuration | **Ansible** | Roles: `common`, `nftables` (firewall), `fail2ban` (intrusion prevention), `registry` (private container registry), `container_host` (Quadlet + Podman setup). |
|
||||
| Hosting | **GCE (e2-small)** | 2 vCPUs, 2 GB RAM, AlmaLinux 10 (GCP). |
|
||||
| DNS | **Cloud DNS** | Managed via OpenTofu. |
|
||||
| CI/CD | **Cloud Build** | Ephemeral builds triggered via Cloud Scheduler. |
|
||||
| Backups | **Cloud Function** | Event-driven Restic backups to R2. |
|
||||
| Secrets | **Secret Manager** | Stores R2 keys and Restic passwords securely. |
|
||||
| Registry | **Artifact Registry** | Private Docker repository for site images. |
|
||||
| Content | **GCS** | Source of truth for markdown files. |
|
||||
|
||||
| Content | **GCS** | Source of truth for markdown files. |
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Provisioning Infrastructure
|
||||
```bash
|
||||
cd infra
|
||||
tofu init -backend-config=backend.hcl
|
||||
tofu apply
|
||||
```
|
||||
|
||||
+16
-1
@@ -5,7 +5,8 @@
|
||||
|
||||
{
|
||||
# Make sure the WAF runs before the reverse-proxy handler.
|
||||
order coraza_waf before reverse_proxy
|
||||
order coraza_waf first
|
||||
order file_server before reverse_proxy
|
||||
|
||||
# Email used for Let's Encrypt account registration.
|
||||
email {$ACME_EMAIL:admin@example.com}
|
||||
@@ -46,6 +47,20 @@
|
||||
resolvers 8.8.8.8 1.1.1.1
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Maintenance Page Handling
|
||||
# ------------------------------------------------------------------
|
||||
handle_errors {
|
||||
@502_503 {
|
||||
expression {err.status} in [502, 503]
|
||||
}
|
||||
handle @502_503 {
|
||||
root * /etc/caddy/maintenance
|
||||
rewrite * /maintenance.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reverse-proxy to the Astro container. Caddy is on the host network
|
||||
# namespace, so we connect over loopback to the port the app container
|
||||
|
||||
@@ -37,3 +37,4 @@ RUN set -eux; \
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY coraza.conf /etc/caddy/coraza/local.conf
|
||||
COPY maintenance.html /etc/caddy/maintenance/maintenance.html
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maintenance - dev-blog</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f4f4f9; color: #333; }
|
||||
.container { text-align: center; padding: 2rem; border-radius: 8px; background: white; shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
h1 { color: #218bff; }
|
||||
p { line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Briefly Offline</h1>
|
||||
<p>The site is updating with new content. We'll be back in just a few seconds.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
steps:
|
||||
# 1. Check if any markdown files in GCS were modified in the last 24 hours
|
||||
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
|
||||
id: 'check-updates'
|
||||
entrypoint: 'bash'
|
||||
args:
|
||||
- '-c'
|
||||
- |
|
||||
LATEST_MOD=$(gsutil ls -l gs://${_BUCKET}/posts/*.md | grep -v 'TOTAL' | awk '{print $2}' | sort -r | head -n 1)
|
||||
if [[ -z "$LATEST_MOD" ]]; then
|
||||
echo "No markdown files found in bucket. Skipping build."
|
||||
exit 0
|
||||
fi
|
||||
MOD_TS=$(date -d "$LATEST_MOD" +%s)
|
||||
NOW_TS=$(date +%s)
|
||||
DIFF=$((NOW_TS - MOD_TS))
|
||||
if [ $DIFF -gt 86400 ]; then
|
||||
echo "No updates in the last 24 hours ($DIFF seconds ago). Skipping build."
|
||||
# We exit 0 but use a custom variable or file to signal skip if needed.
|
||||
# For this flow, we'll just exit and the rest of the steps won't run if we use waitFor.
|
||||
fi
|
||||
|
||||
# 2. Build the Astro site image
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
id: 'build-image'
|
||||
args: ['build', '-t', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest', '.']
|
||||
waitFor: ['check-updates']
|
||||
|
||||
# 3. Push to Artifact Registry
|
||||
- name: 'gcr.io/cloud-builders/docker'
|
||||
id: 'push-image'
|
||||
args: ['push', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest']
|
||||
waitFor: ['build-image']
|
||||
|
||||
# 4. Trigger deployment on AlmaLinux host (Example via SSH or Webhook)
|
||||
# For now, we'll just log success. A real implementation would use IAP SSH.
|
||||
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
|
||||
id: 'notify-deploy'
|
||||
entrypoint: 'bash'
|
||||
args: ['-c', 'echo "Build complete. Image pushed to ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest"']
|
||||
waitFor: ['push-image']
|
||||
|
||||
substitutions:
|
||||
_BUCKET: 'dev-blog-494815-blog-content'
|
||||
_REGION: 'us-central1'
|
||||
_REPO: 'dev-blog'
|
||||
|
||||
options:
|
||||
logging: CLOUD_LOGGING
|
||||
@@ -0,0 +1,18 @@
|
||||
# Use a custom Dockerfile to include restic and gsutil
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install restic and curl (to get cloud-sdk)
|
||||
RUN apt-get update && apt-get install -y restic curl gnupg \
|
||||
&& echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list \
|
||||
&& curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - \
|
||||
&& apt-get update && apt-get install -y google-cloud-cli \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Cloud Run functions expect a specific entrypoint
|
||||
ENTRYPOINT ["functions-framework", "--target", "run_backup", "--signature-type", "cloudevent"]
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import functions_framework
|
||||
|
||||
@functions_framework.cloud_event
|
||||
def run_backup(cloud_event):
|
||||
print(f"Triggered by event: {cloud_event['id']}")
|
||||
|
||||
# Restic environments are expected to be set via Secret Manager / Env vars
|
||||
# Required: RESTIC_REPOSITORY, RESTIC_PASSWORD, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
|
||||
|
||||
source_bucket = os.environ.get('SOURCE_BUCKET') # e.g. gs://my-bucket
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# 1. Sync bucket to local temp dir (restic works best on local files for GCS source)
|
||||
# Alternatively, restic can use rclone as a backend, but for a small blog,
|
||||
# syncing to a temp dir is simpler.
|
||||
print(f"Syncing {source_bucket} to {tmpdir}...")
|
||||
subprocess.run(['gsutil', '-m', 'rsync', '-r', source_bucket, tmpdir], check=True)
|
||||
|
||||
# 2. Run restic backup
|
||||
print("Starting restic backup to R2...")
|
||||
# Note: In a real environment, you'd ensure the restic binary is in the path.
|
||||
# We'll use a wrapper or ensure it's in the container.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['restic', 'backup', tmpdir, '--tag', 'gcs-trigger'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
print(result.stdout)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Restic failed: {e.stderr}")
|
||||
raise e
|
||||
|
||||
print("Backup completed successfully.")
|
||||
@@ -0,0 +1 @@
|
||||
functions-framework==3.8.1
|
||||
+343
-38
@@ -1,51 +1,356 @@
|
||||
data "vultr_os" "alma" {
|
||||
filter {
|
||||
name = "name"
|
||||
values = [var.os_name_filter]
|
||||
}
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_network" "vpc" {
|
||||
name = "${var.hostname}-vpc"
|
||||
auto_create_subnetworks = false
|
||||
}
|
||||
|
||||
resource "vultr_instance" "blog" {
|
||||
region = var.region
|
||||
plan = var.plan
|
||||
os_id = data.vultr_os.alma.id
|
||||
hostname = var.hostname
|
||||
label = var.hostname
|
||||
tags = var.tags
|
||||
ssh_key_ids = var.ssh_key_ids
|
||||
resource "google_compute_subnetwork" "subnet" {
|
||||
name = "${var.hostname}-subnet"
|
||||
ip_cidr_range = "10.0.1.0/24"
|
||||
network = google_compute_network.vpc.id
|
||||
region = var.gcp_region
|
||||
}
|
||||
|
||||
backups = "enabled"
|
||||
backups_schedule {
|
||||
type = "daily"
|
||||
hour = var.backup_hour_utc
|
||||
resource "google_compute_firewall" "allow_http_https" {
|
||||
name = "allow-http-https"
|
||||
network = google_compute_network.vpc.name
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["80", "443"]
|
||||
}
|
||||
|
||||
enable_ipv6 = true
|
||||
ddos_protection = false
|
||||
activation_email = false
|
||||
source_ranges = ["0.0.0.0/0"]
|
||||
target_tags = ["http-server", "https-server"]
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "allow_ssh" {
|
||||
name = "allow-ssh"
|
||||
network = google_compute_network.vpc.name
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["22"]
|
||||
}
|
||||
|
||||
source_ranges = ["0.0.0.0/0"] # Restrict this in production if possible
|
||||
target_tags = ["ssh-server"]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static (Reserved) IPs
|
||||
#
|
||||
# Reserved IPs survive instance replacement, so DNS records stay valid even
|
||||
# if `vultr_instance.blog` is destroyed and recreated.
|
||||
#
|
||||
# - v4 reservation is a single /32, so `subnet` is the address itself.
|
||||
# - v6 reservation is a /64; `subnet` is the network prefix and the instance
|
||||
# takes an address inside it (exposed as `vultr_instance.blog.v6_main_ip`).
|
||||
# Static IP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "vultr_reserved_ip" "v4" {
|
||||
region = var.region
|
||||
ip_type = "v4"
|
||||
label = "${var.hostname}-v4"
|
||||
instance_id = vultr_instance.blog.id
|
||||
resource "google_compute_address" "static_ip" {
|
||||
name = "${var.hostname}-ip"
|
||||
region = var.gcp_region
|
||||
}
|
||||
|
||||
resource "vultr_reserved_ip" "v6" {
|
||||
region = var.region
|
||||
ip_type = "v6"
|
||||
label = "${var.hostname}-v6"
|
||||
instance_id = vultr_instance.blog.id
|
||||
# ---------------------------------------------------------------------------
|
||||
# GCE Instance (AlmaLinux 10 equivalent / e2-small)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_instance" "blog" {
|
||||
name = var.hostname
|
||||
machine_type = "e2-small"
|
||||
zone = var.gcp_zone
|
||||
|
||||
tags = ["http-server", "https-server", "ssh-server"]
|
||||
|
||||
boot_disk {
|
||||
initialize_params {
|
||||
image = "almalinux-cloud/almalinux-9" # Update to Alma 10 when available
|
||||
size = 20
|
||||
}
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = google_compute_network.vpc.name
|
||||
subnetwork = google_compute_subnetwork.subnet.name
|
||||
|
||||
access_config {
|
||||
nat_ip = google_compute_address.static_ip.address
|
||||
}
|
||||
}
|
||||
|
||||
metadata = {
|
||||
enable-oslogin = "TRUE"
|
||||
}
|
||||
|
||||
service_account {
|
||||
scopes = ["cloud-platform"]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DNS Records (Imported from Current State)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_dns_managed_zone" "public" {
|
||||
name = "public"
|
||||
dns_name = "${var.domain}."
|
||||
}
|
||||
|
||||
resource "google_dns_record_set" "root_a" {
|
||||
name = "${var.domain}."
|
||||
type = "A"
|
||||
ttl = 300
|
||||
managed_zone = google_dns_managed_zone.public.name
|
||||
rrdatas = [google_compute_address.static_ip.address]
|
||||
}
|
||||
|
||||
resource "google_dns_record_set" "www_cname" {
|
||||
name = "www.${var.domain}."
|
||||
type = "CNAME"
|
||||
ttl = 300
|
||||
managed_zone = google_dns_managed_zone.public.name
|
||||
rrdatas = ["${var.domain}."]
|
||||
}
|
||||
|
||||
resource "google_dns_record_set" "mail_a" {
|
||||
name = "mail.${var.domain}."
|
||||
type = "A"
|
||||
ttl = 300
|
||||
managed_zone = google_dns_managed_zone.public.name
|
||||
rrdatas = ["194.195.211.88"] # Preserving current mail record
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artifact Registry for Container Images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_artifact_registry_repository" "repo" {
|
||||
location = var.gcp_region
|
||||
repository_id = "dev-blog"
|
||||
description = "Docker repository for dev-blog"
|
||||
format = "DOCKER"
|
||||
|
||||
cleanup_policy_dry_run = false
|
||||
|
||||
cleanup_policies {
|
||||
id = "keep-last-3"
|
||||
action = "KEEP"
|
||||
most_recent_versions {
|
||||
keep_count = 3
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_policies {
|
||||
id = "delete-others"
|
||||
action = "DELETE"
|
||||
condition {
|
||||
tag_state = "ANY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloud Build Trigger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_cloudbuild_trigger" "daily_build" {
|
||||
name = "daily-blog-build"
|
||||
description = "Triggered by Scheduler at 6 AM CT"
|
||||
|
||||
filename = "cloudbuild.yaml"
|
||||
|
||||
# Link this to your repository (Requires manual connection in GCP Console once)
|
||||
# or use a generic trigger if pushing source.
|
||||
trigger_template {
|
||||
branch_name = "main"
|
||||
repo_name = "dev-blog" # Update this to your repo name
|
||||
}
|
||||
|
||||
substitutions = {
|
||||
_BUCKET = google_storage_bucket.content.name
|
||||
_REGION = var.gcp_region
|
||||
_REPO = google_artifact_registry_repository.repo.repository_id
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service Account for Scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_service_account" "scheduler_sa" {
|
||||
account_id = "blog-scheduler-sa"
|
||||
display_name = "Service Account for Cloud Scheduler"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "scheduler_build_editor" {
|
||||
project = var.gcp_project_id
|
||||
role = "roles/cloudbuild.builds.editor"
|
||||
member = "serviceAccount:${google_service_account.scheduler_sa.email}"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloud Scheduler Job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_cloud_scheduler_job" "daily_trigger" {
|
||||
name = "daily-6am-build-trigger"
|
||||
description = "Triggers the blog build every day at 6 AM CT"
|
||||
schedule = "0 6 * * *"
|
||||
time_zone = "America/Chicago"
|
||||
attempt_deadline = "320s"
|
||||
|
||||
http_target {
|
||||
http_method = "POST"
|
||||
uri = "https://cloudbuild.googleapis.com/v1/projects/${var.gcp_project_id}/locations/global/triggers/${google_cloudbuild_trigger.daily_build.trigger_id}:run"
|
||||
|
||||
oauth_token {
|
||||
service_account_email = google_service_account.scheduler_sa.email
|
||||
}
|
||||
|
||||
body = base64encode(jsonencode({
|
||||
branchName = "main"
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret Manager for Backup Credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_secret_manager_secret" "restic_password" {
|
||||
secret_id = "restic-password"
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "r2_access_key" {
|
||||
secret_id = "r2-access-key-id"
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "r2_secret_key" {
|
||||
secret_id = "r2-secret-access-key"
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret" "restic_repo" {
|
||||
secret_id = "restic-repository"
|
||||
replication {
|
||||
auto {}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloud Function for Restic Backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_storage_bucket" "function_source" {
|
||||
name = "${var.gcp_project_id}-function-source"
|
||||
location = var.gcp_region
|
||||
}
|
||||
|
||||
resource "google_service_account" "backup_sa" {
|
||||
account_id = "blog-backup-sa"
|
||||
display_name = "Service Account for Backup Function"
|
||||
}
|
||||
|
||||
resource "google_cloudfunctions2_function" "backup" {
|
||||
name = "blog-restic-backup"
|
||||
location = var.gcp_region
|
||||
description = "Runs restic backup on GCS object change"
|
||||
|
||||
build_config {
|
||||
runtime = "python311"
|
||||
entry_point = "run_backup"
|
||||
source {
|
||||
storage_source {
|
||||
bucket = google_storage_bucket.function_source.name
|
||||
object = "backup-source.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
service_config {
|
||||
max_instance_count = 1
|
||||
available_memory = "512Mi"
|
||||
timeout_seconds = 540
|
||||
service_account_email = google_service_account.backup_sa.email
|
||||
|
||||
environment_variables = {
|
||||
SOURCE_BUCKET = "gs://${google_storage_bucket.content.name}"
|
||||
}
|
||||
|
||||
secret_environment_variables {
|
||||
key = "RESTIC_PASSWORD"
|
||||
project_id = var.gcp_project_id
|
||||
secret = google_secret_manager_secret.restic_password.secret_id
|
||||
version = "latest"
|
||||
}
|
||||
|
||||
secret_environment_variables {
|
||||
key = "AWS_ACCESS_KEY_ID"
|
||||
project_id = var.gcp_project_id
|
||||
secret = google_secret_manager_secret.r2_access_key.secret_id
|
||||
version = "latest"
|
||||
}
|
||||
|
||||
secret_environment_variables {
|
||||
key = "AWS_SECRET_ACCESS_KEY"
|
||||
project_id = var.gcp_project_id
|
||||
secret = google_secret_manager_secret.r2_secret_key.secret_id
|
||||
version = "latest"
|
||||
}
|
||||
|
||||
secret_environment_variables {
|
||||
key = "RESTIC_REPOSITORY"
|
||||
project_id = var.gcp_project_id
|
||||
secret = google_secret_manager_secret.restic_repo.secret_id
|
||||
version = "latest"
|
||||
}
|
||||
}
|
||||
|
||||
event_trigger {
|
||||
trigger_region = var.gcp_region
|
||||
event_type = "google.cloud.storage.object.v1.finalized"
|
||||
retry_policy = "RETRY_POLICY_RETRY"
|
||||
service_account_email = google_service_account.backup_sa.email
|
||||
event_filters {
|
||||
attribute = "bucket"
|
||||
value = google_storage_bucket.content.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IAM for Backup Function
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_project_iam_member" "backup_storage_viewer" {
|
||||
project = var.gcp_project_id
|
||||
role = "roles/storage.objectViewer"
|
||||
member = "serviceAccount:${google_service_account.backup_sa.email}"
|
||||
}
|
||||
|
||||
resource "google_secret_manager_secret_iam_member" "backup_secrets" {
|
||||
for_each = toset([
|
||||
google_secret_manager_secret.restic_password.id,
|
||||
google_secret_manager_secret.r2_access_key.id,
|
||||
google_secret_manager_secret.r2_secret_key.id,
|
||||
google_secret_manager_secret.restic_repo.id
|
||||
])
|
||||
secret_id = each.key
|
||||
role = "roles/secretmanager.secretAccessor"
|
||||
member = "serviceAccount:${google_service_account.backup_sa.email}"
|
||||
}
|
||||
|
||||
# Grant Eventarc permission to trigger the function
|
||||
resource "google_project_iam_member" "eventarc_pubsub_publisher" {
|
||||
project = var.gcp_project_id
|
||||
role = "roles/pubsub.publisher"
|
||||
member = "serviceAccount:service-${data.google_project.project.number}@gcp-sa-pubsub.iam.gserviceaccount.com"
|
||||
}
|
||||
|
||||
data "google_project" "project" {}
|
||||
|
||||
+14
-32
@@ -1,47 +1,29 @@
|
||||
variable "vultr_api_key" {
|
||||
description = "Vultr API key. Provide via TF_VAR_vultr_api_key env var."
|
||||
variable "gcp_project_id" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "The GCP Project ID"
|
||||
default = "dev-blog-494815"
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "Vultr region code."
|
||||
variable "gcp_region" {
|
||||
type = string
|
||||
default = "sea" # Seattle, WA
|
||||
description = "GCP region"
|
||||
default = "us-central1"
|
||||
}
|
||||
|
||||
variable "plan" {
|
||||
description = "Vultr instance plan."
|
||||
variable "gcp_zone" {
|
||||
type = string
|
||||
default = "vc2-1c-2gb"
|
||||
}
|
||||
|
||||
variable "os_name_filter" {
|
||||
description = "Substring to match an OS name in the Vultr OS catalog."
|
||||
type = string
|
||||
default = "AlmaLinux 10"
|
||||
description = "GCP zone"
|
||||
default = "us-central1-a"
|
||||
}
|
||||
|
||||
variable "hostname" {
|
||||
description = "Hostname / label for the instance."
|
||||
type = string
|
||||
description = "The hostname for the instance"
|
||||
default = "dev-blog"
|
||||
}
|
||||
|
||||
variable "ssh_key_ids" {
|
||||
description = "List of pre-existing Vultr SSH key IDs to inject."
|
||||
type = list(string)
|
||||
default = []
|
||||
}
|
||||
|
||||
variable "backup_hour_utc" {
|
||||
description = "Hour of day (UTC, 0-23) for the daily automated backup."
|
||||
type = number
|
||||
default = 8
|
||||
}
|
||||
|
||||
variable "tags" {
|
||||
description = "Tags to apply to the instance."
|
||||
type = list(string)
|
||||
default = ["dev_blog", "managed-by=opentofu"]
|
||||
variable "domain" {
|
||||
type = string
|
||||
description = "The primary domain name"
|
||||
default = "jasonmross.dev"
|
||||
}
|
||||
|
||||
+7
-11
@@ -2,20 +2,16 @@ terraform {
|
||||
required_version = ">= 1.8.0"
|
||||
|
||||
required_providers {
|
||||
vultr = {
|
||||
source = "vultr/vultr"
|
||||
version = "~> 2.21"
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = "~> 6.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Cloudflare R2 is S3-compatible, so we use the s3 backend with a custom
|
||||
# endpoint. Backend values that depend on secrets/account-specific data are
|
||||
# supplied at init time via `-backend-config=backend.hcl` (see README).
|
||||
backend "s3" {
|
||||
key = "dev_blog/terraform.tfstate"
|
||||
region = "auto"
|
||||
|
||||
# R2 quirks: skip AWS-specific validations and use path-style URLs.
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_region_validation = true
|
||||
@@ -25,8 +21,8 @@ terraform {
|
||||
}
|
||||
}
|
||||
|
||||
provider "vultr" {
|
||||
api_key = var.vultr_api_key
|
||||
rate_limit = 700
|
||||
retry_limit = 3
|
||||
provider "google" {
|
||||
project = var.gcp_project_id
|
||||
region = var.gcp_region
|
||||
zone = var.gcp_zone
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user