nandi/atop-analyzepublic Fork 0
78ee9e9
Commits
Clone
git clone https://git.rickub.com/nandi/atop-analyze.git
git clone ssh://git@rickub.com/nandi/atop-analyze.git

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

Improve Modal startup reliability and ClickHouse request resilience

nandithebull committed 2026-09-21T20:26:20-07:00 Browse files
78ee9e9 parent: 5f7ef9d
modified atop_analyze.py +32 -9
@@ -23,11 +23,13 @@ from __future__ import annotations
2323 import argparse
2424 import base64
2525 import json
26+import logging
2627 import os
2728 import re
2829 import shutil
2930 import subprocess
3031 import sys
32+import time
3133 import urllib.error
3234 import urllib.parse
3335 import urllib.request
@@ -39,7 +41,11 @@ DEFAULT_LOGDIR = "/var/log/atop"
3941 CLAUDE_BIN = "claude"
4042 CLAUDE_MODEL = "opus"
4143 CLICKHOUSE_URL_ENV = "CLICKHOUSE_URL"
44+CLICKHOUSE_REQUEST_TIMEOUT = 120
45+CLICKHOUSE_COLD_START_RETRIES = 2
4246 IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
47+logger = logging.getLogger(__name__)
48+logger.setLevel(logging.INFO)
4349
4450 WANTED_LABELS = {"CPU", "CPL", "PRC"}
4551 DATE_RE = re.compile(r"^\d{4}/\d{2}/\d{2}$")
@@ -226,15 +232,32 @@ def clickhouse_request(
226232 if modal_key is not None:
227233 request.add_header("Modal-Key", modal_key)
228234 request.add_header("Modal-Secret", modal_secret or "")
229- try:
230- with urllib.request.urlopen(request, timeout=30) as response:
231- if response.status >= 300:
232- raise AtopError(f"ClickHouse returned HTTP {response.status}")
233- except urllib.error.HTTPError as e:
234- detail = e.read().decode(errors="replace").strip()
235- raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e
236- except urllib.error.URLError as e:
237- raise AtopError(f"could not reach ClickHouse at {url}: {e.reason}") from e
235+ for attempt in range(CLICKHOUSE_COLD_START_RETRIES + 1):
236+ try:
237+ logger.info(
238+ "clickhouse_request operation=%s attempt=%d body_bytes=%d",
239+ query.split(None, 1)[0], attempt + 1, len(body.encode()),
240+ )
241+ # A scale-to-zero Modal Web Function can take longer than a normal
242+ # HTTP request to boot ClickHouse and attach its Volume.
243+ with urllib.request.urlopen(request, timeout=CLICKHOUSE_REQUEST_TIMEOUT) as response:
244+ if response.status >= 300:
245+ raise AtopError(f"ClickHouse returned HTTP {response.status}")
246+ logger.info("clickhouse_request_complete operation=%s", query.split(None, 1)[0])
247+ return
248+ except urllib.error.HTTPError as e:
249+ detail = e.read().decode(errors="replace").strip()
250+ raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e
251+ except (TimeoutError, urllib.error.URLError) as e:
252+ if attempt == CLICKHOUSE_COLD_START_RETRIES:
253+ reason = getattr(e, "reason", str(e))
254+ raise AtopError(f"could not reach ClickHouse at {url}: {reason}") from e
255+ delay = 5 * (attempt + 1)
256+ logger.warning(
257+ "clickhouse_request_retry operation=%s delay_seconds=%d reason=%s",
258+ query.split(None, 1)[0], delay, getattr(e, "reason", str(e)),
259+ )
260+ time.sleep(delay)
238261
239262
240263 def write_clickhouse(
@@ -23,11 +23,13 @@ from __future__ import annotations
23 import argparse23 import argparse
24 import base6424 import base64
25 import json25 import json
26+import logging
26 import os27 import os
27 import re28 import re
28 import shutil29 import shutil
29 import subprocess30 import subprocess
30 import sys31 import sys
32+import time
31 import urllib.error33 import urllib.error
32 import urllib.parse34 import urllib.parse
33 import urllib.request35 import urllib.request
@@ -39,7 +41,11 @@ DEFAULT_LOGDIR = "/var/log/atop"
39 CLAUDE_BIN = "claude"41 CLAUDE_BIN = "claude"
40 CLAUDE_MODEL = "opus"42 CLAUDE_MODEL = "opus"
41 CLICKHOUSE_URL_ENV = "CLICKHOUSE_URL"43 CLICKHOUSE_URL_ENV = "CLICKHOUSE_URL"
44+CLICKHOUSE_REQUEST_TIMEOUT = 120
45+CLICKHOUSE_COLD_START_RETRIES = 2
42 IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")46 IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
47+logger = logging.getLogger(__name__)
48+logger.setLevel(logging.INFO)
43 49
44 WANTED_LABELS = {"CPU", "CPL", "PRC"}50 WANTED_LABELS = {"CPU", "CPL", "PRC"}
45 DATE_RE = re.compile(r"^\d{4}/\d{2}/\d{2}$")51 DATE_RE = re.compile(r"^\d{4}/\d{2}/\d{2}$")
@@ -226,15 +232,32 @@ def clickhouse_request(
226 if modal_key is not None:232 if modal_key is not None:
227 request.add_header("Modal-Key", modal_key)233 request.add_header("Modal-Key", modal_key)
228 request.add_header("Modal-Secret", modal_secret or "")234 request.add_header("Modal-Secret", modal_secret or "")
229- try:235+ for attempt in range(CLICKHOUSE_COLD_START_RETRIES + 1):
230- with urllib.request.urlopen(request, timeout=30) as response:236+ try:
231- if response.status >= 300:237+ logger.info(
232- raise AtopError(f"ClickHouse returned HTTP {response.status}")238+ "clickhouse_request operation=%s attempt=%d body_bytes=%d",
233- except urllib.error.HTTPError as e:239+ query.split(None, 1)[0], attempt + 1, len(body.encode()),
234- detail = e.read().decode(errors="replace").strip()240+ )
235- raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e241+ # A scale-to-zero Modal Web Function can take longer than a normal
236- except urllib.error.URLError as e:242+ # HTTP request to boot ClickHouse and attach its Volume.
237- raise AtopError(f"could not reach ClickHouse at {url}: {e.reason}") from e243+ with urllib.request.urlopen(request, timeout=CLICKHOUSE_REQUEST_TIMEOUT) as response:
244+ if response.status >= 300:
245+ raise AtopError(f"ClickHouse returned HTTP {response.status}")
246+ logger.info("clickhouse_request_complete operation=%s", query.split(None, 1)[0])
247+ return
248+ except urllib.error.HTTPError as e:
249+ detail = e.read().decode(errors="replace").strip()
250+ raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e
251+ except (TimeoutError, urllib.error.URLError) as e:
252+ if attempt == CLICKHOUSE_COLD_START_RETRIES:
253+ reason = getattr(e, "reason", str(e))
254+ raise AtopError(f"could not reach ClickHouse at {url}: {reason}") from e
255+ delay = 5 * (attempt + 1)
256+ logger.warning(
257+ "clickhouse_request_retry operation=%s delay_seconds=%d reason=%s",
258+ query.split(None, 1)[0], delay, getattr(e, "reason", str(e)),
259+ )
260+ time.sleep(delay)
238 261
239 262
240 def write_clickhouse(263 def write_clickhouse(
modified modal_atop_analyze.py +25 -2
@@ -12,24 +12,46 @@ write run in Modal; the ClickHouse endpoint and Proxy Token live in the
1212 from __future__ import annotations
1313
1414 import os
15+import logging
1516
1617 import modal
1718
1819 from atop_analyze import AtopError, log_path, parse, run_atop, write_clickhouse
1920
21+# Modal captures container stderr. Configure it explicitly because the runtime
22+# does not install an INFO-level handler for application loggers by default.
23+logging.basicConfig(
24+ level=logging.INFO,
25+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
26+ force=True,
27+)
2028 app = modal.App("atop-analyze")
29+logger = logging.getLogger(__name__)
30+logger.setLevel(logging.INFO)
31+# ``modal run`` mounts this entrypoint automatically, but imports are not
32+# automatically included in the remote container. Bake the shared parser into
33+# the function image so the remote ingest function can import it.
34+image = modal.Image.debian_slim(python_version="3.12").add_local_file(
35+ "atop_analyze.py", "/root/atop_analyze.py", copy=True
36+)
2137 clickhouse_secret = modal.Secret.from_name(
2238 "atop-clickhouse-proxy",
2339 required_keys=["CLICKHOUSE_URL", "MODAL_KEY", "MODAL_SECRET"],
2440 )
2541
2642
27-@app.function(secrets=[clickhouse_secret], timeout=15 * 60)
43+@app.function(image=image, secrets=[clickhouse_secret], timeout=15 * 60)
2844 def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]:
2945 """Parse an atop export and persist its samples through private ClickHouse."""
46+ logger.info("ingest_started raw_bytes=%d initialize=%s", len(raw_atop.encode()), initialize)
3047 samples = parse(raw_atop)
3148 if not samples:
3249 raise RuntimeError("no usable atop samples in the supplied log range")
50+ process_rows = sum(len(sample.procs) for sample in samples)
51+ logger.info(
52+ "atop_parsed samples=%d process_rows=%d first_epoch=%d last_epoch=%d",
53+ len(samples), process_rows, samples[0].epoch, samples[-1].epoch,
54+ )
3355
3456 write_clickhouse(
3557 samples,
@@ -43,9 +65,10 @@ def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]:
4365 os.environ["MODAL_SECRET"],
4466 initialize,
4567 )
68+ logger.info("clickhouse_write_complete samples=%d process_rows=%d", len(samples), process_rows)
4669 return {
4770 "samples": len(samples),
48- "process_rows": sum(len(sample.procs) for sample in samples),
71+ "process_rows": process_rows,
4972 }
5073
5174
@@ -12,24 +12,46 @@ write run in Modal; the ClickHouse endpoint and Proxy Token live in the
12 from __future__ import annotations12 from __future__ import annotations
13 13
14 import os14 import os
15+import logging
15 16
16 import modal17 import modal
17 18
18 from atop_analyze import AtopError, log_path, parse, run_atop, write_clickhouse19 from atop_analyze import AtopError, log_path, parse, run_atop, write_clickhouse
19 20
21+# Modal captures container stderr. Configure it explicitly because the runtime
22+# does not install an INFO-level handler for application loggers by default.
23+logging.basicConfig(
24+ level=logging.INFO,
25+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
26+ force=True,
27+)
20 app = modal.App("atop-analyze")28 app = modal.App("atop-analyze")
29+logger = logging.getLogger(__name__)
30+logger.setLevel(logging.INFO)
31+# ``modal run`` mounts this entrypoint automatically, but imports are not
32+# automatically included in the remote container. Bake the shared parser into
33+# the function image so the remote ingest function can import it.
34+image = modal.Image.debian_slim(python_version="3.12").add_local_file(
35+ "atop_analyze.py", "/root/atop_analyze.py", copy=True
36+)
21 clickhouse_secret = modal.Secret.from_name(37 clickhouse_secret = modal.Secret.from_name(
22 "atop-clickhouse-proxy",38 "atop-clickhouse-proxy",
23 required_keys=["CLICKHOUSE_URL", "MODAL_KEY", "MODAL_SECRET"],39 required_keys=["CLICKHOUSE_URL", "MODAL_KEY", "MODAL_SECRET"],
24 )40 )
25 41
26 42
27-@app.function(secrets=[clickhouse_secret], timeout=15 * 60)43+@app.function(image=image, secrets=[clickhouse_secret], timeout=15 * 60)
28 def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]:44 def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]:
29 """Parse an atop export and persist its samples through private ClickHouse."""45 """Parse an atop export and persist its samples through private ClickHouse."""
46+ logger.info("ingest_started raw_bytes=%d initialize=%s", len(raw_atop.encode()), initialize)
30 samples = parse(raw_atop)47 samples = parse(raw_atop)
31 if not samples:48 if not samples:
32 raise RuntimeError("no usable atop samples in the supplied log range")49 raise RuntimeError("no usable atop samples in the supplied log range")
50+ process_rows = sum(len(sample.procs) for sample in samples)
51+ logger.info(
52+ "atop_parsed samples=%d process_rows=%d first_epoch=%d last_epoch=%d",
53+ len(samples), process_rows, samples[0].epoch, samples[-1].epoch,
54+ )
33 55
34 write_clickhouse(56 write_clickhouse(
35 samples,57 samples,
@@ -43,9 +65,10 @@ def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]:
43 os.environ["MODAL_SECRET"],65 os.environ["MODAL_SECRET"],
44 initialize,66 initialize,
45 )67 )
68+ logger.info("clickhouse_write_complete samples=%d process_rows=%d", len(samples), process_rows)
46 return {69 return {
47 "samples": len(samples),70 "samples": len(samples),
48- "process_rows": sum(len(sample.procs) for sample in samples),71+ "process_rows": process_rows,
49 }72 }
50 73
51 74
modified modal_grafana.py +20 -15
@@ -7,6 +7,7 @@ with ``modal deploy modal_grafana.py``.
77 """
88
99 import os
10+import socket
1011 import subprocess
1112 import time
1213
@@ -14,9 +15,6 @@ import modal
1415
1516 APP_NAME = "atop-grafana"
1617 IDLE_SECONDS = 300
17-GRAFANA_UID = 472
18-GRAFANA_GID = 0
19-
2018 app = modal.App(APP_NAME)
2119 grafana_secret = modal.Secret.from_name(
2220 "atop-grafana",
@@ -41,6 +39,9 @@ image = (
4139 scaledown_window=IDLE_SECONDS,
4240 timeout=15 * 60,
4341 env={
42+ # Modal's web-server proxy connects over IPv4; Grafana otherwise binds
43+ # to [::], which can leave the public endpoint waiting indefinitely.
44+ "GF_SERVER_HTTP_ADDR": "0.0.0.0",
4445 "GF_AUTH_DISABLE_LOGIN_FORM": "true",
4546 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true",
4647 "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID",
@@ -52,21 +53,25 @@ image = (
5253 "GF_AUTH_GENERIC_OAUTH_VALIDATE_ID_TOKEN": "true",
5354 },
5455 )
56+@modal.concurrent(max_inputs=100)
5557 @modal.web_server(3000, startup_timeout=120)
5658 def grafana() -> None:
5759 """Run a public Grafana login page; its data-source credentials stay server-side."""
58- def drop_privileges() -> None:
59- # Modal ignores the Docker image's USER instruction for Functions.
60- # The upstream Grafana image uses unprivileged UID 472 and group 0.
61- os.setgid(GRAFANA_GID)
62- os.setuid(GRAFANA_UID)
63-
6460 process = subprocess.Popen(
6561 ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"],
66- preexec_fn=drop_privileges,
62+ # Pass this through the spawned process as well as Modal's function
63+ # environment, since the web-server proxy reaches the service over IPv4.
64+ env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"},
6765 )
68- # modal.web_server waits for port 3000. Catch an immediate startup failure
69- # early so its cause appears in Modal logs rather than as a proxy timeout.
70- time.sleep(1)
71- if process.poll() is not None:
72- raise RuntimeError(f"Grafana exited during startup ({process.returncode})")
66+ # Grafana takes a few seconds to bind. Do not return from the Modal web
67+ # function until its port is accepting connections; otherwise the proxy can
68+ # route the first request before a backend exists and leave it hanging.
69+ for _ in range(60):
70+ if process.poll() is not None:
71+ raise RuntimeError(f"Grafana exited during startup ({process.returncode})")
72+ with socket.socket() as probe:
73+ probe.settimeout(0.25)
74+ if probe.connect_ex(("127.0.0.1", 3000)) == 0:
75+ return
76+ time.sleep(0.5)
77+ raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds")
@@ -7,6 +7,7 @@ with ``modal deploy modal_grafana.py``.
7 """7 """
8 8
9 import os9 import os
10+import socket
10 import subprocess11 import subprocess
11 import time12 import time
12 13
@@ -14,9 +15,6 @@ import modal
14 15
15 APP_NAME = "atop-grafana"16 APP_NAME = "atop-grafana"
16 IDLE_SECONDS = 30017 IDLE_SECONDS = 300
17-GRAFANA_UID = 472
18-GRAFANA_GID = 0
19-
20 app = modal.App(APP_NAME)18 app = modal.App(APP_NAME)
21 grafana_secret = modal.Secret.from_name(19 grafana_secret = modal.Secret.from_name(
22 "atop-grafana",20 "atop-grafana",
@@ -41,6 +39,9 @@ image = (
41 scaledown_window=IDLE_SECONDS,39 scaledown_window=IDLE_SECONDS,
42 timeout=15 * 60,40 timeout=15 * 60,
43 env={41 env={
42+ # Modal's web-server proxy connects over IPv4; Grafana otherwise binds
43+ # to [::], which can leave the public endpoint waiting indefinitely.
44+ "GF_SERVER_HTTP_ADDR": "0.0.0.0",
44 "GF_AUTH_DISABLE_LOGIN_FORM": "true",45 "GF_AUTH_DISABLE_LOGIN_FORM": "true",
45 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true",46 "GF_AUTH_GENERIC_OAUTH_ENABLED": "true",
46 "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID",47 "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID",
@@ -52,21 +53,25 @@ image = (
52 "GF_AUTH_GENERIC_OAUTH_VALIDATE_ID_TOKEN": "true",53 "GF_AUTH_GENERIC_OAUTH_VALIDATE_ID_TOKEN": "true",
53 },54 },
54 )55 )
56+@modal.concurrent(max_inputs=100)
55 @modal.web_server(3000, startup_timeout=120)57 @modal.web_server(3000, startup_timeout=120)
56 def grafana() -> None:58 def grafana() -> None:
57 """Run a public Grafana login page; its data-source credentials stay server-side."""59 """Run a public Grafana login page; its data-source credentials stay server-side."""
58- def drop_privileges() -> None:
59- # Modal ignores the Docker image's USER instruction for Functions.
60- # The upstream Grafana image uses unprivileged UID 472 and group 0.
61- os.setgid(GRAFANA_GID)
62- os.setuid(GRAFANA_UID)
63-
64 process = subprocess.Popen(60 process = subprocess.Popen(
65 ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"],61 ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"],
66- preexec_fn=drop_privileges,62+ # Pass this through the spawned process as well as Modal's function
63+ # environment, since the web-server proxy reaches the service over IPv4.
64+ env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"},
67 )65 )
68- # modal.web_server waits for port 3000. Catch an immediate startup failure66+ # Grafana takes a few seconds to bind. Do not return from the Modal web
69- # early so its cause appears in Modal logs rather than as a proxy timeout.67+ # function until its port is accepting connections; otherwise the proxy can
70- time.sleep(1)68+ # route the first request before a backend exists and leave it hanging.
71- if process.poll() is not None:69+ for _ in range(60):
72- raise RuntimeError(f"Grafana exited during startup ({process.returncode})")70+ if process.poll() is not None:
71+ raise RuntimeError(f"Grafana exited during startup ({process.returncode})")
72+ with socket.socket() as probe:
73+ probe.settimeout(0.25)
74+ if probe.connect_ex(("127.0.0.1", 3000)) == 0:
75+ return
76+ time.sleep(0.5)
77+ raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds")
added modal_pocket_id.py +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+"""Persistent Pocket ID deployment for Modal.
2+
3+The ``pocket-id`` secret must contain ``ENCRYPTION_KEY``. Pocket ID keeps its
4+SQLite database, signing keys, and uploads in ``/app/data``, mounted here as a
5+Modal Volume so they survive container replacement and scale-to-zero.
6+"""
7+
8+import subprocess
9+import time
10+
11+import modal
12+
13+APP_NAME = "pocket-id"
14+APP_URL = "https://codegod100--pocket-id-serve.modal.run"
15+DATA_VOLUME_NAME = "pocket-id-data"
16+
17+app = modal.App(APP_NAME)
18+data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True)
19+pocket_id_secret = modal.Secret.from_name("pocket-id", required_keys=["ENCRYPTION_KEY"])
20+image = (
21+ modal.Image.debian_slim(python_version="3.12")
22+ .apt_install("ca-certificates", "curl")
23+ .run_commands(
24+ "mkdir -p /app "
25+ "&& curl --fail --location --silent --show-error "
26+ "https://github.com/pocket-id/pocket-id/releases/latest/download/pocket-id_linux_amd64 "
27+ "--output /app/pocket-id "
28+ "&& chmod 0755 /app/pocket-id"
29+ )
30+)
31+
32+
33+@app.function(
34+ image=image,
35+ secrets=[pocket_id_secret],
36+ volumes={"/app/data": data_volume},
37+ min_containers=0,
38+ max_containers=1,
39+ scaledown_window=300,
40+ timeout=15 * 60,
41+ env={
42+ "APP_URL": APP_URL,
43+ "ALLOW_INSECURE_CALLBACK_URLS": "false",
44+ # Modal Volumes are network filesystems; SQLite's WAL mode is unsafe
45+ # there. Keep a single writer and use DELETE journaling instead.
46+ "DB_CONNECTION_STRING": "data/pocket-id.db?_journal_mode=DELETE",
47+ },
48+)
49+@modal.concurrent(max_inputs=100)
50+@modal.web_server(1411, startup_timeout=120)
51+def serve() -> None:
52+ """Start Pocket ID and retain the web-server Function for its lifetime."""
53+ process = subprocess.Popen(["/app/pocket-id"])
54+ time.sleep(1)
55+ if process.poll() is not None:
56+ raise RuntimeError(f"Pocket ID exited during startup ({process.returncode})")
new file mode 100644
@@ -0,0 +1,56 @@
1+"""Persistent Pocket ID deployment for Modal.
2+
3+The ``pocket-id`` secret must contain ``ENCRYPTION_KEY``. Pocket ID keeps its
4+SQLite database, signing keys, and uploads in ``/app/data``, mounted here as a
5+Modal Volume so they survive container replacement and scale-to-zero.
6+"""
7+
8+import subprocess
9+import time
10+
11+import modal
12+
13+APP_NAME = "pocket-id"
14+APP_URL = "https://codegod100--pocket-id-serve.modal.run"
15+DATA_VOLUME_NAME = "pocket-id-data"
16+
17+app = modal.App(APP_NAME)
18+data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True)
19+pocket_id_secret = modal.Secret.from_name("pocket-id", required_keys=["ENCRYPTION_KEY"])
20+image = (
21+ modal.Image.debian_slim(python_version="3.12")
22+ .apt_install("ca-certificates", "curl")
23+ .run_commands(
24+ "mkdir -p /app "
25+ "&& curl --fail --location --silent --show-error "
26+ "https://github.com/pocket-id/pocket-id/releases/latest/download/pocket-id_linux_amd64 "
27+ "--output /app/pocket-id "
28+ "&& chmod 0755 /app/pocket-id"
29+ )
30+)
31+
32+
33+@app.function(
34+ image=image,
35+ secrets=[pocket_id_secret],
36+ volumes={"/app/data": data_volume},
37+ min_containers=0,
38+ max_containers=1,
39+ scaledown_window=300,
40+ timeout=15 * 60,
41+ env={
42+ "APP_URL": APP_URL,
43+ "ALLOW_INSECURE_CALLBACK_URLS": "false",
44+ # Modal Volumes are network filesystems; SQLite's WAL mode is unsafe
45+ # there. Keep a single writer and use DELETE journaling instead.
46+ "DB_CONNECTION_STRING": "data/pocket-id.db?_journal_mode=DELETE",
47+ },
48+)
49+@modal.concurrent(max_inputs=100)
50+@modal.web_server(1411, startup_timeout=120)
51+def serve() -> None:
52+ """Start Pocket ID and retain the web-server Function for its lifetime."""
53+ process = subprocess.Popen(["/app/pocket-id"])
54+ time.sleep(1)
55+ if process.poll() is not None:
56+ raise RuntimeError(f"Pocket ID exited during startup ({process.returncode})")