246368c132
Legion is offline. Vault needs a GCP-native home. This commit provisions a 3-node Vault HA cluster on GCE e2-small VMs (us-central1 a/b/c) backed by Raft consensus with GCP KMS auto-unseal already in place. - vault-nodes.tf: 3 VMs with separate pd-ssd data disks (prevent_destroy), IAP-only SSH firewall, Raft peer firewall (8201), global HTTPS LB for vault.neuralplatform.ai with Google-managed cert, HTTP health checks against /v1/sys/health - vault/startup.sh: idempotent boot script — installs Vault from HashiCorp APT repo, mounts/formats the data disk, writes Raft config with dynamic retry_join peers resolved via gcloud metadata, starts vault.service - vault-auth-gcp.tf: GCP Secret Manager secret for init script; outputs the service account emails needed for GCP auth roles - vault/configure-vault-auth.sh: post-init script to enable gcp auth method, write marketing-policy and soma-policy, bind roles to Cloud Run service account identities After apply: set vault_lb_ip output as A record for vault.neuralplatform.ai in Cloudflare, then run vault operator init on vault-node-1 to bootstrap.
358 lines
12 KiB
Terraform
358 lines
12 KiB
Terraform
# ── Vault HA Cluster — GCE-based Raft ────────────────────────────────────────
|
|
#
|
|
# Three GCE e2-small VMs across us-central1-{a,b,c} running HashiCorp Vault
|
|
# in Raft HA mode. Auto-unseal via the existing GCP KMS key (vault-kms.tf).
|
|
#
|
|
# Architecture:
|
|
# - vault-node-1 (us-central1-a) — bootstrapped first, others join via retry_join
|
|
# - vault-node-2 (us-central1-b)
|
|
# - vault-node-3 (us-central1-c)
|
|
# - Each VM gets the vault-node SA attached for KMS auto-unseal + ADC
|
|
# - Internal traffic: VMs talk Raft over port 8201 on GCP internal IPs
|
|
# - External access: HTTPS regional LB → port 8200
|
|
# (vault.neuralplatform.ai Cloudflare DNS → GCP LB IP)
|
|
#
|
|
# Ops SSH:
|
|
# gcloud compute ssh vault-node-1 --zone=us-central1-a --tunnel-through-iap
|
|
#
|
|
# After first boot, initialize Vault on node-1:
|
|
# vault operator init (save the root token + recovery keys securely)
|
|
# Nodes 2 and 3 auto-join via retry_join once they boot.
|
|
|
|
locals {
|
|
vault_version = "1.19.2"
|
|
|
|
vault_nodes = {
|
|
"vault-node-1" = { zone = "us-central1-a", node_id = "vault-node-1" }
|
|
"vault-node-2" = { zone = "us-central1-b", node_id = "vault-node-2" }
|
|
"vault-node-3" = { zone = "us-central1-c", node_id = "vault-node-3" }
|
|
}
|
|
}
|
|
|
|
# ── Service Account for Vault nodes ──────────────────────────────────────────
|
|
# Reuses the vault-unseal SA from vault-kms.tf for KMS access.
|
|
# Additional roles: logging + metrics from ops agent.
|
|
|
|
resource "google_project_iam_member" "vault_node_log_writer" {
|
|
project = var.project_id
|
|
role = "roles/logging.logWriter"
|
|
member = "serviceAccount:${google_service_account.vault_unseal.email}"
|
|
}
|
|
|
|
resource "google_project_iam_member" "vault_node_metric_writer" {
|
|
project = var.project_id
|
|
role = "roles/monitoring.metricWriter"
|
|
member = "serviceAccount:${google_service_account.vault_unseal.email}"
|
|
}
|
|
|
|
# Allow Vault nodes to read their own instance metadata (needed for GCP auth method later)
|
|
resource "google_project_iam_member" "vault_node_compute_viewer" {
|
|
project = var.project_id
|
|
role = "roles/compute.viewer"
|
|
member = "serviceAccount:${google_service_account.vault_unseal.email}"
|
|
}
|
|
|
|
# ── Startup script staged in GCS ─────────────────────────────────────────────
|
|
# Stored in the existing runner-assets bucket (reuse infrastructure).
|
|
# The script installs Vault, writes the config, and starts the systemd unit.
|
|
|
|
resource "google_storage_bucket_object" "vault_startup" {
|
|
name = "vault/startup.sh"
|
|
bucket = google_storage_bucket.runner_assets.name
|
|
source = "${path.module}/vault/startup.sh"
|
|
|
|
metadata = {
|
|
sha256 = filesha256("${path.module}/vault/startup.sh")
|
|
}
|
|
}
|
|
|
|
resource "google_storage_bucket_iam_member" "vault_node_bucket_read" {
|
|
bucket = google_storage_bucket.runner_assets.name
|
|
role = "roles/storage.objectViewer"
|
|
member = "serviceAccount:${google_service_account.vault_unseal.email}"
|
|
}
|
|
|
|
# ── Vault node VMs ────────────────────────────────────────────────────────────
|
|
|
|
resource "google_compute_instance" "vault_node" {
|
|
for_each = local.vault_nodes
|
|
name = each.key
|
|
machine_type = "e2-small"
|
|
zone = each.value.zone
|
|
project = var.project_id
|
|
|
|
tags = ["vault-node", "allow-iap-ssh"]
|
|
|
|
boot_disk {
|
|
initialize_params {
|
|
image = "projects/debian-cloud/global/images/family/debian-12"
|
|
size = 20
|
|
type = "pd-balanced"
|
|
}
|
|
}
|
|
|
|
# Separate persistent disk for Raft data — survives VM recreation
|
|
attached_disk {
|
|
source = google_compute_disk.vault_data[each.key].self_link
|
|
device_name = "vault-data"
|
|
mode = "READ_WRITE"
|
|
}
|
|
|
|
network_interface {
|
|
network = "default"
|
|
# No external IP — accessed via IAP SSH and the internal LB
|
|
# Vault API is published externally via the regional LB below
|
|
access_config {}
|
|
}
|
|
|
|
service_account {
|
|
email = google_service_account.vault_unseal.email
|
|
scopes = ["cloud-platform"]
|
|
}
|
|
|
|
metadata = {
|
|
# Pull and execute the real startup script from GCS
|
|
startup-script = <<-EOT
|
|
#!/usr/bin/env bash
|
|
set -euxo pipefail
|
|
apt-get update -y
|
|
apt-get install -y curl ca-certificates apt-transport-https gnupg google-cloud-cli
|
|
gsutil cat gs://${google_storage_bucket.runner_assets.name}/${google_storage_bucket_object.vault_startup.name} \
|
|
> /tmp/vault-startup.sh
|
|
chmod +x /tmp/vault-startup.sh
|
|
VAULT_NODE_ID="${each.value.node_id}" \
|
|
VAULT_VERSION="${local.vault_version}" \
|
|
/tmp/vault-startup.sh
|
|
EOT
|
|
|
|
enable-oslogin = "TRUE"
|
|
}
|
|
|
|
allow_stopping_for_update = true
|
|
|
|
depends_on = [
|
|
google_compute_disk.vault_data,
|
|
google_storage_bucket_object.vault_startup,
|
|
google_storage_bucket_iam_member.vault_node_bucket_read,
|
|
]
|
|
}
|
|
|
|
# ── Persistent data disks ─────────────────────────────────────────────────────
|
|
# 10 GiB per node. Kept separate from the boot disk so Raft data
|
|
# survives a full VM deletion and recreation.
|
|
|
|
resource "google_compute_disk" "vault_data" {
|
|
for_each = local.vault_nodes
|
|
name = "${each.key}-data"
|
|
zone = each.value.zone
|
|
project = var.project_id
|
|
type = "pd-ssd"
|
|
size = 10
|
|
|
|
labels = {
|
|
managed-by = "terraform"
|
|
service = "vault"
|
|
node = each.key
|
|
}
|
|
|
|
lifecycle {
|
|
prevent_destroy = true
|
|
}
|
|
}
|
|
|
|
# ── Firewall rules ────────────────────────────────────────────────────────────
|
|
|
|
# IAP SSH — ops access without a public SSH port
|
|
resource "google_compute_firewall" "vault_iap_ssh" {
|
|
name = "vault-iap-ssh"
|
|
network = "default"
|
|
project = var.project_id
|
|
direction = "INGRESS"
|
|
priority = 1000
|
|
|
|
allow {
|
|
protocol = "tcp"
|
|
ports = ["22"]
|
|
}
|
|
|
|
source_ranges = ["35.235.240.0/20"] # GCP IAP CIDR
|
|
target_tags = ["vault-node"]
|
|
}
|
|
|
|
# Raft cluster traffic — node-to-node port 8201 (internal only)
|
|
resource "google_compute_firewall" "vault_raft" {
|
|
name = "vault-raft-internal"
|
|
network = "default"
|
|
project = var.project_id
|
|
direction = "INGRESS"
|
|
priority = 1000
|
|
|
|
allow {
|
|
protocol = "tcp"
|
|
ports = ["8201"]
|
|
}
|
|
|
|
source_tags = ["vault-node"]
|
|
target_tags = ["vault-node"]
|
|
}
|
|
|
|
# Vault API — allow from the GCP health check ranges and the LB
|
|
resource "google_compute_firewall" "vault_api" {
|
|
name = "vault-api-from-lb"
|
|
network = "default"
|
|
project = var.project_id
|
|
direction = "INGRESS"
|
|
priority = 1000
|
|
|
|
allow {
|
|
protocol = "tcp"
|
|
ports = ["8200"]
|
|
}
|
|
|
|
# GCP health check ranges (130.211.0.0/22, 35.191.0.0/16)
|
|
# and RFC1918 for any internal service access
|
|
source_ranges = ["130.211.0.0/22", "35.191.0.0/16", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
|
|
target_tags = ["vault-node"]
|
|
}
|
|
|
|
# ── Instance Groups (one unmanaged group per zone for the LB) ─────────────────
|
|
|
|
resource "google_compute_instance_group" "vault" {
|
|
for_each = local.vault_nodes
|
|
name = "vault-${each.key}"
|
|
zone = each.value.zone
|
|
project = var.project_id
|
|
instances = [google_compute_instance.vault_node[each.key].self_link]
|
|
|
|
named_port {
|
|
name = "vault-api"
|
|
port = 8200
|
|
}
|
|
}
|
|
|
|
# ── Regional HTTPS LB for vault.neuralplatform.ai ─────────────────────────────
|
|
# We use a global external HTTPS LB (same scheme as the prod marketing LB)
|
|
# so we can attach a Google-managed cert for vault.neuralplatform.ai.
|
|
#
|
|
# TLS is terminated at the LB. Vault listens on plain 8200 internally.
|
|
# The Cloudflare DNS A record for vault.neuralplatform.ai (neuralplatform zone)
|
|
# must point to vault_lb_ip output below — add it in Cloudflare dashboard
|
|
# or in the legion Terraform if you bring that zone under TF management.
|
|
|
|
resource "google_compute_global_address" "vault" {
|
|
name = "vault-ip"
|
|
project = var.project_id
|
|
}
|
|
|
|
resource "google_compute_managed_ssl_certificate" "vault" {
|
|
name = "vault-cert"
|
|
project = var.project_id
|
|
|
|
managed {
|
|
domains = ["vault.neuralplatform.ai"]
|
|
}
|
|
}
|
|
|
|
resource "google_compute_health_check" "vault" {
|
|
name = "vault-health"
|
|
project = var.project_id
|
|
|
|
# Vault listens on plain HTTP (tls_disable = true) — TLS is terminated at the LB
|
|
http_health_check {
|
|
port = 8200
|
|
request_path = "/v1/sys/health?standbyok=true&sealedok=true&uninitcode=200"
|
|
}
|
|
|
|
check_interval_sec = 15
|
|
timeout_sec = 5
|
|
healthy_threshold = 2
|
|
unhealthy_threshold = 3
|
|
}
|
|
|
|
resource "google_compute_backend_service" "vault" {
|
|
name = "vault-backend"
|
|
project = var.project_id
|
|
load_balancing_scheme = "EXTERNAL_MANAGED"
|
|
protocol = "HTTP" # Vault serves plain HTTP; TLS terminates at the LB
|
|
timeout_sec = 30
|
|
port_name = "vault-api"
|
|
|
|
health_checks = [google_compute_health_check.vault.self_link]
|
|
|
|
dynamic "backend" {
|
|
for_each = local.vault_nodes
|
|
content {
|
|
group = google_compute_instance_group.vault[backend.key].self_link
|
|
balancing_mode = "UTILIZATION"
|
|
max_utilization = 0.8
|
|
}
|
|
}
|
|
|
|
log_config {
|
|
enable = true
|
|
sample_rate = 1.0
|
|
}
|
|
}
|
|
|
|
resource "google_compute_url_map" "vault" {
|
|
name = "vault-urlmap"
|
|
project = var.project_id
|
|
default_service = google_compute_backend_service.vault.self_link
|
|
}
|
|
|
|
resource "google_compute_target_https_proxy" "vault" {
|
|
name = "vault-https-proxy"
|
|
project = var.project_id
|
|
url_map = google_compute_url_map.vault.self_link
|
|
ssl_certificates = [google_compute_managed_ssl_certificate.vault.self_link]
|
|
}
|
|
|
|
resource "google_compute_target_http_proxy" "vault_redirect" {
|
|
name = "vault-http-proxy"
|
|
project = var.project_id
|
|
url_map = google_compute_url_map.vault_redirect.self_link
|
|
}
|
|
|
|
resource "google_compute_url_map" "vault_redirect" {
|
|
name = "vault-urlmap-http-redirect"
|
|
project = var.project_id
|
|
|
|
default_url_redirect {
|
|
https_redirect = true
|
|
redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
|
|
strip_query = false
|
|
}
|
|
}
|
|
|
|
resource "google_compute_global_forwarding_rule" "vault_https" {
|
|
name = "vault-fwd-https"
|
|
project = var.project_id
|
|
target = google_compute_target_https_proxy.vault.self_link
|
|
ip_address = google_compute_global_address.vault.address
|
|
port_range = "443"
|
|
load_balancing_scheme = "EXTERNAL_MANAGED"
|
|
}
|
|
|
|
resource "google_compute_global_forwarding_rule" "vault_http" {
|
|
name = "vault-fwd-http"
|
|
project = var.project_id
|
|
target = google_compute_target_http_proxy.vault_redirect.self_link
|
|
ip_address = google_compute_global_address.vault.address
|
|
port_range = "80"
|
|
load_balancing_scheme = "EXTERNAL_MANAGED"
|
|
}
|
|
|
|
# ── Outputs ───────────────────────────────────────────────────────────────────
|
|
|
|
output "vault_lb_ip" {
|
|
description = "Global IP for vault.neuralplatform.ai — set as DNS A record in Cloudflare (neuralplatform.ai zone)"
|
|
value = google_compute_global_address.vault.address
|
|
}
|
|
|
|
output "vault_node_zones" {
|
|
description = "Zone placement of each Vault node"
|
|
value = {
|
|
for k, v in local.vault_nodes : k => v.zone
|
|
}
|
|
}
|