thermograph/infra/terraform/modules/thermograph-host/main.tf
Emi Griffith df409f88b3
All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 4s
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / validate-observability (pull_request) Successful in 20s
PR build (required check) / build-frontend (pull_request) Successful in 37s
PR build (required check) / build-backend (pull_request) Successful in 51s
PR build (required check) / gate (pull_request) Successful in 1s
registry: move image and repo references to the Jinemi namespace
Repos moved to the Jinemi org; the container packages did not follow, since
Forgejo does not transfer packages with a repo. The deploy path still resolved
`emi/thermograph/*` — a user_redirect to admin_emi — while build-push.yml
derives its push path from ${github.repository}, now jinemi/thermograph. The
next backend or frontend build would have published somewhere no deploy looks.

Point the image paths at jinemi/thermograph/* (lowercase: OCI references admit
no uppercase, which is why build-push.yml already pipes through tr), the clone
URLs at Jinemi/thermograph, and the registry logins at admin_emi — the account
that actually owns the tokens, rather than the redirect.

The live tags and both ci-runner tags were copied into the Jinemi namespace
first, so the switch has something to pull. thermograph-infra,
thermograph-observability and the retired */app packages stay under admin_emi;
they did not move.
2026-08-01 09:25:02 -07:00

307 lines
14 KiB
HCL

locals {
# 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}"
# Layer the self-hosted Open-Meteo overlay on hosts that self-host the archive.
effective_compose_files = var.openmeteo ? concat(var.compose_files, ["docker-compose.openmeteo.yml"]) : var.compose_files
# `-f a -f b` for the compose invocations (dev layers the dev overlay).
compose_flags = join(" ", [for f in local.effective_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 local.effective_compose_files : filesha256("${var.repo_root}/${f}")])
# Rendered /etc/thermograph-topology.env — non-secret sizing/routing config only.
# Every app secret is rendered separately, at deploy time, from the SOPS+age
# vault (see remote-exec step 3 below) — Terraform never sees or carries them.
topology_env_content = templatefile("${path.module}/templates/thermograph-topology.env.tftpl", {
app_port = var.app_port
workers = var.workers
app_cpus = var.app_cpus
db_cpus = var.db_cpus
db_memory = var.db_memory
timescaledb_tag = var.timescaledb_tag
base = "/"
base_url = local.base_url
cookie_secure = local.cookie_secure
openmeteo = var.openmeteo
om_data_dir = var.om_data_dir
})
# 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
frontend_port = var.frontend_port
}) : "# No public domain on this host; Caddy is not managed here.\n"
# systemd unit that keeps the object-storage bucket rclone-mounted at om_data_dir,
# so the Open-Meteo containers read the ERA5 .om archive from it. Only installed on
# openmeteo hosts; --allow-other lets the container (root) read the FUSE mount.
rclone_unit = <<-UNIT
[Unit]
Description=rclone mount ERA5 archive (Thermograph)
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
ExecStartPre=/bin/mkdir -p ${var.om_data_dir}
ExecStart=/usr/bin/rclone mount ${var.om_bucket_remote} ${var.om_data_dir} --config /etc/rclone/rclone.conf --vfs-cache-mode full --vfs-cache-max-size ${var.om_vfs_cache_max} --dir-cache-time 12h --allow-other --umask 000
ExecStop=/bin/fusermount -u ${var.om_data_dir}
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
UNIT
rclone_unit_content = var.openmeteo ? local.rclone_unit : "# openmeteo disabled on this host\n"
}
resource "null_resource" "host" {
# Re-provision when the rendered topology env, the compose files, the branch,
# sizing, the app image tag, or the Caddyfile change.
triggers = {
topology_env_sha = sha256(local.topology_env_content)
compose_sha = local.compose_files_sha
compose_flags = local.compose_flags
caddy_sha = sha256(local.caddy_content)
branch = var.git_branch
backend_image_tag = var.backend_image_tag
frontend_image_tag = var.frontend_image_tag
app_dir = var.app_dir
sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}/${var.timescaledb_tag}"
# Re-provision when the archive self-hosting config changes. The rclone.conf is
# hashed (nonsensitive on a one-way digest) so a credential rotation redeploys.
openmeteo = "${var.openmeteo}/${var.om_data_dir}/${var.om_bucket_remote}/${var.om_vfs_cache_max}"
om_conf = var.openmeteo ? nonsensitive(sha256(var.om_rclone_conf)) : "off"
}
connection {
type = "ssh"
host = var.host
user = var.ssh_user
private_key = file(pathexpand(var.ssh_private_key_path))
timeout = "5m"
}
# Push the rendered topology env via `content` (consistent with the other
# `file` provisioners here, though nothing in it is secret).
provisioner "file" {
content = local.topology_env_content
destination = "/tmp/thermograph-topology.env"
}
provisioner "file" {
content = local.caddy_content
destination = "/tmp/thermograph.Caddyfile"
}
# rclone config (bucket credentials) + the mount unit. Pushed via `content` so the
# secret never touches local disk; harmless placeholders on non-openmeteo hosts.
provisioner "file" {
content = var.openmeteo ? var.om_rclone_conf : "# openmeteo disabled on this host\n"
destination = "/tmp/thermograph.rclone.conf"
}
provisioner "file" {
content = local.rclone_unit_content
destination = "/tmp/rclone-om.service"
}
# 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
]
}
# 1b. Self-hosted archive: rclone-mount the ERA5 bucket before compose up so
# open-meteo-api can read .om from object storage. Skipped on non-openmeteo hosts.
provisioner "remote-exec" {
inline = [
<<-EOT
set -eu
if [ "${var.openmeteo}" != "true" ]; then
rm -f /tmp/thermograph.rclone.conf /tmp/rclone-om.service
# Undo any prior openmeteo setup so a toggled-off host doesn't wait on a mount.
if [ -e /etc/systemd/system/docker.service.d/10-wait-rclone.conf ]; then
sudo rm -f /etc/systemd/system/docker.service.d/10-wait-rclone.conf
sudo systemctl disable --now rclone-om >/dev/null 2>&1 || true
sudo systemctl daemon-reload
fi
echo "[${var.name}] openmeteo: disabled"
exit 0
fi
echo "[${var.name}] openmeteo: rclone mount ${var.om_bucket_remote} -> ${var.om_data_dir}"
if ! command -v rclone >/dev/null 2>&1; then
curl -fsSL https://rclone.org/install.sh | sudo bash
fi
# FUSE allow_other so the container (root) can read a mount owned by this user.
if ! grep -q '^user_allow_other' /etc/fuse.conf 2>/dev/null; then
echo user_allow_other | sudo tee -a /etc/fuse.conf >/dev/null
fi
sudo install -d -m 0755 /etc/rclone
sudo install -m 0600 /tmp/thermograph.rclone.conf /etc/rclone/rclone.conf
sudo install -m 0644 /tmp/rclone-om.service /etc/systemd/system/rclone-om.service
rm -f /tmp/thermograph.rclone.conf /tmp/rclone-om.service
sudo install -d -m 0755 ${var.om_data_dir}
sudo systemctl daemon-reload
sudo systemctl enable rclone-om
sudo systemctl restart rclone-om
# Wait for the mount before compose bind-mounts it.
i=0
while [ "$i" -lt 30 ]; do
if mountpoint -q ${var.om_data_dir}; then break; fi
i=$((i + 1)); sleep 2
done
if ! mountpoint -q ${var.om_data_dir}; then
echo "[${var.name}] RCLONE MOUNT FAILED" >&2
sudo systemctl status rclone-om --no-pager || true
exit 1
fi
# Order Docker after the mount on every boot, so the restart-policy containers
# never bind an empty mount point. rclone-om is Type=notify, so `After` waits
# until the mount is actually ready — not merely that the unit was launched.
sudo install -d -m 0755 /etc/systemd/system/docker.service.d
printf '[Unit]\nWants=rclone-om.service\nAfter=rclone-om.service\n' \
| sudo tee /etc/systemd/system/docker.service.d/10-wait-rclone.conf >/dev/null
sudo systemctl daemon-reload
echo "[${var.name}] openmeteo: mounted at ${var.om_data_dir}"
EOT
]
}
# 2. Sync the checkout to the host's branch (cloning first on a fresh box). This
# is now the INFRA repo's checkout (compose files, db init, Caddy templates,
# the secrets vault, deploy.sh itself) — the app's own source is never checked
# out on the host; only its published registry image is pulled (step 3).
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-topology.env, render /etc/thermograph.env from the
# SOPS+age vault (deploy/render-secrets.sh, now part of this same checkout),
# then bring the stack up on the EXPLICIT per-service app image tags — there's no
# app checkout on this host to derive a tag from (see var.backend_image_tag /
# frontend_image_tag). The compose file reads BACKEND_IMAGE_TAG / FRONTEND_IMAGE_TAG
# (for admin_emi/thermograph-backend/app and admin_emi/thermograph-frontend/app), so we export
# those, not the old single IMAGE_PATH/IMAGE_TAG. docker runs as root (sources both
# env files 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 topology env + render secrets + docker compose pull + up"
sudo install -m 0640 -o root -g root /tmp/thermograph-topology.env /etc/thermograph-topology.env
rm -f /tmp/thermograph-topology.env
sudo bash -c '
set -eu
cd ${var.app_dir}
. deploy/render-secrets.sh
render_thermograph_secrets ${var.app_dir}
set -a; . /etc/thermograph-topology.env; . /etc/thermograph.env; set +a
export REGISTRY_HOST="git.thermograph.org" BACKEND_IMAGE_TAG="${var.backend_image_tag}" FRONTEND_IMAGE_TAG="${var.frontend_image_tag}"
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" --username admin_emi --password-stdin
docker compose ${local.compose_flags} pull backend frontend
docker compose ${local.compose_flags} up -d --remove-orphans
'
EOT
]
}
# 4. Health check backend on loopback -- a plain "/" here already exercises
# the whole chain end to end even without Caddy in front (backend's own
# reverse-proxy fallback forwards to frontend), so one curl covers both
# services in every topology this module supports (repo-split Stage 4).
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 backend; docker compose ${local.compose_flags} logs --tail=50 frontend' || true
exit 1
fi
echo "[${var.name}] OK: serving on 127.0.0.1:${var.app_port}"
if ! curl -fsS -o /dev/null "http://127.0.0.1:${var.frontend_port}/healthz"; then
echo "[${var.name}] frontend's own /healthz failed directly (backend's proxy to it may still work) -- check separately" >&2
sudo bash -c 'cd ${var.app_dir} && docker compose ${local.compose_flags} logs --tail=50 frontend' || true
fi
EOT
]
}
}
output "host" {
description = "Address this module manages."
value = var.host
}
output "role" {
description = "Role of this host."
value = var.role
}