Harden Grafana and ClickHouse deployment configuration
4edeea9 parent: e5a43e4 modified
README.md +28 -3 | @@ -71,7 +71,7 @@ ClickHouse merges parts. | ||
| 71 | 71 | |
| 72 | 72 | For low-volume ingestion, [modal_clickhouse.py](modal_clickhouse.py) runs the |
| 73 | 73 | ClickHouse HTTP API as a protected Modal Web Function. It starts only when an |
| 74 | -HTTP request arrives and is eligible to scale to zero after 60 seconds idle. | |
| 74 | +HTTP request arrives and is eligible to scale to zero after five minutes idle. | |
| 75 | 75 | Its data directory is retained in the named `atop-clickhouse-data` Modal |
| 76 | 76 | Volume. It intentionally limits Modal to one container because the Volume must |
| 77 | 77 | not have concurrent ClickHouse writers. |
| @@ -92,7 +92,9 @@ Modal Proxy Token credentials protect the endpoint at the edge; the collector | ||
| 92 | 92 | passes them as `Modal-Key` and `Modal-Secret`. They may also be supplied with |
| 93 | 93 | `--clickhouse-modal-key` and `--clickhouse-modal-secret` instead of environment |
| 94 | 94 | variables. Expect a cold-start delay on the first request after the endpoint |
| 95 | -has scaled down. | |
| 95 | +has scaled down. Grafana's ClickHouse data source allows for that startup delay | |
| 96 | +with a 120-second connection timeout and a 180-second query timeout; a failed | |
| 97 | +startup still surfaces as an error rather than an indefinite loading state. | |
| 96 | 98 | |
| 97 | 99 | ### Modal ingestion app |
| 98 | 100 | |
| @@ -159,6 +161,15 @@ and top processes). Grafana is public at the HTTP layer but authenticates every | ||
| 159 | 161 | visitor through Pocket ID; its Modal Proxy Token stays in a Modal Secret and is |
| 160 | 162 | never sent to the browser. |
| 161 | 163 | |
| 164 | +Before deploying, create a ClickHouse user named `grafana_reader` with a | |
| 165 | +nonempty password. Grant it `SELECT` on `default.atop_samples` and | |
| 166 | +`default.atop_processes`, plus `SHOW TABLES` and `SHOW DATABASES` for the | |
| 167 | +`default` database. Store the matching password in a separate Modal Secret | |
| 168 | +named `atop-grafana-clickhouse-auth` under the key | |
| 169 | +`CLICKHOUSE_GRAFANA_PASSWORD`. Grafana requires that Secret at deployment; | |
| 170 | +its `default` ClickHouse user remains passwordless for ingestion but is not | |
| 171 | +used by Grafana. Do not put the password in the repository. | |
| 172 | + | |
| 162 | 173 | ```sh |
| 163 | 174 | # In Pocket ID, create a confidential OIDC client and restrict it to the users |
| 164 | 175 | # or groups that may view Grafana. Its redirect URI must exactly be: |
| @@ -185,10 +196,24 @@ modal secret create atop-grafana \ | ||
| 185 | 196 | modal deploy modal_grafana.py |
| 186 | 197 | ``` |
| 187 | 198 | |
| 199 | +The deployment creates a persistent Modal Volume named `atop-grafana-data` | |
| 200 | +and mounts it at `/var/lib/grafana`, where Grafana stores its SQLite database | |
| 201 | +and saved settings. The ClickHouse plugin stays in the image at | |
| 202 | +`/opt/grafana/plugins`, so the data mount does not hide it. Keep Grafana at one | |
| 203 | +container while using SQLite; back up the Volume before replacing or deleting | |
| 204 | +it. For example, run | |
| 205 | +`modal volume get atop-grafana-data grafana.db ./grafana.db.backup` and store | |
| 206 | +that backup securely because the database may contain credentials. To recover, | |
| 207 | +stop Grafana, upload the backup with | |
| 208 | +`modal volume put --force atop-grafana-data ./grafana.db.backup grafana.db`, | |
| 209 | +then redeploy. If an older deployment already has dashboards or other changes | |
| 210 | +not in provisioning, migrate its database before deploying this new mount. | |
| 211 | + | |
| 188 | 212 | Or run `python create_grafana_secret.py` to fetch those four public endpoint |
| 189 | 213 | values from Pocket ID's discovery document and securely prompt for the client |
| 190 | 214 | credentials and Modal token. It creates the same `atop-grafana` secret without |
| 191 | -printing the private values. | |
| 215 | +printing the private values. It does not create the separate ClickHouse user or | |
| 216 | +password Secret. | |
| 192 | 217 | |
| 193 | 218 | Grafana redirects visitors to Pocket ID and does not expose a local password |
| 194 | 219 | form. It uses PKCE, validates the signed ID token with Pocket ID’s JWKS, and |
| @@ -71,7 +71,7 @@ ClickHouse merges parts. | |||
| 71 | 71 | ||
| 72 | For low-volume ingestion, [modal_clickhouse.py](modal_clickhouse.py) runs the | 72 | For low-volume ingestion, [modal_clickhouse.py](modal_clickhouse.py) runs the |
| 73 | ClickHouse HTTP API as a protected Modal Web Function. It starts only when an | 73 | ClickHouse HTTP API as a protected Modal Web Function. It starts only when an |
| 74 | -HTTP request arrives and is eligible to scale to zero after 60 seconds idle. | 74 | +HTTP request arrives and is eligible to scale to zero after five minutes idle. |
| 75 | Its data directory is retained in the named `atop-clickhouse-data` Modal | 75 | Its data directory is retained in the named `atop-clickhouse-data` Modal |
| 76 | Volume. It intentionally limits Modal to one container because the Volume must | 76 | Volume. It intentionally limits Modal to one container because the Volume must |
| 77 | not have concurrent ClickHouse writers. | 77 | not have concurrent ClickHouse writers. |
| @@ -92,7 +92,9 @@ Modal Proxy Token credentials protect the endpoint at the edge; the collector | |||
| 92 | passes them as `Modal-Key` and `Modal-Secret`. They may also be supplied with | 92 | passes them as `Modal-Key` and `Modal-Secret`. They may also be supplied with |
| 93 | `--clickhouse-modal-key` and `--clickhouse-modal-secret` instead of environment | 93 | `--clickhouse-modal-key` and `--clickhouse-modal-secret` instead of environment |
| 94 | variables. Expect a cold-start delay on the first request after the endpoint | 94 | variables. Expect a cold-start delay on the first request after the endpoint |
| 95 | -has scaled down. | 95 | +has scaled down. Grafana's ClickHouse data source allows for that startup delay |
| 96 | +with a 120-second connection timeout and a 180-second query timeout; a failed | ||
| 97 | +startup still surfaces as an error rather than an indefinite loading state. | ||
| 96 | 98 | ||
| 97 | ### Modal ingestion app | 99 | ### Modal ingestion app |
| 98 | 100 | ||
| @@ -159,6 +161,15 @@ and top processes). Grafana is public at the HTTP layer but authenticates every | |||
| 159 | visitor through Pocket ID; its Modal Proxy Token stays in a Modal Secret and is | 161 | visitor through Pocket ID; its Modal Proxy Token stays in a Modal Secret and is |
| 160 | never sent to the browser. | 162 | never sent to the browser. |
| 161 | 163 | ||
| 164 | +Before deploying, create a ClickHouse user named `grafana_reader` with a | ||
| 165 | +nonempty password. Grant it `SELECT` on `default.atop_samples` and | ||
| 166 | +`default.atop_processes`, plus `SHOW TABLES` and `SHOW DATABASES` for the | ||
| 167 | +`default` database. Store the matching password in a separate Modal Secret | ||
| 168 | +named `atop-grafana-clickhouse-auth` under the key | ||
| 169 | +`CLICKHOUSE_GRAFANA_PASSWORD`. Grafana requires that Secret at deployment; | ||
| 170 | +its `default` ClickHouse user remains passwordless for ingestion but is not | ||
| 171 | +used by Grafana. Do not put the password in the repository. | ||
| 172 | + | ||
| 162 | ```sh | 173 | ```sh |
| 163 | # In Pocket ID, create a confidential OIDC client and restrict it to the users | 174 | # In Pocket ID, create a confidential OIDC client and restrict it to the users |
| 164 | # or groups that may view Grafana. Its redirect URI must exactly be: | 175 | # or groups that may view Grafana. Its redirect URI must exactly be: |
| @@ -185,10 +196,24 @@ modal secret create atop-grafana \ | |||
| 185 | modal deploy modal_grafana.py | 196 | modal deploy modal_grafana.py |
| 186 | ``` | 197 | ``` |
| 187 | 198 | ||
| 199 | +The deployment creates a persistent Modal Volume named `atop-grafana-data` | ||
| 200 | +and mounts it at `/var/lib/grafana`, where Grafana stores its SQLite database | ||
| 201 | +and saved settings. The ClickHouse plugin stays in the image at | ||
| 202 | +`/opt/grafana/plugins`, so the data mount does not hide it. Keep Grafana at one | ||
| 203 | +container while using SQLite; back up the Volume before replacing or deleting | ||
| 204 | +it. For example, run | ||
| 205 | +`modal volume get atop-grafana-data grafana.db ./grafana.db.backup` and store | ||
| 206 | +that backup securely because the database may contain credentials. To recover, | ||
| 207 | +stop Grafana, upload the backup with | ||
| 208 | +`modal volume put --force atop-grafana-data ./grafana.db.backup grafana.db`, | ||
| 209 | +then redeploy. If an older deployment already has dashboards or other changes | ||
| 210 | +not in provisioning, migrate its database before deploying this new mount. | ||
| 211 | + | ||
| 188 | Or run `python create_grafana_secret.py` to fetch those four public endpoint | 212 | Or run `python create_grafana_secret.py` to fetch those four public endpoint |
| 189 | values from Pocket ID's discovery document and securely prompt for the client | 213 | values from Pocket ID's discovery document and securely prompt for the client |
| 190 | credentials and Modal token. It creates the same `atop-grafana` secret without | 214 | credentials and Modal token. It creates the same `atop-grafana` secret without |
| 191 | -printing the private values. | 215 | +printing the private values. It does not create the separate ClickHouse user or |
| 216 | +password Secret. | ||
| 192 | 217 | ||
| 193 | Grafana redirects visitors to Pocket ID and does not expose a local password | 218 | Grafana redirects visitors to Pocket ID and does not expose a local password |
| 194 | form. It uses PKCE, validates the signed ID token with Pocket ID’s JWKS, and | 219 | form. It uses PKCE, validates the signed ID token with Pocket ID’s JWKS, and |
modified
create_grafana_secret.py +2 -0 | @@ -4,6 +4,8 @@ | ||
| 4 | 4 | Pocket ID exposes public OIDC endpoint metadata through its discovery document, |
| 5 | 5 | but intentionally never exposes an OAuth client secret. This helper fetches the |
| 6 | 6 | public metadata and prompts locally for the credentials that must remain private. |
| 7 | +Grafana also requires the separate ``atop-grafana-clickhouse-auth`` Secret and | |
| 8 | +its matching read-only ClickHouse user; this helper does not create either. | |
| 7 | 9 | """ |
| 8 | 10 | |
| 9 | 11 | import argparse |
| @@ -4,6 +4,8 @@ | |||
| 4 | Pocket ID exposes public OIDC endpoint metadata through its discovery document, | 4 | Pocket ID exposes public OIDC endpoint metadata through its discovery document, |
| 5 | but intentionally never exposes an OAuth client secret. This helper fetches the | 5 | but intentionally never exposes an OAuth client secret. This helper fetches the |
| 6 | public metadata and prompts locally for the credentials that must remain private. | 6 | public metadata and prompts locally for the credentials that must remain private. |
| 7 | +Grafana also requires the separate ``atop-grafana-clickhouse-auth`` Secret and | ||
| 8 | +its matching read-only ClickHouse user; this helper does not create either. | ||
| 7 | """ | 9 | """ |
| 8 | 10 | ||
| 9 | import argparse | 11 | import argparse |
modified
grafana/provisioning/dashboards/json/atop-overview.json +3 -3 | @@ -7,7 +7,7 @@ | ||
| 7 | 7 | "fieldConfig": {"defaults": {"unit": "percent"}, "overrides": []}, |
| 8 | 8 | "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0}, |
| 9 | 9 | "id": 1, |
| 10 | - "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(busy_pct, ingest_version) AS busy, argMax(wait_pct, ingest_version) AS iowait FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], | |
| 10 | + "targets": [{"editorType": "sql", "format": 0, "queryType": "timeseries", "rawSql": "SELECT epoch AS time, argMax(busy_pct, ingest_version) AS busy, argMax(wait_pct, ingest_version) AS iowait FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], | |
| 11 | 11 | "title": "CPU busy and I/O wait", |
| 12 | 12 | "type": "timeseries" |
| 13 | 13 | }, |
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []}, |
| 17 | 17 | "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0}, |
| 18 | 18 | "id": 2, |
| 19 | - "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(load1, ingest_version) AS load_1m, argMax(load5, ingest_version) AS load_5m, argMax(load15, ingest_version) AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], | |
| 19 | + "targets": [{"editorType": "sql", "format": 0, "queryType": "timeseries", "rawSql": "SELECT epoch AS time, argMax(load1, ingest_version) AS load_1m, argMax(load5, ingest_version) AS load_5m, argMax(load15, ingest_version) AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], | |
| 20 | 20 | "title": "Load average", |
| 21 | 21 | "type": "timeseries" |
| 22 | 22 | }, |
| @@ -26,7 +26,7 @@ | ||
| 26 | 26 | "gridPos": {"h": 9, "w": 24, "x": 0, "y": 9}, |
| 27 | 27 | "id": 3, |
| 28 | 28 | "options": {"showHeader": true}, |
| 29 | - "targets": [{"format": 1, "query": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM (SELECT host, epoch, pid, name, argMax(cpu_pct, ingest_version) AS cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY host, epoch, pid, name) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}], | |
| 29 | + "targets": [{"editorType": "sql", "format": 1, "queryType": "table", "rawSql": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM (SELECT host, epoch, pid, name, argMax(cpu_pct, ingest_version) AS cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY host, epoch, pid, name) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}], | |
| 30 | 30 | "title": "Top processes", |
| 31 | 31 | "type": "table" |
| 32 | 32 | } |
| @@ -7,7 +7,7 @@ | |||
| 7 | "fieldConfig": {"defaults": {"unit": "percent"}, "overrides": []}, | 7 | "fieldConfig": {"defaults": {"unit": "percent"}, "overrides": []}, |
| 8 | "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0}, | 8 | "gridPos": {"h": 9, "w": 12, "x": 0, "y": 0}, |
| 9 | "id": 1, | 9 | "id": 1, |
| 10 | - "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(busy_pct, ingest_version) AS busy, argMax(wait_pct, ingest_version) AS iowait FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], | 10 | + "targets": [{"editorType": "sql", "format": 0, "queryType": "timeseries", "rawSql": "SELECT epoch AS time, argMax(busy_pct, ingest_version) AS busy, argMax(wait_pct, ingest_version) AS iowait FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], |
| 11 | "title": "CPU busy and I/O wait", | 11 | "title": "CPU busy and I/O wait", |
| 12 | "type": "timeseries" | 12 | "type": "timeseries" |
| 13 | }, | 13 | }, |
| @@ -16,7 +16,7 @@ | |||
| 16 | "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []}, | 16 | "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []}, |
| 17 | "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0}, | 17 | "gridPos": {"h": 9, "w": 12, "x": 12, "y": 0}, |
| 18 | "id": 2, | 18 | "id": 2, |
| 19 | - "targets": [{"format": 1, "query": "SELECT epoch AS time, argMax(load1, ingest_version) AS load_1m, argMax(load5, ingest_version) AS load_5m, argMax(load15, ingest_version) AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], | 19 | + "targets": [{"editorType": "sql", "format": 0, "queryType": "timeseries", "rawSql": "SELECT epoch AS time, argMax(load1, ingest_version) AS load_1m, argMax(load5, ingest_version) AS load_5m, argMax(load15, ingest_version) AS load_15m FROM atop_samples WHERE $__timeFilter(epoch) GROUP BY host, epoch ORDER BY epoch", "refId": "A"}], |
| 20 | "title": "Load average", | 20 | "title": "Load average", |
| 21 | "type": "timeseries" | 21 | "type": "timeseries" |
| 22 | }, | 22 | }, |
| @@ -26,7 +26,7 @@ | |||
| 26 | "gridPos": {"h": 9, "w": 24, "x": 0, "y": 9}, | 26 | "gridPos": {"h": 9, "w": 24, "x": 0, "y": 9}, |
| 27 | "id": 3, | 27 | "id": 3, |
| 28 | "options": {"showHeader": true}, | 28 | "options": {"showHeader": true}, |
| 29 | - "targets": [{"format": 1, "query": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM (SELECT host, epoch, pid, name, argMax(cpu_pct, ingest_version) AS cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY host, epoch, pid, name) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}], | 29 | + "targets": [{"editorType": "sql", "format": 1, "queryType": "table", "rawSql": "SELECT name AS process, round(avg(cpu_pct), 2) AS avg_cpu_pct, round(max(cpu_pct), 2) AS peak_cpu_pct FROM (SELECT host, epoch, pid, name, argMax(cpu_pct, ingest_version) AS cpu_pct FROM atop_processes WHERE $__timeFilter(epoch) GROUP BY host, epoch, pid, name) GROUP BY name ORDER BY avg_cpu_pct DESC LIMIT 15", "refId": "A"}], |
| 30 | "title": "Top processes", | 30 | "title": "Top processes", |
| 31 | "type": "table" | 31 | "type": "table" |
| 32 | } | 32 | } |
modified
grafana/provisioning/datasources/clickhouse.yaml +4 -1 | @@ -11,7 +11,9 @@ datasources: | ||
| 11 | 11 | port: 443 |
| 12 | 12 | protocol: http |
| 13 | 13 | secure: true |
| 14 | - username: default | |
| 14 | + dialTimeout: 120 | |
| 15 | + queryTimeout: 180 | |
| 16 | + username: grafana_reader | |
| 15 | 17 | defaultDatabase: default |
| 16 | 18 | httpHeaders: |
| 17 | 19 | - name: Modal-Key |
| @@ -19,5 +21,6 @@ datasources: | ||
| 19 | 21 | - name: Modal-Secret |
| 20 | 22 | secure: true |
| 21 | 23 | secureJsonData: |
| 24 | + password: $__env{CLICKHOUSE_GRAFANA_PASSWORD} | |
| 22 | 25 | secureHttpHeaders.Modal-Key: $__env{MODAL_KEY} |
| 23 | 26 | secureHttpHeaders.Modal-Secret: $__env{MODAL_SECRET} |
| @@ -11,7 +11,9 @@ datasources: | |||
| 11 | port: 443 | 11 | port: 443 |
| 12 | protocol: http | 12 | protocol: http |
| 13 | secure: true | 13 | secure: true |
| 14 | - username: default | 14 | + dialTimeout: 120 |
| 15 | + queryTimeout: 180 | ||
| 16 | + username: grafana_reader | ||
| 15 | defaultDatabase: default | 17 | defaultDatabase: default |
| 16 | httpHeaders: | 18 | httpHeaders: |
| 17 | - name: Modal-Key | 19 | - name: Modal-Key |
| @@ -19,5 +21,6 @@ datasources: | |||
| 19 | - name: Modal-Secret | 21 | - name: Modal-Secret |
| 20 | secure: true | 22 | secure: true |
| 21 | secureJsonData: | 23 | secureJsonData: |
| 24 | + password: $__env{CLICKHOUSE_GRAFANA_PASSWORD} | ||
| 22 | secureHttpHeaders.Modal-Key: $__env{MODAL_KEY} | 25 | secureHttpHeaders.Modal-Key: $__env{MODAL_KEY} |
| 23 | secureHttpHeaders.Modal-Secret: $__env{MODAL_SECRET} | 26 | secureHttpHeaders.Modal-Secret: $__env{MODAL_SECRET} |
modified
modal_clickhouse.py +4 -4 | @@ -18,7 +18,7 @@ import modal | ||
| 18 | 18 | |
| 19 | 19 | APP_NAME = "atop-clickhouse" |
| 20 | 20 | DATA_VOLUME_NAME = "atop-clickhouse-data" |
| 21 | -IDLE_SECONDS = 60 | |
| 21 | +IDLE_SECONDS = 300 | |
| 22 | 22 | |
| 23 | 23 | app = modal.App(APP_NAME) |
| 24 | 24 | data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True) |
| @@ -35,13 +35,13 @@ image = modal.Image.from_registry( | ||
| 35 | 35 | scaledown_window=IDLE_SECONDS, |
| 36 | 36 | timeout=10 * 60, |
| 37 | 37 | ) |
| 38 | -@modal.web_server(8123, startup_timeout=120, requires_proxy_auth=True) | |
| 38 | +@modal.web_server(8123, startup_timeout=240, requires_proxy_auth=True) | |
| 39 | 39 | def clickhouse() -> None: |
| 40 | 40 | """Start ClickHouse's native HTTP API; Modal proxies requests to port 8123.""" |
| 41 | 41 | process = subprocess.Popen( |
| 42 | 42 | ["clickhouse-server", "--config-file=/etc/clickhouse-server/config.xml"] |
| 43 | 43 | ) |
| 44 | - deadline = time.monotonic() + 90 | |
| 44 | + deadline = time.monotonic() + 210 | |
| 45 | 45 | while time.monotonic() < deadline: |
| 46 | 46 | if process.poll() is not None: |
| 47 | 47 | raise RuntimeError(f"ClickHouse exited during startup ({process.returncode})") |
| @@ -51,4 +51,4 @@ def clickhouse() -> None: | ||
| 51 | 51 | return |
| 52 | 52 | time.sleep(0.2) |
| 53 | 53 | process.terminate() |
| 54 | - raise RuntimeError("ClickHouse did not open port 8123 within 90 seconds") | |
| 54 | + raise RuntimeError("ClickHouse did not open port 8123 within 210 seconds") | |
| @@ -18,7 +18,7 @@ import modal | |||
| 18 | 18 | ||
| 19 | APP_NAME = "atop-clickhouse" | 19 | APP_NAME = "atop-clickhouse" |
| 20 | DATA_VOLUME_NAME = "atop-clickhouse-data" | 20 | DATA_VOLUME_NAME = "atop-clickhouse-data" |
| 21 | -IDLE_SECONDS = 60 | 21 | +IDLE_SECONDS = 300 |
| 22 | 22 | ||
| 23 | app = modal.App(APP_NAME) | 23 | app = modal.App(APP_NAME) |
| 24 | data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True) | 24 | data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True) |
| @@ -35,13 +35,13 @@ image = modal.Image.from_registry( | |||
| 35 | scaledown_window=IDLE_SECONDS, | 35 | scaledown_window=IDLE_SECONDS, |
| 36 | timeout=10 * 60, | 36 | timeout=10 * 60, |
| 37 | ) | 37 | ) |
| 38 | -@modal.web_server(8123, startup_timeout=120, requires_proxy_auth=True) | 38 | +@modal.web_server(8123, startup_timeout=240, requires_proxy_auth=True) |
| 39 | def clickhouse() -> None: | 39 | def clickhouse() -> None: |
| 40 | """Start ClickHouse's native HTTP API; Modal proxies requests to port 8123.""" | 40 | """Start ClickHouse's native HTTP API; Modal proxies requests to port 8123.""" |
| 41 | process = subprocess.Popen( | 41 | process = subprocess.Popen( |
| 42 | ["clickhouse-server", "--config-file=/etc/clickhouse-server/config.xml"] | 42 | ["clickhouse-server", "--config-file=/etc/clickhouse-server/config.xml"] |
| 43 | ) | 43 | ) |
| 44 | - deadline = time.monotonic() + 90 | 44 | + deadline = time.monotonic() + 210 |
| 45 | while time.monotonic() < deadline: | 45 | while time.monotonic() < deadline: |
| 46 | if process.poll() is not None: | 46 | if process.poll() is not None: |
| 47 | raise RuntimeError(f"ClickHouse exited during startup ({process.returncode})") | 47 | raise RuntimeError(f"ClickHouse exited during startup ({process.returncode})") |
| @@ -51,4 +51,4 @@ def clickhouse() -> None: | |||
| 51 | return | 51 | return |
| 52 | time.sleep(0.2) | 52 | time.sleep(0.2) |
| 53 | process.terminate() | 53 | process.terminate() |
| 54 | - raise RuntimeError("ClickHouse did not open port 8123 within 90 seconds") | 54 | + raise RuntimeError("ClickHouse did not open port 8123 within 210 seconds") |
modified
modal_grafana.py +38 -41 | @@ -2,20 +2,22 @@ | ||
| 2 | 2 | |
| 3 | 3 | Create a Modal Secret named ``atop-grafana`` before deploying. It needs: |
| 4 | 4 | ``CLICKHOUSE_HOST`` (hostname only), ``MODAL_KEY``, ``MODAL_SECRET``, and |
| 5 | -the Pocket ID Generic OAuth settings, including ``GF_SERVER_ROOT_URL``. Deploy | |
| 6 | -with ``modal deploy modal_grafana.py``. | |
| 5 | +the Pocket ID Generic OAuth settings, including ``GF_SERVER_ROOT_URL``. A | |
| 6 | +separate ``atop-grafana-clickhouse-auth`` Secret must contain the nonempty | |
| 7 | +``CLICKHOUSE_GRAFANA_PASSWORD`` for the read-only ``grafana_reader`` user. | |
| 8 | +Deploy with ``modal deploy modal_grafana.py``. | |
| 7 | 9 | """ |
| 8 | 10 | |
| 9 | -import os | |
| 10 | -import socket | |
| 11 | 11 | import subprocess |
| 12 | 12 | import time |
| 13 | 13 | |
| 14 | 14 | import modal |
| 15 | 15 | |
| 16 | 16 | APP_NAME = "atop-grafana" |
| 17 | +DATA_VOLUME_NAME = "atop-grafana-data" | |
| 17 | 18 | IDLE_SECONDS = 300 |
| 18 | 19 | app = modal.App(APP_NAME) |
| 20 | +data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True) | |
| 19 | 21 | grafana_secret = modal.Secret.from_name( |
| 20 | 22 | "atop-grafana", |
| 21 | 23 | required_keys=[ |
| @@ -25,23 +27,44 @@ grafana_secret = modal.Secret.from_name( | ||
| 25 | 27 | "GF_AUTH_GENERIC_OAUTH_API_URL", "GF_AUTH_GENERIC_OAUTH_JWK_SET_URL", |
| 26 | 28 | ], |
| 27 | 29 | ) |
| 30 | +clickhouse_auth_secret = modal.Secret.from_name( | |
| 31 | + "atop-grafana-clickhouse-auth", | |
| 32 | + required_keys=["CLICKHOUSE_GRAFANA_PASSWORD"], | |
| 33 | +) | |
| 28 | 34 | image = ( |
| 29 | - modal.Image.from_dockerfile("grafana/Dockerfile") | |
| 35 | + modal.Image.debian_slim(python_version="3.12") | |
| 36 | + .apt_install("adduser", "ca-certificates", "curl", "libfontconfig1", "musl") | |
| 37 | + .run_commands( | |
| 38 | + "curl --fail --location --silent --show-error " | |
| 39 | + "https://dl.grafana.com/oss/release/grafana_11.6.0_amd64.deb " | |
| 40 | + "--output /tmp/grafana.deb " | |
| 41 | + "&& echo 'c5159ce58c1eb7b4a47474951f0334055dd1a4b3325ffc432ef1761da6474b2a /tmp/grafana.deb' " | |
| 42 | + "| sha256sum --check " | |
| 43 | + "&& apt-get update " | |
| 44 | + "&& apt-get install --yes --no-install-recommends /tmp/grafana.deb " | |
| 45 | + "&& /usr/share/grafana/bin/grafana cli --homepath=/usr/share/grafana " | |
| 46 | + "--pluginsDir=/opt/grafana/plugins " | |
| 47 | + "plugins install grafana-clickhouse-datasource" | |
| 48 | + ) | |
| 30 | 49 | .add_local_dir("grafana/provisioning", "/etc/grafana/provisioning") |
| 31 | 50 | ) |
| 32 | 51 | |
| 33 | 52 | |
| 34 | 53 | @app.function( |
| 35 | 54 | image=image, |
| 36 | - secrets=[grafana_secret], | |
| 55 | + secrets=[grafana_secret, clickhouse_auth_secret], | |
| 56 | + volumes={"/var/lib/grafana": data_volume}, | |
| 37 | 57 | min_containers=0, |
| 38 | 58 | max_containers=1, |
| 39 | 59 | scaledown_window=IDLE_SECONDS, |
| 40 | 60 | timeout=15 * 60, |
| 41 | 61 | env={ |
| 42 | - # Modal's web-server proxy connects over IPv4; Grafana otherwise binds | |
| 43 | - # to [::], which can leave the public endpoint waiting indefinitely. | |
| 44 | 62 | "GF_SERVER_HTTP_ADDR": "0.0.0.0", |
| 63 | + "GF_PATHS_DATA": "/var/lib/grafana", | |
| 64 | + "GF_PATHS_LOGS": "/var/log/grafana", | |
| 65 | + "GF_PATHS_PLUGINS": "/opt/grafana/plugins", | |
| 66 | + "GF_DATABASE_WAL": "false", | |
| 67 | + "GF_PATHS_PROVISIONING": "/etc/grafana/provisioning", | |
| 45 | 68 | "GF_AUTH_DISABLE_LOGIN_FORM": "true", |
| 46 | 69 | "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", |
| 47 | 70 | "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID", |
| @@ -54,39 +77,13 @@ image = ( | ||
| 54 | 77 | }, |
| 55 | 78 | ) |
| 56 | 79 | @modal.concurrent(max_inputs=100) |
| 57 | -@modal.web_server(8080, startup_timeout=120) | |
| 80 | +@modal.web_server(3000, startup_timeout=120) | |
| 58 | 81 | def grafana() -> None: |
| 59 | 82 | """Run a public Grafana login page; its data-source credentials stay server-side.""" |
| 60 | - process = subprocess.Popen( | |
| 61 | - ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"], | |
| 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"}, | |
| 65 | - ) | |
| 66 | - # Start the external listener only after Grafana accepts connections. | |
| 67 | - for _ in range(60): | |
| 68 | - if process.poll() is not None: | |
| 69 | - raise RuntimeError(f"Grafana exited during startup ({process.returncode})") | |
| 70 | - with socket.socket() as probe: | |
| 71 | - probe.settimeout(0.25) | |
| 72 | - if probe.connect_ex(("127.0.0.1", 3000)) == 0: | |
| 73 | - print("Grafana backend ready on port 3000", flush=True) | |
| 74 | - break | |
| 75 | - time.sleep(0.5) | |
| 76 | - else: | |
| 77 | - raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds") | |
| 78 | - | |
| 79 | - proxy = subprocess.Popen([ | |
| 80 | - "socat", "TCP-LISTEN:8080,bind=0.0.0.0,reuseaddr,fork", | |
| 81 | - "TCP:127.0.0.1:3000", | |
| 83 | + grafana_process = subprocess.Popen([ | |
| 84 | + "/usr/share/grafana/bin/grafana", "server", | |
| 85 | + "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini", | |
| 82 | 86 | ]) |
| 83 | - for _ in range(20): | |
| 84 | - if proxy.poll() is not None: | |
| 85 | - raise RuntimeError(f"Grafana proxy exited during startup ({proxy.returncode})") | |
| 86 | - with socket.socket() as probe: | |
| 87 | - probe.settimeout(0.25) | |
| 88 | - if probe.connect_ex(("127.0.0.1", 8080)) == 0: | |
| 89 | - print("Grafana proxy ready on port 8080", flush=True) | |
| 90 | - return | |
| 91 | - time.sleep(0.5) | |
| 92 | - raise RuntimeError("Grafana proxy did not listen on port 8080 within 10 seconds") | |
| 87 | + time.sleep(1) | |
| 88 | + if grafana_process.poll() is not None: | |
| 89 | + raise RuntimeError(f"Grafana exited during startup ({grafana_process.returncode})") | |
| @@ -2,20 +2,22 @@ | |||
| 2 | 2 | ||
| 3 | Create a Modal Secret named ``atop-grafana`` before deploying. It needs: | 3 | Create a Modal Secret named ``atop-grafana`` before deploying. It needs: |
| 4 | ``CLICKHOUSE_HOST`` (hostname only), ``MODAL_KEY``, ``MODAL_SECRET``, and | 4 | ``CLICKHOUSE_HOST`` (hostname only), ``MODAL_KEY``, ``MODAL_SECRET``, and |
| 5 | -the Pocket ID Generic OAuth settings, including ``GF_SERVER_ROOT_URL``. Deploy | 5 | +the Pocket ID Generic OAuth settings, including ``GF_SERVER_ROOT_URL``. A |
| 6 | -with ``modal deploy modal_grafana.py``. | 6 | +separate ``atop-grafana-clickhouse-auth`` Secret must contain the nonempty |
| 7 | +``CLICKHOUSE_GRAFANA_PASSWORD`` for the read-only ``grafana_reader`` user. | ||
| 8 | +Deploy with ``modal deploy modal_grafana.py``. | ||
| 7 | """ | 9 | """ |
| 8 | 10 | ||
| 9 | -import os | ||
| 10 | -import socket | ||
| 11 | import subprocess | 11 | import subprocess |
| 12 | import time | 12 | import time |
| 13 | 13 | ||
| 14 | import modal | 14 | import modal |
| 15 | 15 | ||
| 16 | APP_NAME = "atop-grafana" | 16 | APP_NAME = "atop-grafana" |
| 17 | +DATA_VOLUME_NAME = "atop-grafana-data" | ||
| 17 | IDLE_SECONDS = 300 | 18 | IDLE_SECONDS = 300 |
| 18 | app = modal.App(APP_NAME) | 19 | app = modal.App(APP_NAME) |
| 20 | +data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True) | ||
| 19 | grafana_secret = modal.Secret.from_name( | 21 | grafana_secret = modal.Secret.from_name( |
| 20 | "atop-grafana", | 22 | "atop-grafana", |
| 21 | required_keys=[ | 23 | required_keys=[ |
| @@ -25,23 +27,44 @@ grafana_secret = modal.Secret.from_name( | |||
| 25 | "GF_AUTH_GENERIC_OAUTH_API_URL", "GF_AUTH_GENERIC_OAUTH_JWK_SET_URL", | 27 | "GF_AUTH_GENERIC_OAUTH_API_URL", "GF_AUTH_GENERIC_OAUTH_JWK_SET_URL", |
| 26 | ], | 28 | ], |
| 27 | ) | 29 | ) |
| 30 | +clickhouse_auth_secret = modal.Secret.from_name( | ||
| 31 | + "atop-grafana-clickhouse-auth", | ||
| 32 | + required_keys=["CLICKHOUSE_GRAFANA_PASSWORD"], | ||
| 33 | +) | ||
| 28 | image = ( | 34 | image = ( |
| 29 | - modal.Image.from_dockerfile("grafana/Dockerfile") | 35 | + modal.Image.debian_slim(python_version="3.12") |
| 36 | + .apt_install("adduser", "ca-certificates", "curl", "libfontconfig1", "musl") | ||
| 37 | + .run_commands( | ||
| 38 | + "curl --fail --location --silent --show-error " | ||
| 39 | + "https://dl.grafana.com/oss/release/grafana_11.6.0_amd64.deb " | ||
| 40 | + "--output /tmp/grafana.deb " | ||
| 41 | + "&& echo 'c5159ce58c1eb7b4a47474951f0334055dd1a4b3325ffc432ef1761da6474b2a /tmp/grafana.deb' " | ||
| 42 | + "| sha256sum --check " | ||
| 43 | + "&& apt-get update " | ||
| 44 | + "&& apt-get install --yes --no-install-recommends /tmp/grafana.deb " | ||
| 45 | + "&& /usr/share/grafana/bin/grafana cli --homepath=/usr/share/grafana " | ||
| 46 | + "--pluginsDir=/opt/grafana/plugins " | ||
| 47 | + "plugins install grafana-clickhouse-datasource" | ||
| 48 | + ) | ||
| 30 | .add_local_dir("grafana/provisioning", "/etc/grafana/provisioning") | 49 | .add_local_dir("grafana/provisioning", "/etc/grafana/provisioning") |
| 31 | ) | 50 | ) |
| 32 | 51 | ||
| 33 | 52 | ||
| 34 | @app.function( | 53 | @app.function( |
| 35 | image=image, | 54 | image=image, |
| 36 | - secrets=[grafana_secret], | 55 | + secrets=[grafana_secret, clickhouse_auth_secret], |
| 56 | + volumes={"/var/lib/grafana": data_volume}, | ||
| 37 | min_containers=0, | 57 | min_containers=0, |
| 38 | max_containers=1, | 58 | max_containers=1, |
| 39 | scaledown_window=IDLE_SECONDS, | 59 | scaledown_window=IDLE_SECONDS, |
| 40 | timeout=15 * 60, | 60 | timeout=15 * 60, |
| 41 | env={ | 61 | 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", | 62 | "GF_SERVER_HTTP_ADDR": "0.0.0.0", |
| 63 | + "GF_PATHS_DATA": "/var/lib/grafana", | ||
| 64 | + "GF_PATHS_LOGS": "/var/log/grafana", | ||
| 65 | + "GF_PATHS_PLUGINS": "/opt/grafana/plugins", | ||
| 66 | + "GF_DATABASE_WAL": "false", | ||
| 67 | + "GF_PATHS_PROVISIONING": "/etc/grafana/provisioning", | ||
| 45 | "GF_AUTH_DISABLE_LOGIN_FORM": "true", | 68 | "GF_AUTH_DISABLE_LOGIN_FORM": "true", |
| 46 | "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", | 69 | "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", |
| 47 | "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID", | 70 | "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID", |
| @@ -54,39 +77,13 @@ image = ( | |||
| 54 | }, | 77 | }, |
| 55 | ) | 78 | ) |
| 56 | @modal.concurrent(max_inputs=100) | 79 | @modal.concurrent(max_inputs=100) |
| 57 | -@modal.web_server(8080, startup_timeout=120) | 80 | +@modal.web_server(3000, startup_timeout=120) |
| 58 | def grafana() -> None: | 81 | def grafana() -> None: |
| 59 | """Run a public Grafana login page; its data-source credentials stay server-side.""" | 82 | """Run a public Grafana login page; its data-source credentials stay server-side.""" |
| 60 | - process = subprocess.Popen( | 83 | + grafana_process = subprocess.Popen([ |
| 61 | - ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"], | 84 | + "/usr/share/grafana/bin/grafana", "server", |
| 62 | - # Pass this through the spawned process as well as Modal's function | 85 | + "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini", |
| 63 | - # environment, since the web-server proxy reaches the service over IPv4. | ||
| 64 | - env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"}, | ||
| 65 | - ) | ||
| 66 | - # Start the external listener only after Grafana accepts connections. | ||
| 67 | - for _ in range(60): | ||
| 68 | - if process.poll() is not None: | ||
| 69 | - raise RuntimeError(f"Grafana exited during startup ({process.returncode})") | ||
| 70 | - with socket.socket() as probe: | ||
| 71 | - probe.settimeout(0.25) | ||
| 72 | - if probe.connect_ex(("127.0.0.1", 3000)) == 0: | ||
| 73 | - print("Grafana backend ready on port 3000", flush=True) | ||
| 74 | - break | ||
| 75 | - time.sleep(0.5) | ||
| 76 | - else: | ||
| 77 | - raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds") | ||
| 78 | - | ||
| 79 | - proxy = subprocess.Popen([ | ||
| 80 | - "socat", "TCP-LISTEN:8080,bind=0.0.0.0,reuseaddr,fork", | ||
| 81 | - "TCP:127.0.0.1:3000", | ||
| 82 | ]) | 86 | ]) |
| 83 | - for _ in range(20): | 87 | + time.sleep(1) |
| 84 | - if proxy.poll() is not None: | 88 | + if grafana_process.poll() is not None: |
| 85 | - raise RuntimeError(f"Grafana proxy exited during startup ({proxy.returncode})") | 89 | + raise RuntimeError(f"Grafana exited during startup ({grafana_process.returncode})") |
| 86 | - with socket.socket() as probe: | ||
| 87 | - probe.settimeout(0.25) | ||
| 88 | - if probe.connect_ex(("127.0.0.1", 8080)) == 0: | ||
| 89 | - print("Grafana proxy ready on port 8080", flush=True) | ||
| 90 | - return | ||
| 91 | - time.sleep(0.5) | ||
| 92 | - raise RuntimeError("Grafana proxy did not listen on port 8080 within 10 seconds") | ||
modified
modal_pocket_id.py +2 -1 | @@ -43,7 +43,8 @@ image = ( | ||
| 43 | 43 | "ALLOW_INSECURE_CALLBACK_URLS": "false", |
| 44 | 44 | # Modal Volumes are network filesystems; SQLite's WAL mode is unsafe |
| 45 | 45 | # there. Keep a single writer and use DELETE journaling instead. |
| 46 | - "DB_CONNECTION_STRING": "data/pocket-id.db?_journal_mode=DELETE", | |
| 46 | + "DB_CONNECTION_STRING": "/app/data/pocket-id.db?_journal_mode=DELETE", | |
| 47 | + "UPLOAD_PATH": "/app/data/uploads", | |
| 47 | 48 | }, |
| 48 | 49 | ) |
| 49 | 50 | @modal.concurrent(max_inputs=100) |
| @@ -43,7 +43,8 @@ image = ( | |||
| 43 | "ALLOW_INSECURE_CALLBACK_URLS": "false", | 43 | "ALLOW_INSECURE_CALLBACK_URLS": "false", |
| 44 | # Modal Volumes are network filesystems; SQLite's WAL mode is unsafe | 44 | # Modal Volumes are network filesystems; SQLite's WAL mode is unsafe |
| 45 | # there. Keep a single writer and use DELETE journaling instead. | 45 | # there. Keep a single writer and use DELETE journaling instead. |
| 46 | - "DB_CONNECTION_STRING": "data/pocket-id.db?_journal_mode=DELETE", | 46 | + "DB_CONNECTION_STRING": "/app/data/pocket-id.db?_journal_mode=DELETE", |
| 47 | + "UPLOAD_PATH": "/app/data/uploads", | ||
| 47 | }, | 48 | }, |
| 48 | ) | 49 | ) |
| 49 | @modal.concurrent(max_inputs=100) | 50 | @modal.concurrent(max_inputs=100) |