Self-host the ERA5 archive via Open-Meteo (object storage) (#224)
Get the 45-year historical record off the rate-limited public Open-Meteo archive API by running a private Open-Meteo instance that serves the era5_seamless blend (0.1° ERA5-Land + 0.25° ERA5 for gusts) from the compressed .om archive in object storage, mounted on the host with rclone. - climate.py: make ARCHIVE_URL env-driven (THERMOGRAPH_ARCHIVE_URL) and pin models=era5_seamless on the archive fetches only, so a self-hosted instance serves the same 0.1° resolution; forecast path unchanged. The public API's default is already seamless, so dev/beta (URL unset) behave identically. - docker-compose.openmeteo.yml: open-meteo-api + two rolling sync workers (era5_land 0.1°, era5 0.25° for gusts), bind-mounting the object-storage mount; the overlay points the app at the local instance. - Makefile: om-up / om-down / om-backfill (one-time full-history backfill). - Terraform: per-host openmeteo flag layers the overlay, renders OM_DATA_DIR, and provisions the host rclone systemd mount from the bucket credentials. - deploy/openmeteo: operator runbook + rclone mount unit template.
This commit is contained in:
parent
47aea569ed
commit
a26ca72834
10 changed files with 468 additions and 2 deletions
172
deploy/openmeteo/README.md
Normal file
172
deploy/openmeteo/README.md
Normal file
|
|
@ -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://<ACCOUNT_ID>.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.
|
||||||
32
deploy/openmeteo/rclone-mount.service.example
Normal file
32
deploy/openmeteo/rclone-mount.service.example
Normal file
|
|
@ -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
|
||||||
|
|
@ -21,6 +21,15 @@ POSTGRES_PASSWORD=change-me
|
||||||
# sync with POSTGRES_PASSWORD above).
|
# sync with POSTGRES_PASSWORD above).
|
||||||
#THERMOGRAPH_DATABASE_URL=postgresql+asyncpg://thermograph:change-me@db:5432/thermograph
|
#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;
|
# 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).
|
# leave unset only for plain-HTTP LAN dev (a Secure cookie is never sent over HTTP).
|
||||||
THERMOGRAPH_COOKIE_SECURE=1
|
THERMOGRAPH_COOKIE_SECURE=1
|
||||||
|
|
|
||||||
70
docker-compose.openmeteo.yml
Normal file
70
docker-compose.openmeteo.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -25,12 +25,19 @@ module "host" {
|
||||||
app_cpus = each.value.app_cpus
|
app_cpus = each.value.app_cpus
|
||||||
db_cpus = each.value.db_cpus
|
db_cpus = each.value.db_cpus
|
||||||
db_memory = each.value.db_memory
|
db_memory = each.value.db_memory
|
||||||
|
openmeteo = each.value.openmeteo
|
||||||
|
om_data_dir = each.value.om_data_dir
|
||||||
|
|
||||||
# Shared infra config
|
# Shared infra config
|
||||||
repo_root = local.repo_root
|
repo_root = local.repo_root
|
||||||
repo_url = var.repo_url
|
repo_url = var.repo_url
|
||||||
app_port = var.app_port
|
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
|
# Shared secrets -> /etc/thermograph.env
|
||||||
postgres_password = var.postgres_password
|
postgres_password = var.postgres_password
|
||||||
auth_secret = var.auth_secret
|
auth_secret = var.auth_secret
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@ locals {
|
||||||
# Public base URL: the domain over HTTPS, else the raw host:port for a Caddy-less box.
|
# 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}"
|
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).
|
# `-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.
|
# 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).
|
# Rendered /etc/thermograph.env (sensitive — carries every secret).
|
||||||
env_content = templatefile("${path.module}/templates/thermograph.env.tftpl", {
|
env_content = templatefile("${path.module}/templates/thermograph.env.tftpl", {
|
||||||
|
|
@ -30,6 +33,8 @@ locals {
|
||||||
base = "/"
|
base = "/"
|
||||||
base_url = local.base_url
|
base_url = local.base_url
|
||||||
cookie_secure = local.cookie_secure
|
cookie_secure = local.cookie_secure
|
||||||
|
openmeteo = var.openmeteo
|
||||||
|
om_data_dir = var.om_data_dir
|
||||||
vapid_private_key = var.vapid_private_key
|
vapid_private_key = var.vapid_private_key
|
||||||
vapid_public_key = var.vapid_public_key
|
vapid_public_key = var.vapid_public_key
|
||||||
vapid_contact = var.vapid_contact
|
vapid_contact = var.vapid_contact
|
||||||
|
|
@ -55,6 +60,29 @@ locals {
|
||||||
domain = var.domain
|
domain = var.domain
|
||||||
port = var.app_port
|
port = var.app_port
|
||||||
}) : "# No public domain on this host; Caddy is not managed here.\n"
|
}) : "# 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" {
|
resource "null_resource" "host" {
|
||||||
|
|
@ -69,6 +97,10 @@ resource "null_resource" "host" {
|
||||||
branch = var.git_branch
|
branch = var.git_branch
|
||||||
app_dir = var.app_dir
|
app_dir = var.app_dir
|
||||||
sizing = "${var.workers}/${var.app_cpus}/${var.db_cpus}/${var.db_memory}"
|
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 {
|
connection {
|
||||||
|
|
@ -90,6 +122,18 @@ resource "null_resource" "host" {
|
||||||
destination = "/tmp/thermograph.Caddyfile"
|
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.
|
# 1. Host setup: Docker + compose plugin, ufw firewall, and (domain hosts) Caddy.
|
||||||
provisioner "remote-exec" {
|
provisioner "remote-exec" {
|
||||||
inline = [
|
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).
|
# 2. Sync the checkout to the host's branch (cloning first on a fresh box).
|
||||||
provisioner "remote-exec" {
|
provisioner "remote-exec" {
|
||||||
inline = [
|
inline = [
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ WORKERS=${workers}
|
||||||
APP_CPUS=${app_cpus}
|
APP_CPUS=${app_cpus}
|
||||||
DB_CPUS=${db_cpus}
|
DB_CPUS=${db_cpus}
|
||||||
DB_MEMORY=${db_memory}
|
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 --------------------------------------------------------------------
|
# --- Serving --------------------------------------------------------------------
|
||||||
THERMOGRAPH_BASE=${base}
|
THERMOGRAPH_BASE=${base}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,37 @@ variable "compose_files" {
|
||||||
type = list(string)
|
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" {
|
variable "app_dir" {
|
||||||
description = "Checkout path on the host."
|
description = "Checkout path on the host."
|
||||||
type = string
|
type = string
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ hosts = {
|
||||||
app_cpus = 8
|
app_cpus = 8
|
||||||
db_cpus = 4
|
db_cpus = 4
|
||||||
db_memory = "16g"
|
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`).
|
# Beta / testing: the OLD VPS, repurposed (branch `main`).
|
||||||
|
|
@ -52,6 +56,23 @@ hosts = {
|
||||||
# repo_url = "https://github.com/griffemi/thermograph.git"
|
# repo_url = "https://github.com/griffemi/thermograph.git"
|
||||||
# app_port = 8137
|
# 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)
|
# Shared secrets (DUMMY values — replace, and keep this file out of git)
|
||||||
# ---------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ variable "hosts" {
|
||||||
app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS)
|
app_cpus = optional(number, 4) # app container CPU cap (APP_CPUS)
|
||||||
db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS)
|
db_cpus = optional(number, 2) # db container CPU cap (DB_CPUS)
|
||||||
db_memory = optional(string, "8g") # db container memory cap (DB_MEMORY)
|
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
|
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)
|
# Shared secrets (rendered into /etc/thermograph.env on every host)
|
||||||
# ---------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue