diff --git a/deploy/openmeteo/README.md b/deploy/openmeteo/README.md new file mode 100644 index 0000000..271497f --- /dev/null +++ b/deploy/openmeteo/README.md @@ -0,0 +1,172 @@ +# Self-hosted Open-Meteo (ERA5 archive) + +Operator runbook for running a private Open-Meteo instance that serves the +ERA5 historical archive to Thermograph, with the `.om` data held in object +storage and surfaced on the host through an rclone FUSE mount. + +## 1. What this is and why + +Thermograph reads daily historical weather from an ERA5 archive. Off the +shelf that means the public Open-Meteo archive API, which is rate-limited and +not something to lean on for a production workload. This overlay runs our own +Open-Meteo instance instead: + +- `open-meteo-api` serves `era5_seamless` locally (internal to the compose + network). The app points at it via `THERMOGRAPH_ARCHIVE_URL`. +- Two sync workers (`open-meteo-sync-land`, `open-meteo-sync-era5`) pull `.om` + files from Open-Meteo's free AWS Open-Data bucket (no API key, no rate + limit) and write them into the archive. + +`era5_seamless` is a blend: 0.1° ERA5-Land for temperature, precipitation, +humidity, and wind, plus 0.25° ERA5 for wind gusts (which ERA5-Land does not +carry) and as the over-water fallback. The 0.1° resolution is a hard +requirement for city-level accuracy. + +The archive is ~1–1.5 TB of `.om`. That does not fit on the host's 400 GB +disk, so it lives in an object-storage bucket and is mounted read/write via +rclone. The host disk holds only the bounded rclone VFS cache, the app's own +parquet cache, and Postgres — never a full copy. The app never reads object +storage directly; it only talks to `open-meteo-api`, which reads the mount. + +## 2. Object storage prerequisites + +Provision a bucket of ~2 TB (holds the ~1–1.5 TB archive with headroom): + +- **Co-located with the VPS** and **low- or no-egress** — e.g. Cloudflare R2, + or same-provider object storage in the VPS's region. +- Co-location and low egress matter because the mount serves per-request + **range reads**: every archive query pulls byte ranges out of many `.om` + files. Cross-region or metered egress turns each read into latency and cost. + Keep the bucket next to the compute and on a plan that does not bill egress. + +You'll need S3-compatible credentials (access key id + secret) and the +bucket's S3 endpoint. + +## 3. Host rclone mount setup + +Install rclone: + +```sh +curl https://rclone.org/install.sh | sudo bash +``` + +Create `/etc/rclone/rclone.conf` with an S3-compatible remote. Use real +values for your provider; **never commit real secrets**: + +```ini +[om-archive] +type = s3 +provider = Cloudflare +endpoint = https://.r2.cloudflarestorage.com +access_key_id = REPLACE_WITH_ACCESS_KEY_ID +secret_access_key = REPLACE_WITH_SECRET_ACCESS_KEY +``` + +Install and enable the mount unit (see `rclone-mount.service.example`): + +```sh +sudo install -m0644 rclone-mount.service.example /etc/systemd/system/rclone-om.service +# edit BUCKET_NAME in the unit first; see the unit's header comments +sudo systemctl daemon-reload +sudo systemctl enable --now rclone-om +``` + +Verify the mount: + +```sh +mountpoint -q /mnt/om-archive && echo mounted +ls /mnt/om-archive +``` + +The unit runs with `--vfs-cache-mode full` and a bounded +`--vfs-cache-max-size` (e.g. `80G`). Full VFS cache mode keeps hot cells on +local disk after first read so repeat range reads don't go back to the bucket, +and the size cap keeps that cache inside the 400 GB disk budget by evicting +cold data. + +## 4. Point the overlay at the mount + +`OM_DATA_DIR` is read from the environment at `docker compose` time; in prod +it lives in `/etc/thermograph.env` (which the systemd unit sources). Set it to +the mount: + +```sh +# /etc/thermograph.env +OM_DATA_DIR=/mnt/om-archive +``` + +All three services bind-mount `${OM_DATA_DIR}` to `/app/data`, so with this +set the archive reads and writes go to object storage. + +## 5. One-time backfill + +The sync workers only maintain a rolling recent window. To populate full +history, run the backfill once: + +```sh +make om-backfill +``` + +This runs each dataset's `sync ... --past-days 17000` once via +`docker compose run --rm --no-deps`. It writes the full ~1–1.5 TB of `.om` +**to object storage**, is **hours-long**, and should be watched against the +2 TB budget. Run it **before** flipping the app over — if the app is pointed +at an empty instance it falls back to NASA POWER, so bring the archive up to +full history first. + +For a smoke test, shorten the window with `OM_BACKFILL_DAYS`: + +```sh +make om-backfill OM_BACKFILL_DAYS=30 +``` + +## 6. Bring the overlay up + +```sh +make om-up +``` + +This is `docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml +up -d --build`. The overlay sets `THERMOGRAPH_ARCHIVE_URL=http://open-meteo-api:8080/v1/archive` +automatically. + +Smoke test the internal API and confirm every daily field is present and +non-null. `open-meteo-api` has **no published host port**, so either run the +curl from inside the compose network, or temporarily publish the port: + +```sh +docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml \ + exec app curl "http://open-meteo-api:8080/v1/archive?latitude=47.6&longitude=-122.3&start_date=2026-06-01&end_date=2026-06-10&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max,wind_gusts_10m_max,apparent_temperature_max,apparent_temperature_min,relative_humidity_2m_mean&models=era5_seamless&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch" +``` + +If you've temporarily published the port instead, the same query works +against `http://127.0.0.1:8080/...`. Check that each `daily` array is present +and free of nulls across the date range. + +## 7. Keeping current + +The two sync workers re-sync `--past-days 14` every 1440 minutes (daily). If a +worker stalls, the recent tail of history goes stale — the last couple of +weeks stop updating. (The separate forecast path is unaffected; this only +touches the historical archive.) + +Check the workers: + +```sh +docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml \ + logs open-meteo-sync-land +docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml \ + logs open-meteo-sync-era5 +``` + +## 8. Attribution + +The ERA5 and ERA5-Land data is CC-BY-4.0 (Copernicus/ECMWF), sourced via +Open-Meteo. The Open-Meteo software is AGPLv3. The app already surfaces this +credit; keep it in place. + +## 9. Not on dev/beta + +This overlay runs only on the self-hosting host (prod). Dev and beta leave +`THERMOGRAPH_ARCHIVE_URL` unset and use the public Open-Meteo archive API — do +not bring the overlay up there. diff --git a/deploy/openmeteo/rclone-mount.service.example b/deploy/openmeteo/rclone-mount.service.example new file mode 100644 index 0000000..6866769 --- /dev/null +++ b/deploy/openmeteo/rclone-mount.service.example @@ -0,0 +1,32 @@ +# rclone FUSE mount for the Open-Meteo (ERA5) archive bucket. +# +# Before installing: +# - Replace BUCKET_NAME below with the object-storage bucket name. +# - `--allow-other` requires `user_allow_other` to be set in /etc/fuse.conf. +# - The `om-archive` remote must exist in /etc/rclone/rclone.conf (S3 remote). +# +# Install: +# sudo install -m0644 rclone-mount.service.example /etc/systemd/system/rclone-om.service +# sudo systemctl daemon-reload +# sudo systemctl enable --now rclone-om + +[Unit] +Description=rclone mount for Open-Meteo ERA5 archive +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +ExecStartPre=/bin/mkdir -p /mnt/om-archive +ExecStart=/usr/bin/rclone mount om-archive:BUCKET_NAME /mnt/om-archive \ + --config /etc/rclone/rclone.conf \ + --vfs-cache-mode full \ + --vfs-cache-max-size 80G \ + --dir-cache-time 12h \ + --allow-other \ + --umask 000 +ExecStop=/bin/fusermount -u /mnt/om-archive +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/deploy/thermograph.env.example b/deploy/thermograph.env.example index d2b4f9e..d1320c0 100644 --- a/deploy/thermograph.env.example +++ b/deploy/thermograph.env.example @@ -21,6 +21,15 @@ POSTGRES_PASSWORD=change-me # sync with POSTGRES_PASSWORD above). #THERMOGRAPH_DATABASE_URL=postgresql+asyncpg://thermograph:change-me@db:5432/thermograph +# --- Historical archive (self-hosted Open-Meteo) -------------------------------- +# Where the app fetches its 45-year daily history. Unset (default) → the public +# Open-Meteo archive API (rate-limited). On the self-hosting host, the +# docker-compose.openmeteo.yml overlay sets this to the internal service URL for +# you, so you normally DON'T set it here. Only pin it to run the app outside that +# overlay against a reachable Open-Meteo instance. Leave it UNSET rather than empty: +# an empty value is honored as-is and would break the fallback to the public API. +#THERMOGRAPH_ARCHIVE_URL=http://open-meteo-api:8080/v1/archive + # Mark the session cookie Secure — required behind Caddy's HTTPS. Set to 1 in prod; # leave unset only for plain-HTTP LAN dev (a Secure cookie is never sent over HTTP). THERMOGRAPH_COOKIE_SECURE=1 diff --git a/docker-compose.openmeteo.yml b/docker-compose.openmeteo.yml new file mode 100644 index 0000000..4c7c676 --- /dev/null +++ b/docker-compose.openmeteo.yml @@ -0,0 +1,70 @@ +# Self-hosted Open-Meteo overlay — serves the ERA5 archive locally so the app is +# off the rate-limited public archive API. Enable it only on the self-hosting host: +# +# docker compose -f docker-compose.yml -f docker-compose.openmeteo.yml up -d +# +# The hourly .om data (copernicus_era5_land 0.1° + copernicus_era5 0.25°) lives in +# OBJECT STORAGE, surfaced on the host as a local directory by an rclone FUSE mount +# (a host systemd unit — see deploy/openmeteo/README.md). OM_DATA_DIR points the +# containers at that mount; it defaults to ./data/om-archive so a local smoke test +# works against a plain directory. Object storage is never bind-mounted into the app +# — only Open-Meteo reads it; the app just talks HTTP to open-meteo-api and keeps its +# own small daily-per-cell parquet cache. +# +# One-time backfill (writes ~1–1.5 TB of .om to object storage — run once before the +# app is flipped over): `make om-backfill`. + +services: + # Serves /v1/archive on the compose network. No host port: only `app` reaches it, + # as http://open-meteo-api:8080. Reads .om from the object-storage mount, on demand + # for any point, returning JSON byte-identical to the public archive API. + open-meteo-api: + image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo} + command: ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] + volumes: + - ${OM_DATA_DIR:-./data/om-archive}:/app/data + restart: unless-stopped + + # Rolling keep-current worker for ERA5-Land (0.1°): the surface variables it + # carries. --past-days 14 re-syncs the recent tail every day, appending new days and + # absorbing ERA5T→final corrections. dew_point_2m → relative_humidity and + # shortwave_radiation → apparent_temperature are derived server-side at query time. + open-meteo-sync-land: + image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo} + command: + - "sync" + - "copernicus_era5_land" + - "temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m" + - "--past-days" + - "${OM_SYNC_PAST_DAYS:-14}" + - "--repeat-interval" + - "1440" + volumes: + - ${OM_DATA_DIR:-./data/om-archive}:/app/data + restart: unless-stopped + + # Rolling keep-current worker for ERA5 (0.25°): wind gusts — absent from ERA5-Land — + # plus the seamless-blend fallback over water/coastline where ERA5-Land has no data. + open-meteo-sync-era5: + image: ${OM_IMAGE:-ghcr.io/open-meteo/open-meteo} + command: + - "sync" + - "copernicus_era5" + - "wind_gusts_10m,temperature_2m,dew_point_2m,precipitation,shortwave_radiation,wind_u_component_10m,wind_v_component_10m" + - "--past-days" + - "${OM_SYNC_PAST_DAYS:-14}" + - "--repeat-interval" + - "1440" + volumes: + - ${OM_DATA_DIR:-./data/om-archive}:/app/data + restart: unless-stopped + + # Point the app's historical fetches at the local instance. Set here (not in the + # base file) so it applies only when this overlay is active — and so an unset var + # never reaches the app as an empty string, which would defeat climate.py's default. + app: + depends_on: + open-meteo-api: + condition: service_started + environment: + THERMOGRAPH_ARCHIVE_URL: http://open-meteo-api:8080/v1/archive diff --git a/terraform/main.tf b/terraform/main.tf index f02eabd..160d02e 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -25,12 +25,19 @@ module "host" { app_cpus = each.value.app_cpus db_cpus = each.value.db_cpus db_memory = each.value.db_memory + openmeteo = each.value.openmeteo + om_data_dir = each.value.om_data_dir # Shared infra config repo_root = local.repo_root repo_url = var.repo_url app_port = var.app_port + # Shared object-storage config (only used where openmeteo = true) + om_bucket_remote = var.om_bucket_remote + om_rclone_conf = var.om_rclone_conf + om_vfs_cache_max = var.om_vfs_cache_max + # Shared secrets -> /etc/thermograph.env postgres_password = var.postgres_password auth_secret = var.auth_secret diff --git a/terraform/modules/thermograph-host/main.tf b/terraform/modules/thermograph-host/main.tf index 44bcd6c..4b99a48 100644 --- a/terraform/modules/thermograph-host/main.tf +++ b/terraform/modules/thermograph-host/main.tf @@ -11,11 +11,14 @@ locals { # 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 var.compose_files : "-f ${f}"]) + 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 var.compose_files : filesha256("${var.repo_root}/${f}")]) + compose_files_sha = join(",", [for f in local.effective_compose_files : filesha256("${var.repo_root}/${f}")]) # Rendered /etc/thermograph.env (sensitive — carries every secret). env_content = templatefile("${path.module}/templates/thermograph.env.tftpl", { @@ -30,6 +33,8 @@ locals { base = "/" base_url = local.base_url cookie_secure = local.cookie_secure + openmeteo = var.openmeteo + om_data_dir = var.om_data_dir vapid_private_key = var.vapid_private_key vapid_public_key = var.vapid_public_key vapid_contact = var.vapid_contact @@ -55,6 +60,29 @@ locals { domain = var.domain port = var.app_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" { @@ -69,6 +97,10 @@ resource "null_resource" "host" { branch = var.git_branch app_dir = var.app_dir sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}" + # 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 { @@ -90,6 +122,18 @@ resource "null_resource" "host" { 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 = [ @@ -132,6 +176,49 @@ resource "null_resource" "host" { ] } + # 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 + 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 + 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). provisioner "remote-exec" { inline = [ diff --git a/terraform/modules/thermograph-host/templates/thermograph.env.tftpl b/terraform/modules/thermograph-host/templates/thermograph.env.tftpl index 275581a..1b14a44 100644 --- a/terraform/modules/thermograph-host/templates/thermograph.env.tftpl +++ b/terraform/modules/thermograph-host/templates/thermograph.env.tftpl @@ -18,6 +18,13 @@ WORKERS=${workers} APP_CPUS=${app_cpus} DB_CPUS=${db_cpus} DB_MEMORY=${db_memory} +%{ if openmeteo ~} + +# --- Self-hosted Open-Meteo archive (docker-compose.openmeteo.yml) --------------- +# Host rclone mount of the ERA5 object-storage bucket; the overlay bind-mounts it +# into the Open-Meteo containers. THERMOGRAPH_ARCHIVE_URL is set by the overlay. +OM_DATA_DIR=${om_data_dir} +%{ endif ~} # --- Serving -------------------------------------------------------------------- THERMOGRAPH_BASE=${base} diff --git a/terraform/modules/thermograph-host/variables.tf b/terraform/modules/thermograph-host/variables.tf index 615c751..a8bdda0 100644 --- a/terraform/modules/thermograph-host/variables.tf +++ b/terraform/modules/thermograph-host/variables.tf @@ -40,6 +40,37 @@ variable "compose_files" { type = list(string) } +variable "openmeteo" { + description = "Self-host the ERA5 archive: layer docker-compose.openmeteo.yml + provision the host rclone mount." + type = bool + default = false +} + +variable "om_data_dir" { + description = "Host rclone mount point for the archive bucket (OM_DATA_DIR the overlay bind-mounts)." + type = string + default = "/mnt/om-archive" +} + +variable "om_bucket_remote" { + description = "rclone remote:path for the archive bucket (mounted at om_data_dir)." + type = string + default = "" +} + +variable "om_rclone_conf" { + description = "rclone.conf contents installed to /etc/rclone/rclone.conf. Sensitive." + type = string + default = "" + sensitive = true +} + +variable "om_vfs_cache_max" { + description = "rclone --vfs-cache-max-size for the mount's on-disk hot cache." + type = string + default = "80G" +} + variable "app_dir" { description = "Checkout path on the host." type = string diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example index 2c9a753..84a13ec 100644 --- a/terraform/terraform.tfvars.example +++ b/terraform/terraform.tfvars.example @@ -25,6 +25,10 @@ hosts = { app_cpus = 8 db_cpus = 4 db_memory = "16g" + # Self-host the ERA5 archive here: layers docker-compose.openmeteo.yml and + # provisions the rclone mount of the object-storage bucket (om_* vars below). + openmeteo = true + om_data_dir = "/mnt/om-archive" } # Beta / testing: the OLD VPS, repurposed (branch `main`). @@ -52,6 +56,23 @@ hosts = { # repo_url = "https://github.com/griffemi/thermograph.git" # app_port = 8137 +# --------------------------------------------------------------------------------- +# Self-hosted Open-Meteo archive (only used by hosts with openmeteo = true) -------- +# --------------------------------------------------------------------------------- +# The ERA5 .om archive lives in an object-storage bucket, rclone-mounted on the host. +# om_rclone_conf holds bucket credentials (sensitive; lands in state — keep out of git). +# See deploy/openmeteo/README.md for the bucket + mount setup. +om_bucket_remote = "om-archive:REPLACE_WITH_BUCKET_NAME" +om_vfs_cache_max = "80G" +om_rclone_conf = <<-RCLONE + [om-archive] + type = s3 + provider = Cloudflare + endpoint = https://REPLACE.r2.cloudflarestorage.com + access_key_id = REPLACE_WITH_ACCESS_KEY + secret_access_key = REPLACE_WITH_SECRET_KEY + RCLONE + # --------------------------------------------------------------------------------- # Shared secrets (DUMMY values — replace, and keep this file out of git) # --------------------------------------------------------------------------------- diff --git a/terraform/variables.tf b/terraform/variables.tf index 34da952..2258203 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -30,6 +30,10 @@ variable "hosts" { app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS) db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS) db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY) + # Self-host the ERA5 archive (docker-compose.openmeteo.yml + a host rclone mount + # of the object-storage bucket). Only the self-hosting host (prod) sets true. + openmeteo = optional(bool, false) + om_data_dir = optional(string, "/mnt/om-archive") # host rclone mount point (OM_DATA_DIR) })) } @@ -45,6 +49,32 @@ variable "app_port" { default = 8137 } +# --------------------------------------------------------------------------------- +# Self-hosted Open-Meteo (object storage) — consumed only by hosts with openmeteo=true +# --------------------------------------------------------------------------------- +# The ERA5 .om archive lives in an object-storage bucket, surfaced on the host by an +# rclone FUSE mount at each host's om_data_dir. These describe that bucket + mount. +# om_rclone_conf holds credentials, so it's sensitive and lands in state — keep the +# real value in terraform.tfvars (gitignored), never committed. +variable "om_bucket_remote" { + description = "rclone remote:path for the archive bucket, e.g. \"om-archive:thermograph-era5\" (matches a [remote] in om_rclone_conf)." + type = string + default = "" +} + +variable "om_rclone_conf" { + description = "Full rclone.conf contents defining the archive remote (installed to /etc/rclone/rclone.conf, 0600). Sensitive." + type = string + default = "" + sensitive = true +} + +variable "om_vfs_cache_max" { + description = "rclone --vfs-cache-max-size: bounds the on-disk hot cache for the mount (keep within the disk budget)." + type = string + default = "80G" +} + # --------------------------------------------------------------------------------- # Shared secrets (rendered into /etc/thermograph.env on every host) # ---------------------------------------------------------------------------------