pmtiles-swarm 0.8.0 → 0.9.0

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.9.0
11
+ ### ✨ Features and improvements
12
+ - **`pmtiles-swarm status`**, which asks a running node what it is doing and reads the answer out
13
+ loud. It takes the same config file the node runs with, so the address, the port and the
14
+ credential come from one place rather than being remembered and retyped. That is the whole
15
+ point of it: the API is on `adminPort` rather than the public port, the node binds where `host`
16
+ says and that is usually not loopback, and it accepts `authorization: Bearer` and not
17
+ `x-api-key`. Get any one of those wrong by hand and the answer is a refused connection or a 401,
18
+ both of which read as a broken node rather than as a mistyped command — which is exactly how
19
+ they were read while diagnosing the archive fixed below.
20
+
21
+ It names the case that is otherwise silent: an archive the catalog holds and the engine does
22
+ not, which through `curl` is a row of empty columns and looks like a corrupt archive. Just after
23
+ a start it is normal and passes; persisting, the engine refused it and the log says why. Exits
24
+ non-zero when the node does not answer or its engine is down, so it can be the last step of a
25
+ deployment script, and `--json` hands back the raw replies for anything that would rather parse.
26
+
27
+ Also warns when `--config` names a file that is not there. Startup ignores that on purpose, so
28
+ a first run can write one — but for a question about a running node the silence is
29
+ misleading, since the answer then describes the default address and looks entirely real.
30
+
31
+ - **[docs/haproxy.md](docs/haproxy.md) now covers the backend pool**: why round robin rather than
32
+ the plugin's default of Source-IP Hash, which fails quietly behind a CDN by pinning nearly all
33
+ traffic to one node while the rest sit idle and healthy; when least-connections or URI hash are
34
+ worth having instead; and what HTTP/2 on the frontend does and does not change about balancing.
35
+
36
+ ### 🐞 Bug fixes
37
+ - **An archive built from a watched folder no longer sits at 0%, seeding nobody, for a quarter of
38
+ an hour.** The libtorrent engine dropped `seedOnly` on its way to the sidecar, so libtorrent
39
+ re-hashed an 81 GiB archive that had been read end to end moments earlier to produce its
40
+ torrent. Everything else already handled it — the library sets it in five places, the
41
+ composite engine checks it against what the primary reports, qBittorrent has its own flag for
42
+ it — and this one engine silently did not pass it on. Needs pmtiles-torrent 0.4.1, which the
43
+ existing dependency range picks up on a fresh install.
44
+ - **`docs/running-as-a-service.md` no longer suggests checking a node with `curl localhost:8091`.**
45
+ It names loopback and sends no credential, so on a node bound to its LAN address with a key
46
+ configured it fails twice over, in the two ways that look most like a broken node. It now uses
47
+ the status command.
48
+
10
49
  ## 0.8.0
11
50
  ### ✨ Features and improvements
12
51
  - **`GET /health`**, for a load balancer: 200 when this node can serve, 503 when its engine
package/README.md CHANGED
@@ -589,6 +589,38 @@ network equipment: peers request 16 KiB blocks whatever the piece size. The sett
589
589
  matters there is `maxConnections`, since every peer holds a NAT table entry. See
590
590
  [docs/publishing.md](docs/publishing.md).
591
591
 
592
+ ## Asking a running node what it is doing
593
+
594
+ ```sh
595
+ node src/index.js status --config /etc/pmtiles-swarm/swarm.config.json
596
+ ```
597
+
598
+ ```
599
+ engine libtorrent ready
600
+ version 0.8.0
601
+ 17 archives, 1 the engine does not know about
602
+
603
+ NAME SIZE STATE PROGRESS
604
+ planetiler-openmaptiles-260803.pmtiles 81 GiB seeding 100%
605
+ planet-260803.osm.pbf 94 GiB downloading 37%
606
+ planetiler-openmaptiles-260810.pmtiles 83 GiB — —
607
+ ```
608
+
609
+ It reads the same config file the node runs with, so the address, the port and the credential come
610
+ from one place rather than being remembered and retyped. That matters more than it sounds: the API
611
+ is on `adminPort`, not the public port; the node binds where `host` says, which is usually not
612
+ loopback; and it accepts `authorization: Bearer`, not `x-api-key`. Get any one of those wrong with
613
+ `curl` and the answer is a refused connection or a 401 — both of which read as a broken node rather
614
+ than a mistyped command.
615
+
616
+ An archive with a state of `—` is one the catalog holds and the engine is not. Directly after a
617
+ restart that is normal and passes. Persisting, it means the engine refused it, and the log says
618
+ why.
619
+
620
+ Exit status is 0 when the node answered and its engine is up, 1 when it did not or is not — so it
621
+ works in a deployment script. `--json` gives the raw `/api/status` and `/api/torrents` replies for
622
+ anything that wants to parse rather than read.
623
+
592
624
  ## API
593
625
 
594
626
  | Method | Path | Purpose |
package/docs/haproxy.md CHANGED
@@ -81,6 +81,47 @@ more often.
81
81
  Note also that the node comes *back* on `rise` successful checks — 2 by
82
82
  default — so a flapping node re-enters rotation quickly whichever you choose.
83
83
 
84
+ ## The backend pool
85
+
86
+ **Settings → Backend Pools → Add**, mode **HTTP (Layer 7)**.
87
+
88
+ ### Balancing algorithm
89
+
90
+ **Round Robin.** Tile requests are stateless, numerous and roughly the same
91
+ size, which is the case round robin is for.
92
+
93
+ **Not Source-IP Hash, even though it is the default here.** It exists for
94
+ sticky sessions, and tiles have no session to be sticky about. Worse, it fails
95
+ quietly in exactly the setup this document assumes: behind a CDN every request
96
+ arrives from a handful of edge addresses, so hashing on the source pins almost
97
+ all traffic to one node while the others sit idle and healthy. A NAT'd office
98
+ or a mobile carrier does the same thing on a smaller scale.
99
+
100
+ Two others are worth knowing about, for arrangements this is not:
101
+
102
+ **Least Connections**, when long transfers share the backend with short ones.
103
+ A web-seed range request can run for minutes while a tile takes milliseconds,
104
+ and round robin will happily queue tiles behind a transfer. If web seeds are
105
+ served from a different host — an ordinary web server in front of the published
106
+ directory, which is the usual arrangement — that variance is not here and round
107
+ robin is simpler.
108
+
109
+ **URI Hash**, for a tier of **cache-mode** nodes. Such a node holds only the
110
+ pieces it has read, so sending the same region to different nodes makes each of
111
+ them pay the cold read separately — the one real cost of scaling a cache-mode
112
+ tier horizontally. `balance uri depth 2` hashes on `/archives/<infohash>` and
113
+ gives archive affinity, at the price of concentrating one archive on one node.
114
+ For nodes holding complete copies there is no cold read to avoid, so this is a
115
+ cost with no benefit.
116
+
117
+ ### HTTP/2
118
+
119
+ Enable it on the frontend and leave *HTTP/2 without TLS* unchecked: the client
120
+ gets HTTP/2, the node is spoken to over HTTP/1.1, which is what it speaks.
121
+ Balancing in HTTP mode is per request rather than per connection, so a client
122
+ multiplexing a hundred tile requests over one HTTP/2 connection still has them
123
+ spread across the pool.
124
+
84
125
  ## Real servers
85
126
 
86
127
  **Settings → Real Servers → Add**, one per node, port **8090**.
@@ -441,9 +441,23 @@ Then check it is actually serving:
441
441
 
442
442
  ```sh
443
443
  curl -fsS localhost:8090/feed.xml >/dev/null && echo "public surface ok"
444
- curl -fsS localhost:8091/api/status | head -c 200
444
+ node /opt/pmtiles-swarm/src/index.js status \
445
+ --config /etc/pmtiles-swarm/swarm.config.json
445
446
  ```
446
447
 
448
+ Ask through the status command rather than with `curl`. Reaching the API by hand
449
+ means getting the bind address, the admin port and the credential right in one
450
+ go, and each of them fails in a way that looks like a broken node: a node bound
451
+ to its LAN address refuses a request to `localhost`, and the header it accepts
452
+ is `authorization: Bearer`, so anything else is a 401. The status command reads
453
+ all three out of the config file the service is running with. It exits non-zero
454
+ when the node does not answer or its engine is down, so it also works as the
455
+ last step of a deployment script.
456
+
457
+ An archive listed with a state of `—` is one the catalog holds and the engine is
458
+ not. Just after a start that is normal and passes within a minute or so. If it
459
+ persists, the engine refused it, and the journal says why.
460
+
447
461
  And that it can write where it is supposed to, which nothing above proves:
448
462
 
449
463
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -254,6 +254,12 @@ export class LibtorrentEngine {
254
254
  savePath: request.savePath ?? this.#options.savePath,
255
255
  mode: request.mode,
256
256
  paused: request.paused,
257
+ // The caller's claim that the data is already on disk, which for an
258
+ // archive created here it is: the file was read end to end a moment ago
259
+ // to produce the torrent. Without passing it on, libtorrent hashes the
260
+ // whole archive again before seeding a byte — a quarter of an hour for
261
+ // an 81 GiB build, during which it reads as 0% and serves nobody.
262
+ seedOnly: request.seedOnly,
257
263
  });
258
264
  return result.infoHash;
259
265
  }
package/src/index.js CHANGED
@@ -92,20 +92,26 @@ function createOneEngine(name, config) {
92
92
  * @returns {Promise<void>} - Resolves once listening.
93
93
  */
94
94
  async function main() {
95
- const { values } = parseArgs({
95
+ const { values, positionals } = parseArgs({
96
96
  options: {
97
97
  config: { type: 'string', short: 'c' },
98
98
  port: { type: 'string', short: 'p' },
99
99
  help: { type: 'boolean', short: 'h' },
100
+ json: { type: 'boolean' },
100
101
  },
101
- allowPositionals: false,
102
+ allowPositionals: true,
102
103
  });
103
104
 
104
105
  if (values.help) {
105
106
  console.log(`pmtiles-swarm — BitTorrent distribution for PMTiles archives
106
107
 
108
+ Usage:
109
+ pmtiles-swarm [--config FILE] start the node
110
+ pmtiles-swarm status [--config FILE] ask a running node what it is doing
111
+
107
112
  --config, -c path to a JSON config file
108
113
  --port, -p override the listen port
114
+ --json machine-readable output, for the status command
109
115
  --help, -h this message
110
116
 
111
117
  Environment: PMTILES_SWARM_PORT, PMTILES_SWARM_DATA_DIR, PMTILES_SWARM_ENGINE,
@@ -118,6 +124,23 @@ PMTILES_SWARM_PUBLIC_URL
118
124
  const config = await loadConfig(values.config);
119
125
  if (values.port) config.port = Number(values.port);
120
126
 
127
+ // Asking rather than starting. Everything it needs — which address the admin
128
+ // listener is on, which port, and the credential — comes from the same
129
+ // configuration the node runs with, so there is nothing to pass and nothing
130
+ // to get wrong.
131
+ if (positionals[0] === 'status') {
132
+ const { runStatus } = await import('./status-command.js');
133
+ process.exitCode = await runStatus(config, { json: values.json });
134
+ return;
135
+ }
136
+
137
+ if (positionals.length > 0) {
138
+ console.error(`unknown command: ${positionals[0]}`);
139
+ console.error('try: pmtiles-swarm status');
140
+ process.exitCode = 2;
141
+ return;
142
+ }
143
+
121
144
  // Everything that has to be stopped, in the order it should be stopped,
122
145
  // filled in as startup proceeds.
123
146
  //
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `pmtiles-swarm status` — asking a running node what it is doing.
3
+ *
4
+ * This exists because interrogating a node meant getting four separate things
5
+ * right at once: which address the admin listener is bound to, which port,
6
+ * which header carries the credential, and where in the JSON the answer lives.
7
+ * Getting any one wrong produces something that looks like a broken archive —
8
+ * a refused connection, a 401, or a row of nulls — rather than like a mistyped
9
+ * command. Every one of those is derivable from the configuration file the
10
+ * node is already running with, so nothing here needs to be passed or
11
+ * remembered.
12
+ */
13
+
14
+ import { access } from 'node:fs/promises';
15
+
16
+ /**
17
+ * Whether a path can be read.
18
+ * @param {string} path - The path.
19
+ * @returns {Promise<boolean>} - True when it is there.
20
+ */
21
+ async function readable(path) {
22
+ try {
23
+ await access(path);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** Columns, and how wide the name column may grow before it is cut. */
31
+ const NAME_WIDTH = 44;
32
+
33
+ /**
34
+ * A size in bytes, as a person would write it.
35
+ * @param {number} value - Bytes.
36
+ * @returns {string} - e.g. "81 GiB".
37
+ */
38
+ export function bytes(value) {
39
+ if (!Number.isFinite(value) || value <= 0) return '—';
40
+ const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
41
+ let size = value;
42
+ let unit = 0;
43
+ while (size >= 1024 && unit < units.length - 1) {
44
+ size /= 1024;
45
+ unit += 1;
46
+ }
47
+ return `${size < 10 && unit > 0 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`;
48
+ }
49
+
50
+ /**
51
+ * Where this node's API is, according to its own configuration.
52
+ *
53
+ * The admin listener where there is one, since that is where the API lives on
54
+ * a node that separates them. A wildcard bind is reported as loopback: `::` is
55
+ * what the node listens on, not an address anything can connect to.
56
+ * @param {object} config - Resolved configuration.
57
+ * @returns {string} - An origin, e.g. "http://172.16.1.49:8091".
58
+ */
59
+ export function adminUrl(config) {
60
+ const host = config.adminHost ?? config.host ?? '127.0.0.1';
61
+ const port = config.adminPort ?? config.port ?? 8090;
62
+ const reachable =
63
+ host === '0.0.0.0' || host === '::' || host === '' ? '127.0.0.1' : host;
64
+ // A bare IPv6 address needs brackets before it is a URL.
65
+ const bracketed =
66
+ reachable.includes(':') && !reachable.startsWith('[')
67
+ ? `[${reachable}]`
68
+ : reachable;
69
+ return `http://${bracketed}:${port}`;
70
+ }
71
+
72
+ /**
73
+ * The header a request to this node's API needs, if any.
74
+ *
75
+ * `authorization: Bearer`, which is the only form the node accepts — not
76
+ * `x-api-key`, whatever the convention elsewhere.
77
+ * @param {object} config - Resolved configuration.
78
+ * @returns {object} - Headers to send.
79
+ */
80
+ export function authHeaders(config) {
81
+ const key = config.auth?.apiKey;
82
+ return key ? { authorization: `Bearer ${key}` } : {};
83
+ }
84
+
85
+ /**
86
+ * One line per archive, plus what the engine says about the node.
87
+ * @param {object} answer - `{ status, torrents }` as the API returned them.
88
+ * @returns {string} - The report.
89
+ */
90
+ export function formatStatus({ status, torrents }) {
91
+ const lines = [];
92
+ const engine = status?.engine;
93
+ lines.push(
94
+ `engine ${engine?.name ?? 'unknown'}` +
95
+ (engine?.ok === false ? ` UNAVAILABLE — ${engine.error ?? ''}` : ' ready'),
96
+ );
97
+ if (status?.version) lines.push(`version ${status.version}`);
98
+
99
+ const rows = torrents ?? [];
100
+ // An archive the engine has never heard of is the case worth naming. It is
101
+ // in the catalog, it has a size, and every live column is empty — which
102
+ // reads as a broken archive and is usually a node that has not finished
103
+ // starting, or one that could not add it.
104
+ const unknown = rows.filter((row) => !row.status).length;
105
+ lines.push(
106
+ `${rows.length} archive${rows.length === 1 ? '' : 's'}` +
107
+ (unknown > 0 ? `, ${unknown} the engine does not know about` : ''),
108
+ );
109
+ lines.push('');
110
+
111
+ if (rows.length === 0) return `${lines.join('\n')}\n`;
112
+
113
+ const head =
114
+ 'NAME'.padEnd(NAME_WIDTH) +
115
+ 'SIZE'.padStart(9) +
116
+ ' ' +
117
+ 'STATE'.padEnd(12) +
118
+ 'PROGRESS'.padStart(8);
119
+ lines.push(head);
120
+
121
+ for (const row of rows) {
122
+ const name =
123
+ row.name.length > NAME_WIDTH - 1
124
+ ? `${row.name.slice(0, NAME_WIDTH - 2)}…`
125
+ : row.name;
126
+ const state = row.status?.state ?? (row.paused ? 'paused' : '—');
127
+ const progress =
128
+ typeof row.status?.progress === 'number'
129
+ ? `${Math.round(row.status.progress * 100)}%`
130
+ : '—';
131
+ lines.push(
132
+ name.padEnd(NAME_WIDTH) +
133
+ bytes(row.size).padStart(9) +
134
+ ' ' +
135
+ String(state).padEnd(12) +
136
+ progress.padStart(8),
137
+ );
138
+ }
139
+
140
+ if (unknown > 0) {
141
+ lines.push('');
142
+ lines.push(
143
+ 'An archive with no state is one the engine is not holding. If the node',
144
+ );
145
+ lines.push(
146
+ 'has just started it may still be handing them back; if it persists, the',
147
+ );
148
+ lines.push('log will say why it could not be added.');
149
+ }
150
+
151
+ return `${lines.join('\n')}\n`;
152
+ }
153
+
154
+ /**
155
+ * Asks a running node for its status and prints it.
156
+ * @param {object} config - Resolved configuration.
157
+ * @param {object} [options] - Injectable fetch and output, for testing.
158
+ * @returns {Promise<number>} - The exit code.
159
+ */
160
+ export async function runStatus(config, options = {}) {
161
+ const {
162
+ fetch: get = globalThis.fetch,
163
+ out = (text) => process.stdout.write(text),
164
+ err = (text) => process.stderr.write(text),
165
+ json = false,
166
+ } = options;
167
+
168
+ // A named config file that is not there is silently ignored on startup, so
169
+ // that a first run can write one. Here that silence is misleading: a typo in
170
+ // --config means this reports on the default address with no key, which is a
171
+ // different node from the one that was asked about, and the answer looks
172
+ // real. Say it, rather than letting it be discovered later.
173
+ if (config.configPath && !(await readable(config.configPath))) {
174
+ err(
175
+ `no config file at ${config.configPath} — using defaults, ` +
176
+ 'which is probably not the node you meant.\n',
177
+ );
178
+ }
179
+
180
+ const base = adminUrl(config);
181
+ const headers = authHeaders(config);
182
+
183
+ let status;
184
+ let torrents;
185
+ try {
186
+ const [statusReply, torrentsReply] = await Promise.all([
187
+ get(`${base}/api/status`, { headers }),
188
+ get(`${base}/api/torrents`, { headers }),
189
+ ]);
190
+
191
+ // Said plainly, because a 401 here means the key in this configuration is
192
+ // not the key the node is running with — which is a different problem from
193
+ // the node being down, and looks identical without being told.
194
+ if (statusReply.status === 401 || statusReply.status === 403) {
195
+ err(
196
+ `${base} refused the credential in this configuration file.\n` +
197
+ 'The node is running, but with a different auth.apiKey.\n',
198
+ );
199
+ return 1;
200
+ }
201
+ if (!statusReply.ok) {
202
+ err(`${base}/api/status answered ${statusReply.status}\n`);
203
+ return 1;
204
+ }
205
+
206
+ status = await statusReply.json();
207
+ torrents = torrentsReply.ok ? await torrentsReply.json() : [];
208
+ } catch (error) {
209
+ // A refused connection is the commonest failure and the least obvious: the
210
+ // node binds where the configuration says, which is often not loopback.
211
+ // Node's fetch reports every one of them as "fetch failed" and puts the
212
+ // part worth reading — refused, timed out, no such host — in `cause`.
213
+ const reason = error.cause?.code
214
+ ? `${error.message} (${error.cause.code})`
215
+ : error.message;
216
+ err(
217
+ `could not reach ${base}: ${reason}\n` +
218
+ 'That address comes from adminHost and adminPort in this configuration ' +
219
+ 'file.\nIs the node running, and bound where this says?\n',
220
+ );
221
+ return 1;
222
+ }
223
+
224
+ if (json) {
225
+ out(`${JSON.stringify({ status, torrents }, null, 2)}\n`);
226
+ } else {
227
+ out(formatStatus({ status, torrents }));
228
+ }
229
+
230
+ // Usable from a script: the engine being unreachable is the thing worth
231
+ // failing on, and it is what /health reports to a load balancer.
232
+ return status?.engine?.ok === false ? 1 : 0;
233
+ }