From e0db31714632f752d4b7bda279c268160a392054 Mon Sep 17 00:00:00 2001 From: Mark Wissink Date: Sat, 19 Sep 2026 13:10:34 -0400 Subject: [PATCH] Infra --- .dockerignore | 7 ++ .env.example | 9 +++ .gitignore | 7 ++ Dockerfile | 5 +- compose.yaml | 43 +++++++++- deploy.sh | 35 ++++++++ infra/README.md | 75 ++++++++++++++++++ infra/railway-cli/Dockerfile | 17 ++++ infra/railway/.terraform.lock.hcl | 41 ++++++++++ infra/railway/main.tf | 127 ++++++++++++++++++++++++++++++ server.py | 65 ++++----------- worker.py | 69 ++++++++++++++++ 12 files changed, 444 insertions(+), 56 deletions(-) create mode 100644 .env.example create mode 100755 deploy.sh create mode 100644 infra/README.md create mode 100644 infra/railway-cli/Dockerfile create mode 100644 infra/railway/.terraform.lock.hcl create mode 100644 infra/railway/main.tf create mode 100644 worker.py diff --git a/.dockerignore b/.dockerignore index 956925d..8e14fe5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,10 @@ __pycache__ # The build input. The image ships the compiled bible/, not what it came from. source/ + +# Never in the image: credentials, and what only the deploy uses. +.env +dist/ +infra/ +.build/ +deploy.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f96bb86 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy to .env (gitignored) and fill in. The tools in compose.yaml read these. + +# Railway workspace token: railway.com/account/tokens, with the workspace +# selected. Not RAILWAY_TOKEN: the CLI takes that name as a project token. +RAILWAY_API_TOKEN= + +# Cloudflare API token for the aerlinn.net DNS records: Zone.DNS Edit and +# Zone.Zone Read, on that zone only. +CLOUDFLARE_API_TOKEN= diff --git a/.gitignore b/.gitignore index 86edccf..4c1a1a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,10 @@ __pycache__ source/ bible/ +dist/ +.terraform/ +*.tfstate +*.tfstate.backup +.env +.build/ +tfplan diff --git a/Dockerfile b/Dockerfile index e696d2b..873258a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,13 +2,14 @@ FROM python:3.13-alpine ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ SITE_ROOTS=/srv/assets:/srv/bible \ PORT=8080 WORKDIR /srv -# Baked in so the image runs standalone; compose bind-mounts over all three +# Baked in so the image runs standalone; compose bind-mounts over all of them # for live edits without a rebuild. -COPY server.py /srv/server.py +COPY server.py worker.py /srv/ COPY assets/ /srv/assets/ COPY bible/ /srv/bible/ diff --git a/compose.yaml b/compose.yaml index 29722f6..a5d693c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,3 +1,24 @@ +# The deploy tools are here too, pinned, so nothing gets installed on the +# host. They sit in the "tools" profile: `docker compose up -d` still starts +# only the site, and `docker compose run --rm terraform ...` / +# `... railway ...` runs them. + +x-tool: &tool + profiles: [tools] + # As you, so what they write into the repo (.terraform/, state, lock files) + # is yours and not root's. + user: "${UID:-1000}:${GID:-1000}" + working_dir: /work + volumes: + - .:/work + environment: + # The Terraform provider reads RAILWAY_TOKEN; the CLI reads a workspace + # token only as RAILWAY_API_TOKEN (RAILWAY_TOKEN is a project token to it). + - RAILWAY_TOKEN=${RAILWAY_API_TOKEN:-} + - RAILWAY_API_TOKEN + - CLOUDFLARE_API_TOKEN + - HOME=/tmp + services: bible: build: . @@ -7,11 +28,25 @@ services: # Host port is overridable: BIBLE_PORT=9000 docker compose up -d - "${BIBLE_PORT:-8085}:8080" volumes: - # Live edits without rebuilding. server.py is mounted too: leaving it - # baked into the image is how an old copy of it once kept running for - # days after the file on disk had changed. Drop these lines to serve the - # copy baked into the image instead. + # Live edits without rebuilding. server.py and worker.py are mounted + # too: leaving them baked into the image is how an old copy once kept + # running for days after the file on disk had changed. Drop these lines + # to serve the copy baked into the image instead. - ./server.py:/srv/server.py:ro + - ./worker.py:/srv/worker.py:ro - ./assets:/srv/assets:ro - ./bible:/srv/bible:ro restart: unless-stopped + + terraform: + <<: *tool + image: hashicorp/terraform:1.16.3 + + railway: + <<: *tool + build: infra/railway-cli + image: railway-cli:5.57.12 + # The CLI takes RAILWAY_TOKEN as a project token if it is set at all. + environment: + - RAILWAY_API_TOKEN + - HOME=/tmp diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..8e248ba --- /dev/null +++ b/deploy.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Publish the site to one environment: ./deploy.sh dev|prod +# +# Uploads the build context with Railway's CLI; Railway builds the Dockerfile +# (found on its own) and swaps the new deployment in once it is running. +set -euo pipefail +cd "$(dirname "$0")" + +env=${1:?usage: $0 dev|prod} +case $env in + prod) environment=production ;; + dev) environment=dev ;; + *) echo "usage: $0 dev|prod" >&2; exit 2 ;; +esac + +outputs=$(docker compose run --rm -T terraform -chdir=infra/railway output -json) +out() { python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]]["value"])' "$1" <<<"$outputs"; } + +project=$(out project_id) +service=$(out service) + +# Only what the Dockerfile copies. Uploading the repo would mean either +# leaving out bible/ (gitignored, and the CLI honors .gitignore) or sending +# .env and source/ along with it. +stage=.build +rm -rf "$stage" +mkdir "$stage" +cp -r Dockerfile .dockerignore server.py worker.py assets bible "$stage/" + +docker compose run --rm -T railway up "$stage" --path-as-root --no-gitignore --ci \ + --project "$project" --environment "$environment" --service "$service" \ + --message "deploy.sh $(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo +dirty)" + +rm -rf "$stage" +echo "deployed $env: https://$(python3 -c 'import json,sys; print(json.load(sys.stdin)["domains"]["value"][sys.argv[1]]["domain"])' "$environment" <<<"$outputs")" diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..af72cac --- /dev/null +++ b/infra/README.md @@ -0,0 +1,75 @@ +# Infrastructure + +The site on Railway: one project, `bible`, with a `production` and a `dev` +environment, and one service, `site`, that runs the repo's `Dockerfile` in +Virginia (`us-east4-eqdc4a`). Railway holds the certificates; `server.py` does +the routing there as it does locally. DNS stays on Cloudflare. + +| env | Railway environment | URL | +|------|---------------------|-------------------------------| +| prod | `production` | https://bible-2.aerlinn.net | +| dev | `dev` | https://bible-dev.aerlinn.net | + +``` +railway/ project, environments, service, custom domains, and their + CNAME + TXT records on Cloudflare +railway-cli/ Railway's CLI as a pinned image (theirs is x86-only) +``` + +## Tools + +Nothing to install but Docker. Terraform and Railway's CLI are services in +`compose.yaml` (profile `tools`), reading tokens from `.env`. From the repo +root: + +``` +docker compose run --rm terraform -chdir=infra/railway plan +docker compose run --rm railway status +``` + +Commit `.terraform.lock.hcl`; it pins the provider builds. + +## First-time setup + +1. **Tokens.** A Railway workspace token (account settings -> Tokens, with the + workspace selected; a project token cannot make projects) and the + Cloudflare token. `cp .env.example .env` and fill both in. + +2. **Project and domains.** + ``` + docker compose run --rm terraform -chdir=infra/railway init + docker compose run --rm terraform -chdir=infra/railway apply + ``` + +3. **Deploy**, below. Railway issues each certificate once it sees the CNAME; + the first HTTPS requests can fail for a minute or two until then. + +## Deploying + +``` +python3 utils/compile_bible.py # only when source/ changes +./deploy.sh dev +./deploy.sh prod +``` + +`deploy.sh` copies what the Dockerfile needs into `.build/` and uploads that +with `railway up`; Railway builds it and switches over when the new container +is running. Not the repo itself: the CLI skips gitignored paths, which +includes `bible/`, and telling it not to would send `.env` and `source/` too. +The build logs stream to the terminal, and the dashboard has them as well. + +Terraform never deploys, and the provider cannot set anything Railway calls +config-as-code (healthchecks, start commands); Railway's defaults do. + +## State + +Local: `infra/railway/terraform.tfstate`, gitignored. Back it up. If it is +lost, nothing on Railway goes away; `terraform import` each resource back +(IDs from the dashboard URLs; the Cloudflare records as `/`). + +## Cost + +Hobby plan: $5 a month, which includes $5 of usage. The service idles at a +few tens of MB and next to no CPU in each environment ($10 per GB-month of +memory, $20 per vCPU-month), so both fit in the included usage. Egress is +$0.05/GB, about $0.0001 per new reader (the whole library, ~2.2 MB). diff --git a/infra/railway-cli/Dockerfile b/infra/railway-cli/Dockerfile new file mode 100644 index 0000000..f2362ac --- /dev/null +++ b/infra/railway-cli/Dockerfile @@ -0,0 +1,17 @@ +# Railway's CLI, pinned. Their own image is x86-only; the release binaries are +# static, so this just fetches the one for the platform being built. +FROM alpine:3.22 + +ARG VERSION=5.57.12 +ARG TARGETARCH + +RUN case "$TARGETARCH" in \ + arm64) triple=aarch64-unknown-linux-musl ;; \ + amd64) triple=x86_64-unknown-linux-musl ;; \ + *) echo "unsupported: $TARGETARCH" >&2; exit 1 ;; \ + esac \ + && wget -qO- "https://github.com/railwayapp/cli/releases/download/v$VERSION/railway-v$VERSION-$triple.tar.gz" \ + | tar -xz -C /usr/local/bin railway \ + && railway --version + +ENTRYPOINT ["railway"] diff --git a/infra/railway/.terraform.lock.hcl b/infra/railway/.terraform.lock.hcl new file mode 100644 index 0000000..fa914e6 --- /dev/null +++ b/infra/railway/.terraform.lock.hcl @@ -0,0 +1,41 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "5.25.0" + constraints = "~> 5.25" + hashes = [ + "h1:uOE/ctTNSIjD6mEvrKz81eLB7hVfAAa8TsMbDhJlHts=", + "zh:403d477c0e4c05a9f9886ef7e750dcc3488be7cd2210207e98b4a731bc499159", + "zh:4a2a4659c3273ddb1437a34865eac21823a117557b050786ba510ac5f34047cf", + "zh:6e4a2f6ed105b5353c7b56962d00f923873dd68af6509b9b9f595057edba1915", + "zh:aadd2051c1eb01e7b518d4808de7f5192b2ecbc841d70924785ee3385a889b9e", + "zh:ab0322bcb0d4e465f8603b39f7e743c836b22ae775c03ed677ad999a6a1b8bf4", + "zh:c47e38911c92e0b2ffe7be1d36d96b79dd2af79bc61745d6afe5a730cae9f287", + "zh:df3f13ae57104ce2f7619cc6c050ab90a5a3299470cdbd2ab8dd3809b84edcb8", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + "zh:fb7de9d22b02367036c146bd683c8968ec4bbb56aca3185fa84146d118a2bf96", + ] +} + +provider "registry.terraform.io/terraform-community-providers/railway" { + version = "0.6.2" + constraints = "~> 0.6.2" + hashes = [ + "h1:4Az1FULORPb4P5ZtZ6D/tR6PcqE1AY+hCJW+sJuXjxs=", + "zh:2165a426b2e346c2debb05517e3993f87253ddcdf145d94401ea2ab9178d35ab", + "zh:2f3f0f3423564c88ada05238f136034fc35634fc7978efc503b984ba35e83724", + "zh:3bb0618cddccca0d0c690044f7338e4cd103b45114ffbcca8769e864a1b2554b", + "zh:50fd5dc5cf749d8ecd10e5e4ee5cef04b541beb522aab6d4e1845ee812e0c088", + "zh:6a52b2c60121a695151d6a91e61c3fecabc0a40bf75d6b530d828eef0dc0619d", + "zh:7356f98cef110a5ca1a261fddde4b53c74b6a22848bdca642e2094f7708b3626", + "zh:7b47792310e502d0a0b4427133b161114dce2323bc61bf40dc6c4d67858b7629", + "zh:7e8740d239d76a869e0d8eb5dc6c1ec62459603ea6a057333006f8de1c12e2f0", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:99d7a179fe16211beb790e19a8f65d570a23ed4f6b752b5bd6d073195fc4cf9d", + "zh:9f31f26d0d93067fca796a718a9b39ebfb1fce4d24e382c215f33e0dde7b3420", + "zh:adb29c332488b29cc483f8cac00a0cc54cacd4cfe5bbd818a55a1c1b4394ebcd", + "zh:d16148e673900550273cbbb12f7dbd7f96ec0cfaa863ec93ec5b108258415637", + "zh:da40431bcd65e85bec7f301611cad5b4b772dae250e9622556072b6a3a7503b6", + ] +} diff --git a/infra/railway/main.tf b/infra/railway/main.tf new file mode 100644 index 0000000..5629028 --- /dev/null +++ b/infra/railway/main.tf @@ -0,0 +1,127 @@ +# The site on Railway: one project, with an environment each for prod and dev, +# and one service that runs the repo's Dockerfile. server.py does the routing, +# as it does locally; Railway holds the certificates. +# +# Terraform makes the project, the service and the domains, and puts their +# records on Cloudflare. It never deploys: deploy.sh uploads the site with +# Railway's CLI. State is local (terraform.tfstate here, gitignored). + +terraform { + required_version = ">= 1.10" + required_providers { + railway = { source = "terraform-community-providers/railway", version = "~> 0.6.2" } + cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.25" } + } +} + +# Reads RAILWAY_TOKEN (compose.yaml sets it from RAILWAY_API_TOKEN in .env): +# a workspace token. +provider "railway" {} + +# Reads CLOUDFLARE_API_TOKEN: DNS edit and zone read, on aerlinn.net only. +provider "cloudflare" {} + +locals { + zone = "aerlinn.net" + # Railway Metal, Virginia. + region = "us-east4-eqdc4a" +} + +resource "railway_project" "bible" { + name = "bible" + description = "Offline Bible reader" + default_environment = { + name = "production" + } +} + +resource "railway_environment" "dev" { + name = "dev" + project_id = railway_project.bible.id +} + +resource "railway_service" "site" { + name = "site" + project_id = railway_project.bible.id + regions = [{ + region = local.region + num_replicas = 1 + }] + + # Made after both environments, so it is in both. + depends_on = [railway_environment.dev] +} + +locals { + environments = { + production = { + id = railway_project.bible.default_environment.id + domain = "bible-2.${local.zone}" + } + dev = { + id = railway_environment.dev.id + domain = "bible-dev.${local.zone}" + } + } +} + +resource "railway_custom_domain" "site" { + for_each = local.environments + + domain = each.value.domain + environment_id = each.value.id + service_id = railway_service.site.id +} + +data "cloudflare_zone" "site" { + filter = { + name = local.zone + } +} + +# DNS only, not proxied: Railway issues the certificate itself and has to see +# the CNAME as it is. +resource "cloudflare_dns_record" "site" { + for_each = railway_custom_domain.site + + zone_id = data.cloudflare_zone.site.id + name = each.value.domain + type = "CNAME" + content = each.value.dns_record_value + ttl = 1 + proxied = false + comment = "terraform: railway bible ${each.key}" +} + +# Railway's proof that the domain is ours. +resource "cloudflare_dns_record" "verification" { + for_each = railway_custom_domain.site + + zone_id = data.cloudflare_zone.site.id + name = "${each.value.verification_host_label}.${each.value.zone}" + type = "TXT" + content = "\"${each.value.verification_record_value}\"" + ttl = 1 + comment = "terraform: railway bible ${each.key} verification" +} + +output "project_id" { + value = railway_project.bible.id +} + +output "service" { + value = railway_service.site.name +} + +output "domains" { + value = { + for name, d in railway_custom_domain.site : name => { + domain = d.domain + host_label = d.host_label + zone = d.zone + target = d.dns_record_value + verification_host = d.verification_host_label + verification = d.verification_record_value + } + } +} diff --git a/server.py b/server.py index f7bc16f..107f5fb 100644 --- a/server.py +++ b/server.py @@ -3,9 +3,7 @@ from __future__ import annotations import argparse -import hashlib import io -import json import os import posixpath import signal @@ -13,59 +11,26 @@ import sys from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from worker import WORKER, stamp_worker, walk + NO_CACHE_NAMES = {"sw.js"} NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"} BOOT_PAGE = "/boot.html" -WORKER = "sw.js" +_stamped = (None, None) -VERSION_LINE = "const VERSION = 'dev';" -LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';" -LIBRARY_LINE = "const LIBRARY = [];" - -def walk(roots): - found = {} - for root in roots: - for dirpath, _dirs, names in os.walk(root): - for name in sorted(names): - path = os.path.join(dirpath, name) - rel = os.path.relpath(path, root).replace(os.sep, "/") - found.setdefault(rel, path) - return found - -def digest(files): - out = hashlib.sha256() - for rel in sorted(files): - st = os.stat(files[rel]) - out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode()) - return out.hexdigest()[:12] - -def page_url(rel): - if rel == "index.html": - return "./" - return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel) - -def stamp_worker(roots): - chrome = walk(roots[:1]) - library = {rel: path for rel, path in walk(roots).items() if rel not in chrome} - - src = chrome.pop(WORKER, None) - if src is None: - return None - with open(src, encoding="utf-8") as fh: - body = fh.read() - - for line, value in ( - (VERSION_LINE, "const VERSION = '%s';" % digest(chrome)), - (LIBRARY_VERSION_LINE, "const LIBRARY_VERSION = '%s';" % digest(library)), - (LIBRARY_LINE, "const LIBRARY = %s;" - % json.dumps(sorted(page_url(rel) for rel in library), indent=2)), - ): - if line not in body: - raise RuntimeError("%s: expected to find %r to stamp" % (WORKER, line)) - body = body.replace(line, value, 1) - return body.encode("utf-8") +def cached_worker(roots): + # Stamping hashes every file the worker lists, 11 MB of them, and browsers + # ask for sw.js on almost every load. Stats are cheap, so redo the stamp + # only when one moves: often under compose, never in a baked image. + global _stamped + key = tuple((rel, st.st_size, st.st_mtime_ns) + for rel, st in ((rel, os.stat(path)) + for rel, path in sorted(walk(roots).items()))) + if _stamped[0] != key: + _stamped = (key, stamp_worker(roots)) + return _stamped[1] class Handler(SimpleHTTPRequestHandler): extensions_map = { @@ -106,7 +71,7 @@ class Handler(SimpleHTTPRequestHandler): def send_worker(self): try: - body = stamp_worker(self.roots) + body = cached_worker(self.roots) except Exception as err: self.log_message("stamp failed: %s", err) self.send_error(500, "service worker unavailable") diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..d222c2b --- /dev/null +++ b/worker.py @@ -0,0 +1,69 @@ +"""Stamping for the service worker. + +The dev server stamps sw.js on every request; the deploy stamps it once into +the uploaded copy. Both import this, because a digest computed two different +ways is a worker that thinks its caches are current when they are not. +""" + +from __future__ import annotations + +import hashlib +import json +import os + +WORKER = "sw.js" + +VERSION_LINE = "const VERSION = 'dev';" +LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';" +LIBRARY_LINE = "const LIBRARY = [];" + +CHUNK = 1 << 20 + +def walk(roots): + found = {} + for root in roots: + for dirpath, _dirs, names in os.walk(root): + for name in sorted(names): + path = os.path.join(dirpath, name) + rel = os.path.relpath(path, root).replace(os.sep, "/") + found.setdefault(rel, path) + return found + +def digest(files): + # Contents, not size and mtime: a checkout, a rebuild and a rsync all move + # mtimes without changing a byte, and each one of those would otherwise + # throw away every cache on every device for nothing. + out = hashlib.sha256() + for rel in sorted(files): + out.update(rel.encode("utf-8") + b"\0") + with open(files[rel], "rb") as fh: + for chunk in iter(lambda: fh.read(CHUNK), b""): + out.update(chunk) + out.update(b"\0") + return out.hexdigest()[:12] + +def page_url(rel): + if rel == "index.html": + return "./" + return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel) + +def stamp_worker(roots): + chrome = walk(roots[:1]) + library = {rel: path for rel, path in walk(roots).items() if rel not in chrome} + + src = chrome.pop(WORKER, None) + if src is None: + return None + with open(src, encoding="utf-8") as fh: + body = fh.read() + + for line, value in ( + (VERSION_LINE, "const VERSION = '%s';" % digest(chrome)), + (LIBRARY_VERSION_LINE, "const LIBRARY_VERSION = '%s';" % digest(library)), + (LIBRARY_LINE, "const LIBRARY = %s;" + % json.dumps(sorted(page_url(rel) for rel in library), indent=2)), + ): + if line not in body: + raise RuntimeError("%s: expected to find %r to stamp" % (WORKER, line)) + body = body.replace(line, value, 1) + return body.encode("utf-8")