This commit is contained in:
mark committed 2026-09-19 13:10:34 -04:00
1 parent ea1afaf833
commit e0db317146
12 files changed
+444 -56

No files matched your search

+7
View File
@@ -5,3 +5,10 @@ __pycache__
# The build input. The image ships the compiled bible/, not what it came from. # The build input. The image ships the compiled bible/, not what it came from.
source/ source/
# Never in the image: credentials, and what only the deploy uses.
.env
dist/
infra/
.build/
deploy.sh
+9
View File
@@ -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=
+7
View File
@@ -1,3 +1,10 @@
__pycache__ __pycache__
source/ source/
bible/ bible/
dist/
.terraform/
*.tfstate
*.tfstate.backup
.env
.build/
tfplan
+3 -2
View File
@@ -2,13 +2,14 @@
FROM python:3.13-alpine FROM python:3.13-alpine
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
SITE_ROOTS=/srv/assets:/srv/bible \ SITE_ROOTS=/srv/assets:/srv/bible \
PORT=8080 PORT=8080
WORKDIR /srv 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. # for live edits without a rebuild.
COPY server.py /srv/server.py COPY server.py worker.py /srv/
COPY assets/ /srv/assets/ COPY assets/ /srv/assets/
COPY bible/ /srv/bible/ COPY bible/ /srv/bible/
+39 -4
View File
@@ -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: services:
bible: bible:
build: . build: .
@@ -7,11 +28,25 @@ services:
# Host port is overridable: BIBLE_PORT=9000 docker compose up -d # Host port is overridable: BIBLE_PORT=9000 docker compose up -d
- "${BIBLE_PORT:-8085}:8080" - "${BIBLE_PORT:-8085}:8080"
volumes: volumes:
# Live edits without rebuilding. server.py is mounted too: leaving it # Live edits without rebuilding. server.py and worker.py are mounted
# baked into the image is how an old copy of it once kept running for # too: leaving them baked into the image is how an old copy once kept
# days after the file on disk had changed. Drop these lines to serve the # running for days after the file on disk had changed. Drop these lines
# copy baked into the image instead. # to serve the copy baked into the image instead.
- ./server.py:/srv/server.py:ro - ./server.py:/srv/server.py:ro
- ./worker.py:/srv/worker.py:ro
- ./assets:/srv/assets:ro - ./assets:/srv/assets:ro
- ./bible:/srv/bible:ro - ./bible:/srv/bible:ro
restart: unless-stopped 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
Executable
+35
View File
@@ -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")"
+75
View File
@@ -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 `<zone_id>/<id>`).
## 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).
+17
View File
@@ -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"]
+41
View File
@@ -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",
]
}
+127
View File
@@ -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
}
}
}
+15 -50
View File
@@ -3,9 +3,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import io import io
import json
import os import os
import posixpath import posixpath
import signal import signal
@@ -13,59 +11,26 @@ import sys
from functools import partial from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from worker import WORKER, stamp_worker, walk
NO_CACHE_NAMES = {"sw.js"} NO_CACHE_NAMES = {"sw.js"}
NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"} NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
BOOT_PAGE = "/boot.html" BOOT_PAGE = "/boot.html"
WORKER = "sw.js" _stamped = (None, None)
VERSION_LINE = "const VERSION = 'dev';" def cached_worker(roots):
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';" # Stamping hashes every file the worker lists, 11 MB of them, and browsers
LIBRARY_LINE = "const LIBRARY = [];" # 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.
def walk(roots): global _stamped
found = {} key = tuple((rel, st.st_size, st.st_mtime_ns)
for root in roots: for rel, st in ((rel, os.stat(path))
for dirpath, _dirs, names in os.walk(root): for rel, path in sorted(walk(roots).items())))
for name in sorted(names): if _stamped[0] != key:
path = os.path.join(dirpath, name) _stamped = (key, stamp_worker(roots))
rel = os.path.relpath(path, root).replace(os.sep, "/") return _stamped[1]
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")
class Handler(SimpleHTTPRequestHandler): class Handler(SimpleHTTPRequestHandler):
extensions_map = { extensions_map = {
@@ -106,7 +71,7 @@ class Handler(SimpleHTTPRequestHandler):
def send_worker(self): def send_worker(self):
try: try:
body = stamp_worker(self.roots) body = cached_worker(self.roots)
except Exception as err: except Exception as err:
self.log_message("stamp failed: %s", err) self.log_message("stamp failed: %s", err)
self.send_error(500, "service worker unavailable") self.send_error(500, "service worker unavailable")
+69
View File
@@ -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")