201 lines
7.6 KiB
Terraform
201 lines
7.6 KiB
Terraform
|
|
locals {
|
||
|
|
# THERMOGRAPH_DATABASE_URL is built from the same password compose uses for the db
|
||
|
|
# container, so the app always matches the database it initialized.
|
||
|
|
database_url = "postgresql+asyncpg://thermograph:${var.postgres_password}@db:5432/thermograph"
|
||
|
|
|
||
|
|
# A Secure cookie is only sent over HTTPS, so enable it only where Caddy terminates
|
||
|
|
# TLS (domain set). A plain-HTTP dev box (domain "") would otherwise drop its login
|
||
|
|
# cookie and no one could stay signed in.
|
||
|
|
cookie_secure = var.domain != "" ? "1" : "0"
|
||
|
|
|
||
|
|
# Public base URL: the domain over HTTPS, else the raw host:port for a Caddy-less box.
|
||
|
|
base_url = var.domain != "" ? "https://${var.domain}" : "http://${var.host}:${var.app_port}"
|
||
|
|
|
||
|
|
# `-f a -f b` for the compose invocations (dev layers the dev overlay).
|
||
|
|
compose_flags = join(" ", [for f in var.compose_files : "-f ${f}"])
|
||
|
|
|
||
|
|
# Hash the local compose files so a compose edit re-triggers the remote deploy.
|
||
|
|
compose_files_sha = join(",", [for f in var.compose_files : filesha256("${var.repo_root}/${f}")])
|
||
|
|
|
||
|
|
# Rendered /etc/thermograph.env (sensitive — carries every secret).
|
||
|
|
env_content = templatefile("${path.module}/templates/thermograph.env.tftpl", {
|
||
|
|
app_port = var.app_port
|
||
|
|
postgres_password = var.postgres_password
|
||
|
|
database_url = local.database_url
|
||
|
|
auth_secret = var.auth_secret
|
||
|
|
workers = var.workers
|
||
|
|
app_cpus = var.app_cpus
|
||
|
|
db_cpus = var.db_cpus
|
||
|
|
db_memory = var.db_memory
|
||
|
|
base = "/"
|
||
|
|
base_url = local.base_url
|
||
|
|
cookie_secure = local.cookie_secure
|
||
|
|
vapid_private_key = var.vapid_private_key
|
||
|
|
vapid_public_key = var.vapid_public_key
|
||
|
|
vapid_contact = var.vapid_contact
|
||
|
|
google_verify = var.google_verify
|
||
|
|
bing_verify = var.bing_verify
|
||
|
|
mail_backend = var.mail_backend
|
||
|
|
smtp_host = var.smtp_host
|
||
|
|
smtp_port = var.smtp_port
|
||
|
|
smtp_user = var.smtp_user
|
||
|
|
smtp_password = var.smtp_password
|
||
|
|
smtp_starttls = var.smtp_starttls
|
||
|
|
mail_from = var.mail_from
|
||
|
|
mail_reply_to = var.mail_reply_to
|
||
|
|
discord_webhook = var.discord_webhook
|
||
|
|
discord_public_key = var.discord_public_key
|
||
|
|
discord_app_id = var.discord_app_id
|
||
|
|
discord_bot_token = var.discord_bot_token
|
||
|
|
discord_client_secret = var.discord_client_secret
|
||
|
|
})
|
||
|
|
|
||
|
|
# Caddyfile is only meaningful on a host with a public domain.
|
||
|
|
caddy_content = var.domain != "" ? templatefile("${path.module}/templates/Caddyfile.tftpl", {
|
||
|
|
domain = var.domain
|
||
|
|
port = var.app_port
|
||
|
|
}) : "# No public domain on this host; Caddy is not managed here.\n"
|
||
|
|
}
|
||
|
|
|
||
|
|
resource "null_resource" "host" {
|
||
|
|
# Re-provision when the rendered env, the compose files, the branch, sizing, or the
|
||
|
|
# Caddyfile change. sha256 of the secret-bearing env is unwrapped with nonsensitive()
|
||
|
|
# (a one-way hash leaks nothing) so plans stay readable.
|
||
|
|
triggers = {
|
||
|
|
env_sha = nonsensitive(sha256(local.env_content))
|
||
|
|
compose_sha = local.compose_files_sha
|
||
|
|
compose_flags = local.compose_flags
|
||
|
|
caddy_sha = sha256(local.caddy_content)
|
||
|
|
branch = var.git_branch
|
||
|
|
app_dir = var.app_dir
|
||
|
|
sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}"
|
||
|
|
}
|
||
|
|
|
||
|
|
connection {
|
||
|
|
type = "ssh"
|
||
|
|
host = var.host
|
||
|
|
user = var.ssh_user
|
||
|
|
private_key = file(pathexpand(var.ssh_private_key_path))
|
||
|
|
timeout = "5m"
|
||
|
|
}
|
||
|
|
|
||
|
|
# Push the rendered secrets via `content` so they are never written to local disk.
|
||
|
|
provisioner "file" {
|
||
|
|
content = local.env_content
|
||
|
|
destination = "/tmp/thermograph.env"
|
||
|
|
}
|
||
|
|
|
||
|
|
provisioner "file" {
|
||
|
|
content = local.caddy_content
|
||
|
|
destination = "/tmp/thermograph.Caddyfile"
|
||
|
|
}
|
||
|
|
|
||
|
|
# 1. Host setup: Docker + compose plugin, ufw firewall, and (domain hosts) Caddy.
|
||
|
|
provisioner "remote-exec" {
|
||
|
|
inline = [
|
||
|
|
<<-EOT
|
||
|
|
set -eu
|
||
|
|
echo "[${var.name}] setup: docker, compose plugin, firewall"
|
||
|
|
if ! command -v docker >/dev/null 2>&1; then
|
||
|
|
curl -fsSL https://get.docker.com | sudo sh
|
||
|
|
fi
|
||
|
|
sudo usermod -aG docker "$(id -un)" || true
|
||
|
|
if ! sudo docker compose version >/dev/null 2>&1; then
|
||
|
|
sudo apt-get update -y
|
||
|
|
sudo apt-get install -y docker-compose-plugin
|
||
|
|
fi
|
||
|
|
if ! command -v ufw >/dev/null 2>&1; then
|
||
|
|
sudo apt-get update -y
|
||
|
|
sudo apt-get install -y ufw
|
||
|
|
fi
|
||
|
|
sudo ufw allow 22/tcp
|
||
|
|
sudo ufw allow 80/tcp
|
||
|
|
sudo ufw allow 443/tcp
|
||
|
|
DOMAIN='${var.domain}'
|
||
|
|
if [ -z "$DOMAIN" ]; then
|
||
|
|
sudo ufw allow ${var.app_port}/tcp
|
||
|
|
fi
|
||
|
|
sudo ufw --force enable
|
||
|
|
if [ -n "$DOMAIN" ]; then
|
||
|
|
if ! command -v caddy >/dev/null 2>&1; then
|
||
|
|
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||
|
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||
|
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
|
||
|
|
sudo apt-get update -y
|
||
|
|
sudo apt-get install -y caddy
|
||
|
|
fi
|
||
|
|
sudo install -m 0644 /tmp/thermograph.Caddyfile /etc/caddy/Caddyfile
|
||
|
|
sudo systemctl reload caddy || sudo systemctl restart caddy
|
||
|
|
fi
|
||
|
|
rm -f /tmp/thermograph.Caddyfile
|
||
|
|
EOT
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
# 2. Sync the checkout to the host's branch (cloning first on a fresh box).
|
||
|
|
provisioner "remote-exec" {
|
||
|
|
inline = [
|
||
|
|
<<-EOT
|
||
|
|
set -eu
|
||
|
|
echo "[${var.name}] code: ${var.app_dir} -> origin/${var.git_branch}"
|
||
|
|
sudo mkdir -p ${var.app_dir}
|
||
|
|
sudo chown -R "$(id -un)":"$(id -gn)" ${var.app_dir}
|
||
|
|
if [ ! -d ${var.app_dir}/.git ]; then
|
||
|
|
git clone ${var.repo_url} ${var.app_dir}
|
||
|
|
fi
|
||
|
|
cd ${var.app_dir}
|
||
|
|
git fetch --prune origin ${var.git_branch}
|
||
|
|
git reset --hard origin/${var.git_branch}
|
||
|
|
EOT
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
# 3. Install /etc/thermograph.env, then bring the stack up. docker runs as root
|
||
|
|
# (sources the env file in the same shell) so it never depends on the docker
|
||
|
|
# group membership taking effect in this session.
|
||
|
|
provisioner "remote-exec" {
|
||
|
|
inline = [
|
||
|
|
<<-EOT
|
||
|
|
set -eu
|
||
|
|
echo "[${var.name}] deploy: install env + docker compose up"
|
||
|
|
sudo install -m 0640 -o root -g root /tmp/thermograph.env /etc/thermograph.env
|
||
|
|
rm -f /tmp/thermograph.env
|
||
|
|
sudo bash -c 'set -a; . /etc/thermograph.env; set +a; cd ${var.app_dir} && docker compose ${local.compose_flags} up -d --build'
|
||
|
|
EOT
|
||
|
|
]
|
||
|
|
}
|
||
|
|
|
||
|
|
# 4. Health check the app on loopback.
|
||
|
|
provisioner "remote-exec" {
|
||
|
|
inline = [
|
||
|
|
<<-EOT
|
||
|
|
set -eu
|
||
|
|
echo "[${var.name}] health: http://127.0.0.1:${var.app_port}/"
|
||
|
|
ok=0
|
||
|
|
i=0
|
||
|
|
while [ "$i" -lt 30 ]; do
|
||
|
|
if curl -fsS -o /dev/null "http://127.0.0.1:${var.app_port}/"; then ok=1; break; fi
|
||
|
|
i=$((i + 1))
|
||
|
|
sleep 2
|
||
|
|
done
|
||
|
|
if [ "$ok" != 1 ]; then
|
||
|
|
echo "[${var.name}] HEALTH CHECK FAILED" >&2
|
||
|
|
sudo bash -c 'cd ${var.app_dir} && docker compose ${local.compose_flags} ps; docker compose ${local.compose_flags} logs --tail=50 app' || true
|
||
|
|
exit 1
|
||
|
|
fi
|
||
|
|
echo "[${var.name}] OK: serving on 127.0.0.1:${var.app_port}"
|
||
|
|
EOT
|
||
|
|
]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
output "host" {
|
||
|
|
description = "Address this module manages."
|
||
|
|
value = var.host
|
||
|
|
}
|
||
|
|
|
||
|
|
output "role" {
|
||
|
|
description = "Role of this host."
|
||
|
|
value = var.role
|
||
|
|
}
|