planisphere 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0f892c4538b11a9eaea839ce16b677e0034b04540040dd9cecd7f81465f5ed07
4
+ data.tar.gz: c792adef698e4a0ce0711a81612779fafa278f3a72c3ed70de697fd4b903eac1
5
+ SHA512:
6
+ metadata.gz: 98b61037052e9e549f0d1ccd8fc1d94e8c63757907adab7bb3178df18f0a555b3313561667a734c48d82f522cc7c9fc4c964f8f9041c346eed324f6c9b727798
7
+ data.tar.gz: 71ed9d21706d7a5451d86f0ce5cca2528b9da5cc53f1ecaa79e1ef3b1d8ce4707734a57cc843ee59c5b6203167c372054a7abc132f5514c8fd972ae939f77a93
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Abdelkader Boudih
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,546 @@
1
+ # planisphere
2
+
3
+ A headless map server for **PostGIS**. Your layers are SQL functions;
4
+ planisphere serves them as vector tiles, OGC API Tiles, and WMS images. It's a modern
5
+ replacement for `mapserv`-style CGI servers. No mapfiles, no GDAL, no GUI.
6
+
7
+ - **Vector tiles** (MVT) with TileJSON 3.0, ETags, gzip
8
+ - **OGC API - Tiles** with the `WebMercatorQuad` tile matrix set (passes the OGC conformance suite)
9
+ - **WMS 1.3.0 / 1.1.1** `GetMap` (PNG, PNG8, JPEG) with labels, and `GetFeatureInfo`,
10
+ rendered server-side and styled with a MapLibre style (passes the OGC WMS 1.3.0 suite)
11
+ - **Resilient**: adaptive concurrency with queue shedding, circuit breaker around PostGIS,
12
+ startup backoff, per-client rate limits
13
+ - **Observable**: live `/status` dashboard, readiness probe, and an admin MCP endpoint
14
+ - **PostgreSQL 18 + PostGIS 3.6 only**: no compatibility layers for older versions
15
+
16
+ ## Quick start
17
+
18
+ ```sh
19
+ docker compose up --build
20
+ ```
21
+
22
+ Open <http://127.0.0.1:8080/viewer> for a map of the demo layers and
23
+ <http://127.0.0.1:8080/status> for live server state. The database starts with sample data
24
+ from [`dev/fixtures.sql`](dev/fixtures.sql) (loaded once, on a fresh volume).
25
+
26
+ To develop against the same database with cargo:
27
+
28
+ ```sh
29
+ docker compose up -d postgis
30
+ cargo run -- -c planisphere.example.toml serve
31
+ ```
32
+
33
+ ## Layers are SQL functions
34
+
35
+ Any function in a configured schema with this signature becomes a layer, named after the function:
36
+
37
+ ```sql
38
+ fn(z integer, x integer, y integer [, query json | jsonb]) RETURNS bytea
39
+ ```
40
+
41
+ It's the same contract as Martin and pg_tileserv, so existing functions work unchanged:
42
+
43
+ ```sql
44
+ CREATE FUNCTION tiles.roads(z integer, x integer, y integer)
45
+ RETURNS bytea LANGUAGE sql STABLE PARALLEL SAFE AS $$
46
+ SELECT ST_AsMVT(t, 'roads', 4096, 'geom') FROM (
47
+ SELECT name, ST_AsMVTGeom(geom, ST_TileEnvelope(z, x, y), 4096, 64) AS geom
48
+ FROM roads
49
+ WHERE geom && ST_TileEnvelope(z, x, y, margin => 64.0 / 4096)
50
+ ) t WHERE geom IS NOT NULL
51
+ $$;
52
+
53
+ COMMENT ON FUNCTION tiles.roads(integer, integer, integer) IS
54
+ '{"description": "Road network", "minzoom": 4, "maxzoom": 16,
55
+ "vector_layers": [{"id": "roads", "fields": {"name": "String"}}]}';
56
+ ```
57
+
58
+ - **Metadata** is optional JSON in the function comment: `title`, `description`,
59
+ `attribution`, `minzoom`, `maxzoom` (≤ 24), `bounds` (WGS84 `[w, s, e, n]`; a
60
+ single-point extent is widened by 0.0001°), `vector_layers`. A plain-text comment
61
+ becomes the description.
62
+ - **Query parameters** reach the optional `query` argument as JSON, so
63
+ `/tiles/roads/10/512/340.mvt?kind=motorway` sees `{"kind": "motorway"}`.
64
+ - Mark functions `STABLE PARALLEL SAFE`, not `IMMUTABLE`, since they read tables.
65
+ - Use `&&` against an envelope **in the table's SRID**. Transforming the indexed column
66
+ (`ST_Transform(geom, 3857) && …`) disables the spatial index.
67
+ - Return an empty `bytea` for an empty tile; planisphere answers `204 No Content`.
68
+
69
+ `planisphere check` lists the layers it found.
70
+
71
+ The scripts in [`sql/`](sql) create their helpers in a `planisphere` schema. PostgreSQL's
72
+ default `search_path` is `"$user", public`, so a role named `planisphere` creates its
73
+ unqualified tables in that schema instead of `public`. Don't run application code as a
74
+ role called `planisphere`, or pin it:
75
+ `ALTER ROLE planisphere IN DATABASE <db> SET search_path = public;`.
76
+
77
+ ### Live layers
78
+
79
+ Create, change or drop a tile function and planisphere picks it up without a restart. Each
80
+ reload rediscovers layers and extensions and reloads the WMS style file, then swaps the whole
81
+ catalog atomically. In-flight requests finish on the catalog they started with. A reload that
82
+ fails (for example, a broken style) is logged and the current catalog keeps serving.
83
+
84
+ There are three ways to reload:
85
+
86
+ - **Automatically:** install [`sql/planisphere-reload.sql`](sql/planisphere-reload.sql). An event
87
+ trigger sends `NOTIFY planisphere_reload` on function, comment, grant and extension DDL.
88
+ planisphere listens on a dedicated connection, coalesces bursts (such as a migration) into
89
+ one reload, and catches up after reconnecting. Event triggers need a superuser; on
90
+ Amazon RDS, install it as the master user. Drop the trigger before a major-version
91
+ upgrade, and run DDL against the primary, since read replicas don't fire event triggers.
92
+ - `kill -HUP <pid>`, or `NOTIFY planisphere_reload` from any session.
93
+ - The MCP `reload` tool, which also reports what changed.
94
+
95
+ `/status` shows the catalog generation and when it was loaded.
96
+
97
+ ## Query functions
98
+
99
+ Request-shaped work that isn't a tile (routes, lookups, statistics) uses a second
100
+ contract, discovered the same way in `[functions] schemas` (default `["api"]`):
101
+
102
+ ```sql
103
+ fn(params jsonb) RETURNS jsonb
104
+ ```
105
+
106
+ - `GET /functions/{name}?a=1&b=2` passes `{"a": "1", "b": "2"}`, and
107
+ `POST /functions/{name}` passes the JSON body. `GET /functions` lists them.
108
+ - They follow the same rules as tile functions: `"public": true` in the comment, verified
109
+ claims set in the transaction (so row-level security applies), `statement_timeout`,
110
+ the circuit breaker, and hot reload.
111
+ - A `RAISE EXCEPTION` (or a data error) returns `400` with the function's message, for
112
+ example "no route between those points". Other failures return `500`.
113
+ - A result whose `type` is `FeatureCollection` is served as `application/geo+json`.
114
+
115
+ The demo fixtures route over a street grid with pgRouting
116
+ (`/functions/route?from=4.405,51.882&to=4.545,51.955`). They use only functions
117
+ present in both pgRouting 3.8 (Amazon RDS) and 4.0.
118
+
119
+ ## Authentication and tenant isolation
120
+
121
+ Each app signs its own users' tokens, planisphere verifies them, and PostgreSQL enforces
122
+ isolation. planisphere never sees your user database.
123
+
124
+ 1. **Your app mints a short-lived JWT** (5–15 minutes) after its own login. It must be
125
+ signed with an asymmetric key (`EdDSA`, `ES256`/`ES384`, `RS*`, `PS*`); `HS*` tokens
126
+ are always refused, because holding a verification key must never let anyone mint
127
+ tokens. Required claims are `iss`, `aud` (`planisphere`) and `exp`; `nbf` is checked
128
+ if present. Add `sub`, `tenant`, and anything your policies need, such as `roles` or
129
+ `nation`.
130
+ 2. **planisphere verifies it** against `[[auth.issuers]]`: a PEM public key, a JWKS file,
131
+ or a JWKS URL, with keys selected by `kid` for rotation. Key files are re-read on
132
+ `SIGHUP` or the MCP `reload` tool. A `jwks_url` is fetched at startup (planisphere
133
+ won't start if it can't be fetched), then refetched every `jwks_refresh_secs`
134
+ (default 300). A token naming an unknown `kid` triggers an early refetch, at most once
135
+ every 30 seconds, and gets a `401` until then. To rotate, publish the new key before
136
+ signing with it. A failed refresh keeps the last good keys. Use `https://`; plain
137
+ `http://` is only accepted for loopback addresses. Clients send
138
+ `Authorization: Bearer <token>`; with MapLibre, set it in `transformRequest`.
139
+ 3. **PostgreSQL enforces isolation.** planisphere runs each authenticated tile query in a
140
+ transaction with `request.jwt.claims` set to the verified claims. Install
141
+ [`sql/planisphere-auth.sql`](sql/planisphere-auth.sql) and write policies with its helpers:
142
+
143
+ ```sql
144
+ ALTER TABLE depots ENABLE ROW LEVEL SECURITY;
145
+ CREATE POLICY depots_tenant ON depots FOR SELECT USING (tenant = planisphere.tenant());
146
+ ```
147
+
148
+ Outside an authenticated request the helpers return `NULL`, so policies fail closed.
149
+
150
+ Rules that follow:
151
+
152
+ - A layer marked `"public": true` in its comment needs no token. Every other layer
153
+ returns `401` without a valid token, and an invalid token is always a `401`.
154
+ - Authenticated responses are `Cache-Control: private` with `Vary: Authorization`, so a
155
+ CDN never serves one tenant's tiles to another.
156
+ - **planisphere refuses to start** with auth on when its database role is a superuser or
157
+ `BYPASSRLS`, because row-level security would silently not apply. It also warns about
158
+ RLS tables the role owns without `FORCE ROW LEVEL SECURITY`, and about
159
+ `SECURITY DEFINER` tile functions, which run as their owner.
160
+ - `planisphere token --key k.pem --iss dev --tenant acme` mints a token for development.
161
+ `dev/keys.sh` creates a dev keypair.
162
+ - The MCP `probe_tile` tool takes `claims` (for example `{"tenant": "acme"}`) to show
163
+ what a tenant would see.
164
+
165
+ The demo fixtures include `tiles.depots`, protected by row-level security, with rows for
166
+ two tenants.
167
+
168
+ ## Live updates
169
+
170
+ Maps repaint when your data changes, without polling.
171
+
172
+ 1. Install [`sql/planisphere-changes.sql`](sql/planisphere-changes.sql) and attach its trigger
173
+ to the tables behind a layer, naming the layer and, optionally, the tenant column:
174
+
175
+ ```sql
176
+ CREATE TRIGGER depots_changed AFTER INSERT OR UPDATE OR DELETE ON depots
177
+ FOR EACH ROW EXECUTE FUNCTION planisphere.notify_layer_change('depots', 'tenant');
178
+ ```
179
+
180
+ Or call `planisphere.notify_change(layer, tenant, bbox)` yourself. Notifications go out
181
+ at `COMMIT`, so nothing is sent for a rollback. Identical ones collapse per transaction:
182
+ a bulk update costs one event per (layer, tenant), not one per row.
183
+ 2. Clients subscribe to `GET /events` (SSE), optionally with `?layers=a,b`. The stream
184
+ carries these events:
185
+ - `ready`;
186
+ - `change` (`{"layer", "bbox"?}`): refetch that layer, for example with MapLibre's
187
+ `map.refreshTiles(layer)`;
188
+ - `catalog`: layers were added or changed;
189
+ - `resync`: the client fell behind, so refetch everything;
190
+ - `expired`.
191
+ 3. Each event reaches only clients allowed to read the layer. A change with a `tenant`
192
+ reaches only that tenant, and events never carry tenant ids. `EventSource` cannot set
193
+ headers, so `/events` also accepts `?access_token=`. The stream closes when the token
194
+ expires; reconnect with a fresh one.
195
+
196
+ `/viewer` subscribes automatically; add `?token=<jwt>` to see protected layers.
197
+
198
+ ### Tile cache
199
+
200
+ Tiles are cached in memory (`tiles.cache_max_mb`, default 256 MB), keyed by layer, tile,
201
+ query and the caller's claims. Two users of one tenant with different roles never share
202
+ an entry, because row-level security may read any claim. Concurrent requests for the same
203
+ uncached tile share one database query.
204
+
205
+ - A `change` event refreshes that layer for the affected tenant. Other tenants keep
206
+ their cached tiles.
207
+ - A catalog reload (any function DDL) drops everything.
208
+ - `tiles.cache_ttl_secs` (default 1 hour) bounds staleness for data changed without a
209
+ trigger.
210
+
211
+ Invalidation bumps version counters rather than deleting entries, so a query that was
212
+ already running when the change committed can never serve its stale result to anyone
213
+ afterwards. `/status` shows the hit rate and size.
214
+
215
+ ## Extensions
216
+
217
+ PostGIS is the only requirement. planisphere also detects the established extensions it can
218
+ build on: h3, pgRouting, pgvector, pg_cron, pg_partman, pgTAP, hypopg, pg_repack and others.
219
+ Features use these rather than reimplementing them. Every extension on the list is available
220
+ on Amazon RDS for PostgreSQL 18, so nothing locks you out of managed Postgres (which is why
221
+ TimescaleDB is not on the list). `planisphere check`, `/status` and the MCP `status` tool show
222
+ what is installed, what could be, and version caveats. For example, pgRouting 4.x removed
223
+ `pgr_createTopology`, which RDS's 3.8 still has.
224
+
225
+ ## Endpoints
226
+
227
+ | Path | What |
228
+ |---|---|
229
+ | `/tiles` | layer list |
230
+ | `/tiles/{layer}.json` | TileJSON 3.0 |
231
+ | `/tiles/{layer}/{z}/{x}/{y}.mvt` | vector tile |
232
+ | `/fonts/{fontstack}/{start}-{end}.pbf` | MapLibre glyph range, generated from the label font |
233
+ | `/`, `/conformance`, `/api` | OGC API landing page, conformance classes, OpenAPI 3.0 |
234
+ | `/collections/{layer}/tiles/WebMercatorQuad/{tileMatrix}/{tileRow}/{tileCol}` | OGC API tile |
235
+ | `/tileMatrixSets/WebMercatorQuad` | tile matrix set definition (OGC 17-083r4) |
236
+ | `/wms` | WMS 1.3.0 / 1.1.1: `GetCapabilities`, `GetMap`, `GetFeatureInfo`, `GetLegendGraphic` |
237
+ | `/functions`, `/functions/{name}` | query functions (`GET` params or `POST` JSON) |
238
+ | `/events` | live `change` / `catalog` events (SSE), filtered per tenant |
239
+ | `/viewer` | MapLibre map of every layer, refreshed live |
240
+ | `/status` | live dashboard (Datastar over SSE) |
241
+ | `/metrics` | Prometheus metrics |
242
+ | `/health`, `/ready` | liveness; readiness is 503 while the database circuit is open |
243
+ | `/mcp` | admin MCP endpoint (see below) |
244
+
245
+ Errors are `application/problem+json`, except WMS, which returns OGC XML exception reports.
246
+
247
+ ## WMS
248
+
249
+ `GetMap` renders from the same tile functions: it fetches the tiles a MapLibre client would
250
+ load for the requested view, then draws them with [tiny-skia](https://github.com/linebender/tiny-skia).
251
+ Supported CRSs are `EPSG:3857`, `EPSG:4326` and `CRS:84`. For `EPSG:4326` the BBOX is lat,lon
252
+ in 1.3.0 and lon,lat in 1.1.1, as the specs require. `FORMAT` is `image/png`,
253
+ `image/png; mode=8bit` (palette PNG, also `image/png8`; usually a third of the size) or
254
+ `image/jpeg` (opaque: `TRANSPARENT` is ignored).
255
+
256
+ `GetFeatureInfo` fetches the one tile under the clicked pixel for each `QUERY_LAYERS`
257
+ entry, at the zoom `GetMap` used, so it's normally a cache hit. It reports the features
258
+ drawn there, topmost first:
259
+
260
+ - polygons that contain the pixel;
261
+ - lines and circles within their styled width plus 3 px.
262
+
263
+ Style filters apply, so hidden features are never reported. `INFO_FORMAT` is
264
+ `text/plain`, `text/html`, `text/xml`, `application/json` or `application/geo+json`;
265
+ the JSON forms include lon/lat geometry at tile resolution. `FEATURE_COUNT` is capped
266
+ at 50.
267
+
268
+ Styling uses a [MapLibre style](https://maplibre.org/maplibre-style-spec/) (`[wms] style`).
269
+ Each source name is a planisphere layer id, and `source-layer` is the MVT layer inside it.
270
+ Without a style file every layer gets an automatic one. The supported subset:
271
+
272
+ - **Layer types:** `background`, `fill`, `line`, `circle`, `symbol` (text and icons)
273
+ - **Expressions:** `get`, `has`, `geometry-type`, `zoom`, comparisons, `all`, `any`, `!`, `case`,
274
+ `match`, `coalesce`, `to-number`, `to-string`, `concat`, `upcase`, `downcase`, `step`,
275
+ `interpolate` (`linear`, `exponential`)
276
+
277
+ **Labels** come from `symbol` layers:
278
+
279
+ - **Text:** `text-field`, as an expression or a `"{name}"` template.
280
+ - **Layout:** `text-size`, `text-anchor`, `text-offset`, `text-transform`,
281
+ `text-max-width` (wraps at spaces), `text-allow-overlap`, `symbol-placement`,
282
+ `symbol-spacing` and `text-max-angle`.
283
+ - **Paint:** `text-color`, `text-opacity`, `text-halo-color` and `text-halo-width`.
284
+
285
+ Placement:
286
+
287
+ - Labels are placed after all geometry, topmost layer first, and a label that would
288
+ collide with one already placed is dropped.
289
+ - A label cut by the image edge is dropped too, so tiled WMS clients never show half
290
+ a label.
291
+ - With `symbol-placement: point` (the default), points are labelled at the point, lines
292
+ at their midpoint, polygons at the centroid of their largest part.
293
+ - With `symbol-placement: line`, labels follow lines and polygon outlines glyph by glyph,
294
+ repeating every `symbol-spacing` px (default 250). They always read left to right, and
295
+ vertical lines read bottom to top. A label is dropped where the line bends more than
296
+ `text-max-angle` (default 45°) between two glyphs, or where it's longer than the line.
297
+ `line-center` places one label at the middle of each line.
298
+
299
+ `text-font` is ignored. The bundled font is Noto Sans (Latin, Greek, Cyrillic; SIL OFL);
300
+ point `[wms] font` at another TrueType/OpenType file for other scripts. There is no text
301
+ shaping, so scripts that need it (Arabic, Indic) render unjoined.
302
+
303
+ **Icons** come from the style's `sprite`: a local path without extension, resolved
304
+ relative to the style file, naming `<path>.json` and `<path>.png` (MapLibre's sprite
305
+ format). Remote sprite URLs aren't fetched.
306
+
307
+ - **Properties:** `icon-image` (an expression, so `match` or `step` can pick an icon
308
+ per feature), `icon-size`, `icon-anchor`, `icon-offset`, `icon-opacity` and
309
+ `icon-allow-overlap`. SDF icons are tinted with `icon-color`.
310
+ - **With text:** a symbol can have an icon, text, or both. With both, they're placed
311
+ together, so both fit or neither is drawn.
312
+ - **Limits:** icons need `symbol-placement: point`.
313
+ - **Legends:** legends show icons, one row per class when `icon-image` depends on a
314
+ property.
315
+
316
+ **Glyphs for MapLibre clients:** planisphere serves the label font as MapLibre glyph
317
+ ranges, so client-side styles can draw text without a font CDN:
318
+
319
+ ```json
320
+ "glyphs": "https://maps.example.com/fonts/{fontstack}/{range}.pbf"
321
+ ```
322
+
323
+ Every fontstack name gets the same font: `[wms] font`, else the bundled Noto Sans.
324
+ The glyphs are signed distance fields made the way MapLibre's TinySDF makes them.
325
+ Each range is rendered once and cached for the life of the process.
326
+
327
+ Unsupported style syntax is rejected at startup, naming the layer. See
328
+ [`dev/style.json`](dev/style.json) for an example.
329
+
330
+ `GetLegendGraphic` draws one row per style layer of `LAYER`, with a swatch in its own
331
+ paint. A colour driven by a property (`interpolate`, `step` or `match` over `get`) becomes
332
+ one row per stop or category, such as `< 5,000,000` or `motorway, trunk`. `SCALE` picks
333
+ the zoom; otherwise the legend is drawn at zoom 10. `WIDTH`/`HEIGHT` size the swatches,
334
+ and `TRANSPARENT` and `BGCOLOR` work as in `GetMap`. Each layer's style advertises its
335
+ legend through `LegendURL`, which QGIS and web clients pick up.
336
+
337
+
338
+ ## Configuration
339
+
340
+ `planisphere -c <file>` (or `$PLANISPHERE_CONFIG`, default `planisphere.toml`).
341
+ [`planisphere.example.toml`](planisphere.example.toml) documents every option. The main ones:
342
+
343
+ | Key | Default | |
344
+ |---|---|---|
345
+ | `listen` | (required) | address to bind |
346
+ | `public_url` | from `Host` | base URL used in links |
347
+ | `database.url` | (required) | overridden by `$PLANISPHERE_DATABASE_URL` |
348
+ | `database.pool_size` | 8 | |
349
+ | `database.statement_timeout_ms` | 10000 | per connection; 0 disables |
350
+ | `database.connect_attempts` | 10 | `serve` retries startup with exponential backoff |
351
+ | `database.breaker.*` | 5 failures / 30 s, open 10 s | trips only on database-health errors |
352
+ | `tiles.queue_target_ms`, `queue_interval_ms` | 50, 500 | shed tile queries once waits stay above target for a whole interval |
353
+ | `tiles.queue_max_wait_ms` | 2000 | longest any tile query waits for a slot |
354
+ | `tiles.schemas` | `["public"]` | where tile functions are discovered |
355
+ | `rate_limit.requests_per_sec`, `burst` | 50, 100 | per client IP; 0 disables |
356
+ | `rate_limit.trust_x_forwarded_for` | false | enable only behind a proxy that sets it |
357
+ | `wms.style` | automatic | MapLibre style file, relative to the config file |
358
+ | `wms.max_size`, `max_tiles` | 4096, 64 | GetMap limits |
359
+ | `wms.font` | bundled Noto Sans | Label font (TrueType/OpenType), relative to the config file |
360
+ | `mcp.enabled`, `token_env` | true, `PLANISPHERE_MCP_TOKEN` | |
361
+
362
+ `RUST_LOG` controls logging (default `info`).
363
+
364
+ ### Database TLS
365
+
366
+ TLS is configured with libpq parameters in `database.url`, which mean what libpq says
367
+ they mean:
368
+
369
+ | `sslmode` | encrypted | server verified |
370
+ |---|---|---|
371
+ | `disable` | no | – |
372
+ | `prefer` (default) | if the server offers it | no |
373
+ | `require` | yes | no, unless `sslrootcert` is set (then like `verify-ca`) |
374
+ | `verify-ca` | yes | certificate chain |
375
+ | `verify-full` | yes | chain and host name |
376
+
377
+ `sslrootcert` names the CA file; without it, the system trust store is used.
378
+ `sslcert` and `sslkey` enable client certificates. For Amazon RDS, download the
379
+ [RDS CA bundle](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html)
380
+ and use `?sslmode=verify-full&sslrootcert=/path/to/global-bundle.pem`. Every
381
+ certificate in the bundle is trusted. planisphere logs a warning whenever the server
382
+ certificate is not verified.
383
+
384
+ ### RDS IAM authentication
385
+
386
+ With `database.iam_auth = true`, planisphere authenticates to RDS with IAM instead of a
387
+ stored password. It needs TLS (`sslmode=require` or stricter) and a database user granted
388
+ `rds_iam`:
389
+
390
+ ```toml
391
+ [database]
392
+ url = "postgres://planisphere_reader@mydb.abc123.eu-west-1.rds.amazonaws.com/gis?sslmode=verify-full&sslrootcert=/etc/ssl/rds-global-bundle.pem"
393
+ iam_auth = true
394
+ region = "eu-west-1" # else $AWS_REGION / $AWS_DEFAULT_REGION
395
+ ```
396
+
397
+ ```sql
398
+ CREATE ROLE planisphere_reader LOGIN;
399
+ GRANT rds_iam TO planisphere_reader;
400
+ ```
401
+
402
+ - **Tokens:** each new connection, pooled or the `LISTEN` one, signs a 15-minute token
403
+ (the same token `aws rds generate-db-auth-token` prints). A token is reused for up to
404
+ 10 minutes.
405
+ - **Credentials:** found the way the AWS SDKs find them:
406
+ - `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` (/ `AWS_SESSION_TOKEN`);
407
+ - EKS IRSA (`AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN`, exchanged with STS);
408
+ - the ECS task role or EKS Pod Identity endpoint;
409
+ - EC2 instance metadata (IMDSv2).
410
+ - **Caching and limits:** temporary credentials are cached until five minutes before
411
+ they expire. Profiles and SSO aren't read, and no AWS SDK is linked.
412
+ - **The IAM policy** needs `rds-db:connect` on
413
+ `arn:aws:rds-db:<region>:<account>:dbuser:<resource id>/planisphere_reader`.
414
+
415
+ ## Metrics
416
+
417
+ `/metrics` serves Prometheus text format. It is not rate-limited and needs no token. It
418
+ includes:
419
+
420
+ - tile requests by outcome;
421
+ - cache hits, misses, entries and bytes;
422
+ - circuit state and pool use;
423
+ - open event streams and delivered events, and whether the `LISTEN` connection behind them
424
+ and hot reload is up (`planisphere_listener_connected`);
425
+ - catalog reloads and generation;
426
+ - `planisphere_tile_db_seconds{layer}`, a histogram of database time per layer (cache
427
+ misses only).
428
+
429
+ ### Admission control
430
+
431
+ How many tile queries run at once is learned, not configured
432
+ ([shed-machines](https://crates.io/crates/shed-machines)):
433
+
434
+ - **The limit** starts at one query per pooled connection. It grows by one each time a
435
+ query succeeds with every slot busy, up to `database.breaker.max_concurrency`
436
+ (default 4 per pooled connection). It drops to three quarters on overload: a pool
437
+ wait timeout, a statement timeout, or a lost connection. Latency alone doesn't move
438
+ it, because layers legitimately differ a hundredfold.
439
+ - **Requests over the limit wait** instead of failing, because a map view asks for dozens
440
+ of tiles at once and MapLibre doesn't retry a refused tile. They're served in arrival
441
+ order, so a new request never overtakes one already waiting. CoDel watches those waits:
442
+ a burst passes, but once waits stay above `tiles.queue_target_ms` for a whole
443
+ `tiles.queue_interval_ms`, requests are shed with `503` and `Retry-After`. No request
444
+ waits longer than `tiles.queue_max_wait_ms`.
445
+ - **Visibility:** `/status` and `/metrics` show the limit, the queries running and the
446
+ count shed (`planisphere_tile_concurrency_limit`,
447
+ `planisphere_tile_queries_in_flight`, `planisphere_tiles_total{outcome="shed"}`).
448
+
449
+ Tile queries slower than `tiles.slow_ms` (default 1000 ms) are logged with the layer and
450
+ z/x/y.
451
+
452
+ ## Admin MCP endpoint
453
+
454
+ `/mcp` serves [MCP](https://modelcontextprotocol.io) tools built on
455
+ [mcp-host](https://crates.io/crates/mcp-host):
456
+
457
+ | Tool | |
458
+ |---|---|
459
+ | `status` | the `/status` snapshot: circuit, pool, counters, layers, extensions |
460
+ | `list_layers`, `describe_layer` | layer metadata, including the function's SQL source |
461
+ | `probe_tile` | render one tile through the circuit breaker and time it |
462
+ | `hot_tiles` | the most requested tiles since start (Space-Saving heavy hitters over 64 counters, with each count's possible overestimate): candidates to pre-render or cache longer |
463
+ | `layer_stats` | per-layer tile timings (count, mean, t-digest p50/p95/p99, max) beside `pg_stat_statements` for the same query (calls, mean, stddev, max, rows, cache hit ratio), slowest first. The database side needs `pg_stat_statements`, which RDS preloads by default |
464
+ | `diagnose_layer` | a behavior tree: describe the layer, probe its centre tile at min/mid/max zoom in parallel, then ask the client's model for an assessment (skipped if the client can't sample) |
465
+ | `reload` | rediscover layers and extensions now, and report what changed |
466
+
467
+ On a loopback listener no token is needed. On any other address planisphere refuses to start
468
+ without a bearer token in `$PLANISPHERE_MCP_TOKEN` (or set `mcp.enabled = false`).
469
+
470
+ ```sh
471
+ claude mcp add --transport http planisphere http://127.0.0.1:8080/mcp
472
+ ```
473
+
474
+ ## Ruby
475
+
476
+ The `planisphere` gem ships the precompiled server for Ruby and Rails apps. Platform gems
477
+ exist for `arm64-darwin` and `aarch64-linux` (glibc 2.17+); other platforms follow.
478
+ Bundler picks the right one.
479
+
480
+ ```ruby
481
+ # Gemfile
482
+ gem 'planisphere'
483
+ ```
484
+
485
+ ```sh
486
+ bundle exec planisphere -c config/planisphere.toml serve
487
+ ```
488
+
489
+ `Planisphere.executable` returns the binary's path, for Procfiles or process supervisors.
490
+ On other platforms, install the server with `cargo install planisphere` and set
491
+ `PLANISPHERE_EXECUTABLE` to it.
492
+
493
+ ## Performance
494
+
495
+ `examples/load.rs` drives keep-alive HTTP/1.1 connections at random tiles of one layer:
496
+
497
+ ```sh
498
+ cargo run --release --example load -- http://127.0.0.1:8080 cities 0-6 32 10
499
+ ```
500
+
501
+ Indicative numbers: an 8-core Apple M1, PostgreSQL 18 in Docker on the same machine, 32
502
+ connections, a 16-connection pool, rate limiting off.
503
+
504
+ | layer | tile cache | throughput | p50 | p99 |
505
+ |---|---|---|---|---|
506
+ | `cities` (points), z0–6 | off | 17,000 req/s | 1.7 ms | 3.9 ms |
507
+ | `hexbins` (generated polygons), z0–5 | off | 1,000 req/s | 31 ms | 51 ms |
508
+ | `cities` | on | 129,000 req/s | 0.23 ms | 0.53 ms |
509
+ | `hexbins` | on | 93,000 req/s | 0.27 ms | 0.58 ms |
510
+
511
+ Uncached throughput is whatever the tile functions allow: `hexbins` builds its hexagons per
512
+ request. The MCP `layer_stats` tool shows which layer spends where.
513
+
514
+ ## Development
515
+
516
+ ```sh
517
+ cargo test
518
+ cargo clippy --all-targets
519
+ bundle exec rake test # gem stub
520
+ bundle exec rake native:build # platform gem for this machine, into pkg/
521
+ dev/cite-tiles.sh # OGC API - Tiles conformance suite (Docker, amd64 image)
522
+ dev/cite-wms.sh # WMS 1.3.0 suite: loads the CITE dataset into schema `cite`, runs it
523
+ ```
524
+
525
+ ### Fault injection
526
+
527
+ A debug build with the `chaos` feature injects faults into tile queries, for hardening
528
+ drills ([rogue-machines](https://crates.io/crates/rogue-machines)). Release builds refuse
529
+ the feature, so it never reaches a binary or a gem.
530
+
531
+ ```sh
532
+ cargo run --features chaos -- -c planisphere.example.toml serve
533
+ ```
534
+
535
+ It adds three MCP tools (MCP must be enabled):
536
+
537
+ | Tool | |
538
+ |---|---|
539
+ | `chaos_arm` | for `seconds` (at most 3600), make `percent` of one layer's tile queries `raise` (fail like a lost connection), `delay` (run `delay_ms` late, holding their slot) or `garble` (succeed with a body that isn't a tile) |
540
+ | `chaos_disarm` | end one layer's experiment, or all of them |
541
+ | `chaos_status` | live experiments, with what is left of each |
542
+
543
+ Faults run inside the admission gate and the circuit breaker, so they get the same
544
+ reaction a real fault would: five `raise` failures open the circuit for every layer, and a
545
+ `delay` longer than the queue target makes CoDel shed. A garbled tile is cached like a
546
+ real one until it is evicted or the catalog reloads. `examples/load.rs` drives the traffic.
data/exe/planisphere ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'planisphere'
5
+
6
+ begin
7
+ exec(Planisphere.executable, *ARGV)
8
+ rescue Planisphere::UnsupportedPlatform => e
9
+ warn e.message
10
+ exit 1
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planisphere
4
+ # Platform gems published for each release: RubyGems platform => Rust target.
5
+ # Trimmed to the fleet's hosts while the gem is proven out; the full list
6
+ # (x86_64 darwin/linux, musl, amd64-freebsd) returns afterwards.
7
+ PLATFORMS = {
8
+ 'arm64-darwin' => 'aarch64-apple-darwin',
9
+ 'aarch64-linux' => 'aarch64-unknown-linux-gnu'
10
+ }.freeze
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planisphere
4
+ VERSION = '0.1.2' # x-release-please-version
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'planisphere/version'
4
+ require_relative 'planisphere/platforms'
5
+
6
+ # Ruby packaging of the planisphere map server. Platform gems carry the compiled
7
+ # server in libexec/; this module only finds it.
8
+ module Planisphere
9
+ class UnsupportedPlatform < StandardError; end
10
+
11
+ BUNDLED = File.expand_path('../libexec/planisphere', __dir__)
12
+
13
+ # Path of the server binary: $PLANISPHERE_EXECUTABLE if set, else the one bundled
14
+ # in this platform gem.
15
+ def self.executable(bundled: BUNDLED)
16
+ override = ENV.fetch('PLANISPHERE_EXECUTABLE', nil)
17
+ return override if override && !override.empty?
18
+ return bundled if File.executable?(bundled)
19
+
20
+ raise UnsupportedPlatform, <<~MESSAGE.chomp
21
+ planisphere #{VERSION} has no precompiled binary for #{Gem::Platform.local}.
22
+ Supported: #{PLATFORMS.keys.join(', ')}.
23
+ Otherwise install the server with `cargo install planisphere` and set PLANISPHERE_EXECUTABLE.
24
+ MESSAGE
25
+ end
26
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: planisphere
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2
5
+ platform: ruby
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Ships the precompiled planisphere server for use from Ruby and Rails
13
+ apps.
14
+ email:
15
+ - terminale@gmail.com
16
+ executables:
17
+ - planisphere
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE
22
+ - README.md
23
+ - exe/planisphere
24
+ - lib/planisphere.rb
25
+ - lib/planisphere/platforms.rb
26
+ - lib/planisphere/version.rb
27
+ homepage: https://github.com/seuros/planisphere
28
+ licenses:
29
+ - MIT
30
+ metadata:
31
+ homepage_uri: https://github.com/seuros/planisphere
32
+ source_code_uri: https://github.com/seuros/planisphere
33
+ changelog_uri: https://github.com/seuros/planisphere/blob/master/CHANGELOG.md
34
+ rubygems_mfa_required: 'true'
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 4.0.0
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 4.0.20
50
+ specification_version: 4
51
+ summary: 'Headless PostGIS map server: vector tiles, OGC API Tiles and WMS'
52
+ test_files: []