nandi/gleanpublic Fork
⑂ 3e88e98
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

Deploy the reader to Modal

One container serving both the API and the Flutter web build, so there is
nothing to proxy and no second origin needing CORS. The client is built with
an empty base URL and talks to relative paths.

The web build is committed output rather than built in the image: installing
Flutter there would add gigabytes and minutes to reproduce what the
developer machine already made. The cost is that it has to be rebuilt before
deploying, which the readme says.

GLEAN_TOKEN is required rather than optional. Without it the reader binds
loopback, Modal's proxy reaches nothing, and the result looks like a broken
deploy instead of an open one -- so the function raises with the reason.
/feeds and /fetch-content retrieve any URL handed to them, which makes an
ungated instance an SSRF proxy as much as someone else's feed list.

Storage reuses the snapshot scheme the old deployment needed: WAL wants a
filesystem a Modal Volume is not, so the live databases are on local disk
and the Volume holds VACUUM INTO copies taken every five minutes and on
shutdown. That bounds durability rather than guaranteeing it, pins the app
to one container, and rules out zero-downtime redeploys. The readme states
all three.

Verified live: unauthenticated requests are refused, the app shell and
assets serve with correct types, two real feeds ingest, FTS5 search works,
and the client loads in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandithebull committed 2026-09-21T20:43:06-07:00 Browse files
3e88e98 parent: 00a8370
added deploy/modal_app.py +160 -0
new file mode 100644
@@ -0,0 +1,160 @@
1+"""Deploy the reader to Modal: the Nim binary, serving the Flutter web build.
2+
3+One container, one port. The Nim server handles both the API and the client's
4+static files, so there is nothing to proxy and no second origin to arrange
5+CORS for.
6+
7+Storage is the awkward part, as it always is on a serverless host. SQLite
8+runs in WAL mode, which wants a real POSIX filesystem with working shared
9+memory -- a Modal Volume is neither. So the live databases sit on the
10+container's local disk and the Volume is a snapshot store: restored on start,
11+refreshed periodically and on shutdown with `VACUUM INTO`, which copies a
12+live database consistently without stopping writers.
13+
14+That makes this single-container and single-writer, with a data-loss window
15+bounded by SNAPSHOT_INTERVAL. For a personal reader whose contents can be
16+refetched from the feeds, that is a fair trade.
17+"""
18+
19+import os
20+import pathlib
21+import shutil
22+import signal
23+import subprocess
24+import sys
25+import threading
26+import time
27+
28+import modal
29+
30+REPO = pathlib.Path(__file__).parent.parent
31+
32+APP_NAME = "glean-reader"
33+PORT = 8080
34+
35+VOLUME_PATH = "/data"
36+SNAPSHOT_DIR = f"{VOLUME_PATH}/db"
37+LIVE_DIR = "/livedb"
38+DB_BASE = f"{LIVE_DIR}/glean"
39+
40+# The reader opens <base>_users and attaches <base>_articles.
41+DB_SUFFIXES = ("_users", "_articles")
42+
43+# Worst case a crash costs, if the container dies without its exit handler.
44+SNAPSHOT_INTERVAL = 300
45+
46+image = (
47+ modal.Image.debian_slim(python_version="3.12")
48+ .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates")
49+ .run_commands(
50+ # choosenim is the supported installer and pins a known version;
51+ # Debian's Nim is far behind what this code needs.
52+ "curl -sSf https://nim-lang.org/choosenim/init.sh | sh -s -- -y",
53+ )
54+ .env({"PATH": "/root/.nimble/bin:/usr/local/bin:/usr/bin:/bin"})
55+ .add_local_dir(
56+ REPO / "spike" / "nim",
57+ remote_path="/src",
58+ copy=True,
59+ ignore=["nimcache", "glean", "*_probe", "*.db*"],
60+ )
61+ # The Flutter web build is committed output rather than built here:
62+ # installing Flutter in the image would add gigabytes and minutes for
63+ # something the developer machine has already produced.
64+ .add_local_dir(REPO / "app" / "build" / "web", remote_path="/web", copy=True)
65+ .run_commands("cd /src && nim c -d:release -d:ssl --hints:off -o:/usr/local/bin/glean glean.nim")
66+)
67+
68+volume = modal.Volume.from_name("glean-reader-data", create_if_missing=True)
69+
70+# GLEAN_TOKEN. Without it the reader binds loopback and Modal's proxy reaches
71+# nothing, so this is required rather than optional:
72+# modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)"
73+secret = modal.Secret.from_name("glean-reader")
74+
75+app = modal.App(APP_NAME)
76+
77+
78+def _snapshot_once() -> None:
79+ """Copy each live database to the Volume, via a local staging file.
80+
81+ VACUUM INTO journals as it writes, so it is pointed at local disk rather
82+ than the Volume -- the filesystem this whole scheme exists to keep SQLite
83+ off. Only the finished file is copied across.
84+ """
85+ os.makedirs(SNAPSHOT_DIR, exist_ok=True)
86+ for suffix in DB_SUFFIXES:
87+ live = f"{DB_BASE}{suffix}"
88+ if not os.path.exists(live):
89+ continue
90+ staged = f"{LIVE_DIR}/snapshot{suffix}"
91+ if os.path.exists(staged):
92+ os.remove(staged)
93+ subprocess.run(
94+ ["sqlite3", live, f"VACUUM INTO '{staged}'"], check=True, capture_output=True
95+ )
96+ shutil.copy2(staged, f"{SNAPSHOT_DIR}/glean{suffix}")
97+ os.remove(staged)
98+ volume.commit()
99+
100+
101+def _restore() -> None:
102+ os.makedirs(LIVE_DIR, exist_ok=True)
103+ for suffix in DB_SUFFIXES:
104+ snap = f"{SNAPSHOT_DIR}/glean{suffix}"
105+ if os.path.exists(snap):
106+ shutil.copy2(snap, f"{DB_BASE}{suffix}")
107+ print(f"[glean] restored {snap}", flush=True)
108+
109+
110+def _snapshot_loop(stop: threading.Event) -> None:
111+ while not stop.wait(SNAPSHOT_INTERVAL):
112+ try:
113+ _snapshot_once()
114+ except Exception as exc: # a failed snapshot must not kill the server
115+ print(f"[glean] snapshot failed: {exc}", file=sys.stderr, flush=True)
116+
117+
118+@app.function(
119+ image=image,
120+ volumes={VOLUME_PATH: volume},
121+ secrets=[secret],
122+ # SQLite tolerates one writer, and the snapshot scheme assumes one
123+ # container owns the data. This is correctness, not tuning.
124+ max_containers=1,
125+ scaledown_window=900,
126+ timeout=24 * 60 * 60,
127+)
128+@modal.concurrent(max_inputs=50)
129+@modal.web_server(PORT, startup_timeout=120)
130+def serve() -> None:
131+ _restore()
132+
133+ env = dict(os.environ)
134+ env["GLEAN_DB"] = DB_BASE
135+ env["GLEAN_PORT"] = str(PORT)
136+ env["GLEAN_WEB"] = "/web"
137+ if not env.get("GLEAN_TOKEN"):
138+ # The reader would bind loopback and Modal's proxy would reach
139+ # nothing, which looks like a broken deploy rather than a missing
140+ # secret. Fail with the reason instead.
141+ raise RuntimeError("GLEAN_TOKEN is required: the reader has no other auth")
142+
143+ proc = subprocess.Popen(["/usr/local/bin/glean"], env=env)
144+
145+ stop = threading.Event()
146+ threading.Thread(target=_snapshot_loop, args=(stop,), daemon=True).start()
147+
148+ def shutdown(_signum, _frame):
149+ stop.set()
150+ proc.terminate()
151+ try:
152+ time.sleep(1)
153+ _snapshot_once()
154+ print("[glean] final snapshot written", flush=True)
155+ except Exception as exc:
156+ print(f"[glean] final snapshot failed: {exc}", file=sys.stderr, flush=True)
157+ sys.exit(0)
158+
159+ signal.signal(signal.SIGTERM, shutdown)
160+ signal.signal(signal.SIGINT, shutdown)
new file mode 100644
@@ -0,0 +1,160 @@
1+"""Deploy the reader to Modal: the Nim binary, serving the Flutter web build.
2+
3+One container, one port. The Nim server handles both the API and the client's
4+static files, so there is nothing to proxy and no second origin to arrange
5+CORS for.
6+
7+Storage is the awkward part, as it always is on a serverless host. SQLite
8+runs in WAL mode, which wants a real POSIX filesystem with working shared
9+memory -- a Modal Volume is neither. So the live databases sit on the
10+container's local disk and the Volume is a snapshot store: restored on start,
11+refreshed periodically and on shutdown with `VACUUM INTO`, which copies a
12+live database consistently without stopping writers.
13+
14+That makes this single-container and single-writer, with a data-loss window
15+bounded by SNAPSHOT_INTERVAL. For a personal reader whose contents can be
16+refetched from the feeds, that is a fair trade.
17+"""
18+
19+import os
20+import pathlib
21+import shutil
22+import signal
23+import subprocess
24+import sys
25+import threading
26+import time
27+
28+import modal
29+
30+REPO = pathlib.Path(__file__).parent.parent
31+
32+APP_NAME = "glean-reader"
33+PORT = 8080
34+
35+VOLUME_PATH = "/data"
36+SNAPSHOT_DIR = f"{VOLUME_PATH}/db"
37+LIVE_DIR = "/livedb"
38+DB_BASE = f"{LIVE_DIR}/glean"
39+
40+# The reader opens <base>_users and attaches <base>_articles.
41+DB_SUFFIXES = ("_users", "_articles")
42+
43+# Worst case a crash costs, if the container dies without its exit handler.
44+SNAPSHOT_INTERVAL = 300
45+
46+image = (
47+ modal.Image.debian_slim(python_version="3.12")
48+ .apt_install("curl", "xz-utils", "gcc", "libsqlite3-dev", "ca-certificates")
49+ .run_commands(
50+ # choosenim is the supported installer and pins a known version;
51+ # Debian's Nim is far behind what this code needs.
52+ "curl -sSf https://nim-lang.org/choosenim/init.sh | sh -s -- -y",
53+ )
54+ .env({"PATH": "/root/.nimble/bin:/usr/local/bin:/usr/bin:/bin"})
55+ .add_local_dir(
56+ REPO / "spike" / "nim",
57+ remote_path="/src",
58+ copy=True,
59+ ignore=["nimcache", "glean", "*_probe", "*.db*"],
60+ )
61+ # The Flutter web build is committed output rather than built here:
62+ # installing Flutter in the image would add gigabytes and minutes for
63+ # something the developer machine has already produced.
64+ .add_local_dir(REPO / "app" / "build" / "web", remote_path="/web", copy=True)
65+ .run_commands("cd /src && nim c -d:release -d:ssl --hints:off -o:/usr/local/bin/glean glean.nim")
66+)
67+
68+volume = modal.Volume.from_name("glean-reader-data", create_if_missing=True)
69+
70+# GLEAN_TOKEN. Without it the reader binds loopback and Modal's proxy reaches
71+# nothing, so this is required rather than optional:
72+# modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)"
73+secret = modal.Secret.from_name("glean-reader")
74+
75+app = modal.App(APP_NAME)
76+
77+
78+def _snapshot_once() -> None:
79+ """Copy each live database to the Volume, via a local staging file.
80+
81+ VACUUM INTO journals as it writes, so it is pointed at local disk rather
82+ than the Volume -- the filesystem this whole scheme exists to keep SQLite
83+ off. Only the finished file is copied across.
84+ """
85+ os.makedirs(SNAPSHOT_DIR, exist_ok=True)
86+ for suffix in DB_SUFFIXES:
87+ live = f"{DB_BASE}{suffix}"
88+ if not os.path.exists(live):
89+ continue
90+ staged = f"{LIVE_DIR}/snapshot{suffix}"
91+ if os.path.exists(staged):
92+ os.remove(staged)
93+ subprocess.run(
94+ ["sqlite3", live, f"VACUUM INTO '{staged}'"], check=True, capture_output=True
95+ )
96+ shutil.copy2(staged, f"{SNAPSHOT_DIR}/glean{suffix}")
97+ os.remove(staged)
98+ volume.commit()
99+
100+
101+def _restore() -> None:
102+ os.makedirs(LIVE_DIR, exist_ok=True)
103+ for suffix in DB_SUFFIXES:
104+ snap = f"{SNAPSHOT_DIR}/glean{suffix}"
105+ if os.path.exists(snap):
106+ shutil.copy2(snap, f"{DB_BASE}{suffix}")
107+ print(f"[glean] restored {snap}", flush=True)
108+
109+
110+def _snapshot_loop(stop: threading.Event) -> None:
111+ while not stop.wait(SNAPSHOT_INTERVAL):
112+ try:
113+ _snapshot_once()
114+ except Exception as exc: # a failed snapshot must not kill the server
115+ print(f"[glean] snapshot failed: {exc}", file=sys.stderr, flush=True)
116+
117+
118+@app.function(
119+ image=image,
120+ volumes={VOLUME_PATH: volume},
121+ secrets=[secret],
122+ # SQLite tolerates one writer, and the snapshot scheme assumes one
123+ # container owns the data. This is correctness, not tuning.
124+ max_containers=1,
125+ scaledown_window=900,
126+ timeout=24 * 60 * 60,
127+)
128+@modal.concurrent(max_inputs=50)
129+@modal.web_server(PORT, startup_timeout=120)
130+def serve() -> None:
131+ _restore()
132+
133+ env = dict(os.environ)
134+ env["GLEAN_DB"] = DB_BASE
135+ env["GLEAN_PORT"] = str(PORT)
136+ env["GLEAN_WEB"] = "/web"
137+ if not env.get("GLEAN_TOKEN"):
138+ # The reader would bind loopback and Modal's proxy would reach
139+ # nothing, which looks like a broken deploy rather than a missing
140+ # secret. Fail with the reason instead.
141+ raise RuntimeError("GLEAN_TOKEN is required: the reader has no other auth")
142+
143+ proc = subprocess.Popen(["/usr/local/bin/glean"], env=env)
144+
145+ stop = threading.Event()
146+ threading.Thread(target=_snapshot_loop, args=(stop,), daemon=True).start()
147+
148+ def shutdown(_signum, _frame):
149+ stop.set()
150+ proc.terminate()
151+ try:
152+ time.sleep(1)
153+ _snapshot_once()
154+ print("[glean] final snapshot written", flush=True)
155+ except Exception as exc:
156+ print(f"[glean] final snapshot failed: {exc}", file=sys.stderr, flush=True)
157+ sys.exit(0)
158+
159+ signal.signal(signal.SIGTERM, shutdown)
160+ signal.signal(signal.SIGINT, shutdown)
added deploy/readme.md +52 -0
new file mode 100644
@@ -0,0 +1,52 @@
1+# Deploying
2+
3+```
4+modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)"
5+cd app && flutter build web --release --dart-define=GLEAN_BASE_URL=
6+modal deploy deploy/modal_app.py
7+```
8+
9+Then open `https://<your-app>.modal.run/?key=<token>` once. That sets a
10+cookie and redirects, so the token stays out of the URL bar, the browser's
11+history, and any `Referer` the page sends.
12+
13+## One container, one port
14+
15+The Nim binary serves the API *and* the Flutter web build, so there is
16+nothing to proxy and no second origin to arrange CORS for. The client is
17+built with an empty base URL and uses relative paths.
18+
19+The web build is committed output rather than built in the image. Installing
20+Flutter there would add gigabytes and minutes to produce something the
21+developer machine has already made — so **rebuild it before deploying** or
22+the deployment ships the previous client.
23+
24+## The token is not optional here
25+
26+Without `GLEAN_TOKEN` the reader binds loopback, Modal's proxy reaches
27+nothing, and the deploy looks broken rather than insecure. The function
28+raises with the reason instead of starting.
29+
30+That is deliberate: `/feeds` and `/fetch-content` retrieve any URL handed to
31+them, so an open instance is an SSRF proxy as much as it is someone else's
32+feed list.
33+
34+## Storage, and what it costs
35+
36+SQLite runs in WAL mode, which wants a real POSIX filesystem with working
37+shared memory. A Modal Volume is neither. So the live databases sit on the
38+container's local disk and the Volume is a snapshot store: restored on start,
39+refreshed every five minutes and on shutdown with `VACUUM INTO`, which copies
40+a live database consistently without stopping writers.
41+
42+The consequences, plainly:
43+
44+- **Up to five minutes of reads can be lost** if a container dies without
45+ running its exit handler — an OOM, a preemption, a hard kill.
46+- **`max_containers=1` is correctness, not tuning.** Two containers would be
47+ two SQLite writers over two divergent copies, and the Volume's
48+ last-write-wins would quietly discard one.
49+- **Redeploys are not zero-downtime**, for the same reason.
50+
51+For a personal reader whose contents can be refetched from the feeds, that is
52+a fair trade. For anything where the data is the point, it is not.
new file mode 100644
@@ -0,0 +1,52 @@
1+# Deploying
2+
3+```
4+modal secret create glean-reader GLEAN_TOKEN="$(openssl rand -hex 24)"
5+cd app && flutter build web --release --dart-define=GLEAN_BASE_URL=
6+modal deploy deploy/modal_app.py
7+```
8+
9+Then open `https://<your-app>.modal.run/?key=<token>` once. That sets a
10+cookie and redirects, so the token stays out of the URL bar, the browser's
11+history, and any `Referer` the page sends.
12+
13+## One container, one port
14+
15+The Nim binary serves the API *and* the Flutter web build, so there is
16+nothing to proxy and no second origin to arrange CORS for. The client is
17+built with an empty base URL and uses relative paths.
18+
19+The web build is committed output rather than built in the image. Installing
20+Flutter there would add gigabytes and minutes to produce something the
21+developer machine has already made — so **rebuild it before deploying** or
22+the deployment ships the previous client.
23+
24+## The token is not optional here
25+
26+Without `GLEAN_TOKEN` the reader binds loopback, Modal's proxy reaches
27+nothing, and the deploy looks broken rather than insecure. The function
28+raises with the reason instead of starting.
29+
30+That is deliberate: `/feeds` and `/fetch-content` retrieve any URL handed to
31+them, so an open instance is an SSRF proxy as much as it is someone else's
32+feed list.
33+
34+## Storage, and what it costs
35+
36+SQLite runs in WAL mode, which wants a real POSIX filesystem with working
37+shared memory. A Modal Volume is neither. So the live databases sit on the
38+container's local disk and the Volume is a snapshot store: restored on start,
39+refreshed every five minutes and on shutdown with `VACUUM INTO`, which copies
40+a live database consistently without stopping writers.
41+
42+The consequences, plainly:
43+
44+- **Up to five minutes of reads can be lost** if a container dies without
45+ running its exit handler — an OOM, a preemption, a hard kill.
46+- **`max_containers=1` is correctness, not tuning.** Two containers would be
47+ two SQLite writers over two divergent copies, and the Volume's
48+ last-write-wins would quietly discard one.
49+- **Redeploys are not zero-downtime**, for the same reason.
50+
51+For a personal reader whose contents can be refetched from the feeds, that is
52+a fair trade. For anything where the data is the point, it is not.