pmtiles-swarm 0.4.3 → 0.4.5

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,39 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.4.5
11
+ ### 🐞 Bug fixes
12
+ - **A hook whose command could not be started is tried again.** Completion is recorded before
13
+ the command runs, so that a six-hour build is not started six times over — but a command that
14
+ never launched has not started anything, and keeping the record meant fixing the path and
15
+ still never seeing it run. The archive was permanently, silently done. A failure to spawn now
16
+ hands the record back; a command that ran and failed keeps it, because retrying that every
17
+ minute is how a broken build becomes a broken loop. A spawn failure also raised two accounts
18
+ of itself on some platforms — the real error, then a nonsense exit code — and only the first
19
+ stands now.
20
+
21
+ ### 📚 Documentation
22
+ - **The service guide is organised around the thing that actually costs an afternoon.**
23
+ Permissions were spread across three sections and `ReadWritePaths` was explained twice, in
24
+ neither place completely. There is now one **Where it writes** section built on the fact that
25
+ three separate things decide whether a write succeeds — the filesystem bits, the group the
26
+ process actually holds, and `ReadWritePaths` — that each refuse on their own and all fail
27
+ identically. It also covers creating the archive directory, which was never mentioned even
28
+ though `savePath` is the entry most often missing from `ReadWritePaths`; why `chmod -R` is
29
+ the wrong tool, since on a directory the execute bit is the search bit; `SupplementaryGroups=`
30
+ for when a group will not appear; and that `PrivateTmp=true` hides a hook's lock and log.
31
+
32
+ ## 0.4.4
33
+ ### 🐞 Bug fixes
34
+ - **The console no longer claims an `.incomplete` file that is not there.** The marker was a
35
+ literal in the page, drawn beside every unfinished archive, with a tooltip naming the file it
36
+ was supposedly on disk as. libtorrent renames nothing — the rename would have to happen in the
37
+ sidecar — so on the engine most people run, that named a file which did not exist, next to one
38
+ sitting under its final name at 25% downloaded. Each engine now says whether it marks
39
+ incomplete files, the composite answers for its primary since that is the engine writing the
40
+ bytes, and `/api/status` combines that with `incompleteSuffix` — which can also be empty — to
41
+ report the marker actually in use, or none. The console draws only what it is told.
42
+
10
43
  ## 0.4.3
11
44
  ### 🐞 Bug fixes
12
45
  - **The lock file keeps the optional native builds `ws` asks for.** `bufferutil` and
@@ -1,8 +1,14 @@
1
1
  # Running as a systemd service
2
2
 
3
- Setting up the account, then the unit. Two lines in that unit are not optional,
4
- and one line most people copy from elsewhere should be deleted — the rest is
5
- ordinary.
3
+ Setting up the account, then the unit, then the directories it writes to.
4
+
5
+ Two lines in that unit are not optional and one line most people copy from
6
+ elsewhere should be deleted, but neither is what costs the afternoon. That is
7
+ **permission to write**, which here means three separate things that all have to
8
+ agree: the filesystem bits, the group the process actually holds, and
9
+ `ReadWritePaths`. Any one of them says no on its own, and the failure looks the
10
+ same each time — so [Where it writes](#where-it-writes) is worth reading before
11
+ the first archive rather than after.
6
12
 
7
13
  ## An account of its own
8
14
 
@@ -46,6 +52,24 @@ Generate the key rather than inventing one:
46
52
  node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
47
53
  ```
48
54
 
55
+ ### Where the archives go
56
+
57
+ Wherever `savePath` points, and it is rarely under `/var/lib` — archives are
58
+ measured in hundreds of gigabytes and usually live on their own mount. Make it
59
+ before the first download, owned by the service:
60
+
61
+ ```sh
62
+ sudo install -d -o pmtiles-swarm -g pmtiles-swarm -m 0755 /mnt/store/torrent-data
63
+ ```
64
+
65
+ `0755` rather than `0750` because it leaves the option of serving those files
66
+ over HTTP later; `0750` if you would rather they stay private. Nothing in
67
+ pmtiles-swarm depends on which you choose.
68
+
69
+ A directory this account owns outright needs nothing further. One that another
70
+ service also writes to is a different job — see
71
+ [a folder shared with another service](#a-folder-shared-with-another-service).
72
+
49
73
  ## Node, and the package
50
74
 
51
75
  Node from your distribution or NodeSource — the package needs `^22.13.0 || 24`:
@@ -160,18 +184,27 @@ TimeoutStopSec=45
160
184
  # descriptors of its own.
161
185
  LimitNOFILE=65535
162
186
 
163
- # The archives and the sidecar are the only things it needs to touch.
187
+ # Everything else is read-only inside this unit's namespace.
164
188
  ProtectSystem=strict
165
189
  ProtectHome=read-only
166
190
  PrivateTmp=true
167
191
  NoNewPrivileges=true
168
- # Both, since the console rewrites the configuration when a token is minted.
192
+
193
+ # The starting pair: /var/lib for the data directory, /etc because the console
194
+ # rewrites the configuration when a token is minted. Anywhere else the
195
+ # configuration points — savePath above all — has to be added here or the write
196
+ # is refused whatever its permissions say. See "Where it writes".
169
197
  ReadWritePaths=/var/lib/pmtiles-swarm /etc/pmtiles-swarm
170
198
 
171
199
  [Install]
172
200
  WantedBy=multi-user.target
173
201
  ```
174
202
 
203
+ `PrivateTmp=true` is worth one more note, because it surprises people writing
204
+ hooks: the service gets its own `/tmp`, so a script that keeps a lock or a log
205
+ there is invisible from a normal shell, and a run started by hand cannot see the
206
+ lock a hook run holds. Have hooks log somewhere real.
207
+
175
208
  ## The two lines that matter
176
209
 
177
210
  **`Restart=always`, not `on-failure`.** The console's *Save & Restart* applies
@@ -189,61 +222,95 @@ while the Python sidecar kept running and kept the data directory locked.
189
222
 
190
223
  Leave `KillMode` at its default, so the sidecar goes with its parent.
191
224
 
192
- ## Paths
225
+ ## Where it writes
193
226
 
194
- Every path in the configuration resolves **relative to the configuration
195
- file**, not to the working directory:
227
+ Three separate things decide whether the service can write to a directory, and
228
+ **each can refuse on its own**. A permission problem, a group problem and a
229
+ namespace problem all produce the same symptom — an operation that silently does
230
+ nothing, or a hook that exits 1 with no output — so it is worth confirming all
231
+ three rather than guessing between them.
232
+
233
+ ### 1. The paths in the configuration
234
+
235
+ Every path resolves **relative to the configuration file**, not to the working
236
+ directory:
196
237
 
197
238
  ```
198
239
  /etc/pmtiles-swarm/swarm.config.json with "dataDir": "./data"
199
240
  -> /etc/pmtiles-swarm/data
200
241
  ```
201
242
 
202
- That is usually not what you want for a service. Use absolute paths for
203
- anything that holds data:
243
+ That is rarely what you want for a service. Use absolute paths for anything
244
+ holding data:
204
245
 
205
246
  ```json
206
247
  {
207
248
  "dataDir": "/var/lib/pmtiles-swarm",
208
- "savePath": "/var/lib/pmtiles-swarm/archives",
249
+ "savePath": "/mnt/store/torrent-data",
209
250
  "libtorrent": { "resumeDir": "/var/lib/pmtiles-swarm/resume" }
210
251
  }
211
252
  ```
212
253
 
213
- `ProtectSystem=strict` makes the whole filesystem read-only apart from what
214
- `ReadWritePaths` names, so every one of those has to be listed. An archive
215
- directory on another mount needs its own entry.
254
+ ### 2. Every one of them in `ReadWritePaths`
255
+
256
+ `ProtectSystem=strict` presents the whole filesystem as read-only inside the
257
+ unit's namespace. The refusal happens **there, before any permission bit is
258
+ consulted** — so a directory whose ownership and mode are perfect still fails if
259
+ it is not named here.
260
+
261
+ List every directory the configuration points at, plus anywhere a hook writes:
262
+
263
+ ```ini
264
+ ReadWritePaths=/var/lib/pmtiles-swarm /etc/pmtiles-swarm /mnt/store /mnt/work/planetiler
265
+ ```
266
+
267
+ `ReadWritePaths=` is a list: repeated assignments **merge** rather than replace,
268
+ whether in the unit itself or in a drop-in from `systemctl edit pmtiles-swarm`.
269
+ So a drop-in adds to what the unit already names. An empty assignment on its own
270
+ line is the only thing that resets it.
271
+
272
+ The one to forget is `savePath`, because it is usually on another mount and
273
+ nothing complains until a download starts — at which point a torrent that cannot
274
+ write fails in a way that reads like a network problem.
275
+
276
+ ### 3. Permission on the directory itself
216
277
 
217
- ## Sharing a folder with another service
278
+ A directory this account owns needs nothing beyond
279
+ [the setup above](#where-the-archives-go). A shared one does.
280
+
281
+ ### A folder shared with another service
218
282
 
219
283
  A folder produced by something else — a generation script, or a directory a
220
- torrent client already owns — needs three things, and group membership is only
221
- the first of them.
284
+ torrent client already owns — takes three steps, and group membership is only
285
+ the first:
222
286
 
223
287
  ```sh
224
288
  # 1. Put the service account in the owning group.
225
289
  sudo usermod -aG qbittorrent-nox pmtiles-swarm
226
290
 
227
291
  # 2. Give that group write, and setgid so new entries inherit it.
228
- sudo find /mnt/hd-16TB/store/generated -type d -exec chmod 2775 {} +
229
- sudo find /mnt/hd-16TB/store/generated -type f -exec chmod 664 {} +
292
+ sudo find /mnt/store/generated -type d -exec chmod 2775 {} +
293
+ sudo find /mnt/store/generated -type f -exec chmod 664 {} +
230
294
 
231
- # 3. Make what the service creates group-writable too.
232
- sudo systemctl edit pmtiles-swarm
233
- sudo systemctl restart pmtiles-swarm
295
+ # 3. Make what this service creates group-writable too, then restart.
296
+ sudo systemctl edit pmtiles-swarm # [Service] / UMask=0002
297
+ sudo systemctl daemon-reload && sudo systemctl restart pmtiles-swarm
234
298
  ```
235
299
 
236
- Step 3 opens an override; the two lines to put in it are:
300
+ **A folder at 0755 gives the group `r-x`.** Membership alone buys read access and
301
+ nothing else — enough to hash and seed an archive, not enough for anything that
302
+ writes. So this looks like it worked right up until the first thing that does.
237
303
 
238
- ```ini
239
- [Service]
240
- UMask=0002
241
- ```
304
+ The `2` in `2775` is setgid, and it is what stops this drifting: without it a
305
+ file the service creates belongs to group `pmtiles-swarm`, the other service
306
+ cannot touch it, and you are back here in a month. `UMask=0002` is the same
307
+ thought for the mode — without it a new file is `0644` and the other account can
308
+ delete it but not modify it.
242
309
 
243
- **A folder at 0755 gives the group `r-x`.** Membership alone buys read access
244
- and nothing else, which is enough to hash and seed an archive and not enough to
245
- do anything else with the folder so this looks like it worked until the first
246
- thing that writes.
310
+ Split by type rather than using `chmod -R`. On a **directory** the execute bit
311
+ is the search bit: it permits resolving a path *through* the directory, so
312
+ removing it leaves a folder whose contents you can list and not one of which you
313
+ can open. Files should lose it; directories must not.
247
314
 
248
315
  Three features want write, and it is worth knowing which, because a read-only
249
316
  folder is a perfectly reasonable way to run:
@@ -254,33 +321,38 @@ folder is a perfectly reasonable way to run:
254
321
  | `keep`, `keepDays` | Deletes retired builds |
255
322
  | `onComplete` | Whatever the script does, since it runs as this account |
256
323
 
257
- Renaming and deleting need write on the **directory**, not on the file, which is
258
- why the directory bits are the ones that matter. `UMask=0002` matters for the
259
- other direction: without it a file the service creates is `0644`, and the other
260
- service can delete it but not modify it.
324
+ Renaming and deleting need write on the **directory**, not on the file which is
325
+ why the directory bits are the ones that matter, and why a build written under a
326
+ temporary name and renamed into place works with directory write alone.
327
+
328
+ ### Two checks that lie
329
+
330
+ **`id pmtiles-swarm`** reads `/etc/group` and shows the new group the instant
331
+ `usermod` returns, whether or not the running process has it. Supplementary
332
+ groups are read when a process starts, so the restart is not optional — and this
333
+ is what proves it:
334
+
335
+ ```sh
336
+ grep -E '^(Uid|Gid|Groups)' /proc/$(systemctl show -p MainPID --value pmtiles-swarm)/status
337
+ getent group qbittorrent-nox # is that GID in the Groups line above?
338
+ ```
261
339
 
262
- **Group membership is read when a process starts**, so the restart is not
263
- optional. Neither is `ReadWritePaths`: `ProtectSystem=strict` presents the rest
264
- of the filesystem as read-only inside the unit's namespace, and the write is
265
- refused there before the permission bits are consulted. Every folder outside
266
- `/var/lib/pmtiles-swarm` has to be named, in a drop-in from
267
- `systemctl edit pmtiles-swarm`:
340
+ If it is missing even after a restart, name it outright rather than relying on
341
+ how systemd resolves groups when `User=` and `Group=` are both set:
268
342
 
269
343
  ```ini
270
- [Service]
271
- ReadWritePaths=/mnt/store/generated /mnt/work/planetiler
272
- UMask=0002
344
+ SupplementaryGroups=qbittorrent-nox
273
345
  ```
274
346
 
275
- `ReadWritePaths=` accumulates, so a drop-in adds to what the unit already lists
276
- rather than replacing it.
347
+ **`sudo -u pmtiles-swarm touch …`** runs outside the unit's namespace, so it
348
+ succeeds on permission bits alone while the service is still being refused by
349
+ `ProtectSystem`. It can prove a permission problem; it cannot clear one.
277
350
 
278
- Two things that look like checks and are not. `id pmtiles-swarm` reads
279
- `/etc/group` and shows the new group the instant `usermod` returns, whether or
280
- not the running process has it — read `/proc/$(systemctl show -p MainPID --value
281
- pmtiles-swarm)/status` instead. And `sudo -u pmtiles-swarm touch …` runs outside
282
- the unit's namespace, so it succeeds on permissions alone while the service is
283
- still being refused.
351
+ What the running service actually has:
352
+
353
+ ```sh
354
+ systemctl show -p ReadWritePaths -p UMask -p SupplementaryGroups pmtiles-swarm
355
+ ```
284
356
 
285
357
  ## The sidecar
286
358
 
@@ -371,3 +443,21 @@ Then check it is actually serving:
371
443
  curl -fsS localhost:8090/feed.xml >/dev/null && echo "public surface ok"
372
444
  curl -fsS localhost:8091/api/status | head -c 200
373
445
  ```
446
+
447
+ And that it can write where it is supposed to, which nothing above proves:
448
+
449
+ ```sh
450
+ systemctl show -p ReadWritePaths -p UMask -p SupplementaryGroups pmtiles-swarm
451
+ grep -E '^Groups' /proc/$(systemctl show -p MainPID --value pmtiles-swarm)/status
452
+ ```
453
+
454
+ Two things that go wrong quietly rather than loudly, and are worth confirming
455
+ once rather than diagnosing later:
456
+
457
+ * An archive that re-hashes its whole store on every start means resume data is
458
+ not being written. There should be one file per torrent in `resumeDir` within
459
+ `resumeSaveIntervalSeconds` of a start.
460
+ * A hook that never seems to run. It logs what it launched and why it stopped —
461
+ `journalctl -u pmtiles-swarm | grep -i onComplete` — and a hook redirecting its
462
+ own output to a file will have nothing for the journal to show, which is not
463
+ the same as not having run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
package/src/api.js CHANGED
@@ -408,6 +408,14 @@ export function createApp({
408
408
  }
409
409
  res.json({
410
410
  version: VERSION,
411
+ // What an unfinished archive is actually called on disk, or null when
412
+ // nothing renames it. Both halves matter: the setting can be empty,
413
+ // and an engine can ignore it entirely. The console showed the marker
414
+ // for every unfinished archive regardless, which on libtorrent was a
415
+ // filename that did not exist.
416
+ incompleteMarker: engine.marksIncomplete
417
+ ? config.incompleteSuffix || null
418
+ : null,
411
419
  engine: { name: engine.name, ok: engineOk, error: engineError },
412
420
  archives: catalog.list().length,
413
421
  categories: catalog.categories(),
@@ -58,6 +58,9 @@ export class CompositeEngine {
58
58
  this.shareIntervalMs = Math.max(5, shareIntervalSeconds) * 1000;
59
59
  this.shareTimeoutMs = Math.max(60, shareTimeoutSeconds) * 1000;
60
60
  this.name = [primary.name, ...this.#secondaries.map((e) => e.name)].join('+');
61
+ // The primary is the one that writes the file. A secondary only ever
62
+ // receives an archive that is already whole, so it never marks anything.
63
+ this.marksIncomplete = primary.marksIncomplete ?? false;
61
64
  }
62
65
 
63
66
  /** The engine that owns the data. @returns {object} - The primary. */
@@ -78,6 +78,12 @@ export class LibtorrentEngine {
78
78
  throw new Error('libtorrent engine requires a savePath');
79
79
  }
80
80
  this.name = 'libtorrent';
81
+ // Whether `incompleteSuffix` means anything here: it does not. The rename
82
+ // would have to happen in the sidecar, so a partial archive sits under its
83
+ // final name from the first byte — which is why a web server must never be
84
+ // pointed at this engine's save path, and why the console must not claim
85
+ // a marker that is not there.
86
+ this.marksIncomplete = false;
81
87
  if (options.listen !== undefined && typeof options.listen !== 'string') {
82
88
  // Caught here because the alternative is a C++ converter error four
83
89
  // frames into a Python traceback, which says nothing about which setting
@@ -54,6 +54,9 @@ export class WebTorrentSeedEngine {
54
54
  throw new Error('WebTorrent engine requires a savePath');
55
55
  }
56
56
  this.name = 'webtorrent';
57
+ // Honoured, by replacing the store — the only thing that decides where
58
+ // bytes land.
59
+ this.marksIncomplete = true;
57
60
  this.#options = { readyTimeoutMs: 300000, ...options };
58
61
  }
59
62
 
package/src/hooks.js CHANGED
@@ -169,9 +169,25 @@ export class ProgramHooks {
169
169
 
170
170
  this.#running.add(entry.infoHash);
171
171
  fired.push(entry);
172
- this.#fire('onComplete', entry).finally(() =>
173
- this.#running.delete(entry.infoHash),
174
- );
172
+ this.#fire('onComplete', entry)
173
+ .then(async (result) => {
174
+ // Recorded before running so a six-hour build is not started six
175
+ // times over — but a command that never launched has not started
176
+ // anything, and keeping the stamp would mean fixing the path and
177
+ // still never seeing it run. That one case is given back.
178
+ if (result?.started === false) {
179
+ await this.#library.catalog.put({
180
+ infoHash: entry.infoHash,
181
+ completedAt: null,
182
+ });
183
+ console.warn(
184
+ `[hook] ${entry.name}: the command never started, so this will ` +
185
+ 'be tried again on the next sweep',
186
+ );
187
+ }
188
+ })
189
+ .catch(() => {})
190
+ .finally(() => this.#running.delete(entry.infoHash));
175
191
  }
176
192
 
177
193
  return fired;
@@ -237,7 +253,13 @@ export class ProgramHooks {
237
253
  stream?.on('data', collect);
238
254
  }
239
255
 
240
- const report = (problem) => {
256
+ // A failure to spawn raises 'error' and then, on some platforms, 'close'
257
+ // with a nonsense exit code — so the first account of what happened is
258
+ // the true one and the second is noise.
259
+ let reported = false;
260
+ const report = (problem, started = true) => {
261
+ if (reported) return;
262
+ reported = true;
241
263
  if (problem) {
242
264
  console.error(`[${label}] ${entry.name}: ${problem}`);
243
265
  } else {
@@ -248,12 +270,14 @@ export class ProgramHooks {
248
270
  for (const line of tail) {
249
271
  if (line.trim()) console.log(`[${label}] ${line}`);
250
272
  }
251
- resolve();
273
+ resolve({ started });
252
274
  };
253
275
 
254
276
  // A command that could not be started at all — no such file, not
255
- // executable — never reaches 'close'.
256
- child.on('error', (error) => report(error.message));
277
+ // executable, a working directory that is not there — never reaches
278
+ // 'close'. Reported as not started, which is what lets the caller try
279
+ // again: this is a configuration to fix, not a job that ran and failed.
280
+ child.on('error', (error) => report(error.message, false));
257
281
  child.on('close', (code, signal) => {
258
282
  if (signal) return report(`killed by ${signal}`);
259
283
  report(code === 0 ? undefined : `exited with code ${code}`);
@@ -725,6 +725,9 @@
725
725
  // ── Archives ──────────────────────────────────────────────────────────
726
726
  let archives = [];
727
727
  let selected = null;
728
+ // Null until /api/status says otherwise, so nothing claims a marker
729
+ // before the node has said whether its engine applies one.
730
+ let incompleteMarker = null;
728
731
  let activeTab = 'general';
729
732
 
730
733
  // What the header switch shows, so a click knows what it is toggling to.
@@ -882,6 +885,9 @@
882
885
  renderSpeed(speed);
883
886
  renderFetching(adds?.running);
884
887
  if (status.version) $('version').textContent = `v${status.version}`;
888
+ // What an unfinished archive is really called on disk, or null when
889
+ // nothing renames it. The engine decides, not the setting alone.
890
+ incompleteMarker = status.incompleteMarker ?? null;
885
891
  const engine = status.engine;
886
892
  $('status').innerHTML =
887
893
  `engine <b>${engine.name}</b> ${engine.ok ? 'ready' : 'unavailable'}` +
@@ -994,8 +1000,8 @@
994
1000
  <td class="sub">${
995
1001
  entry.paused ? 'paused' : escapeHtml(s.state ?? '—')
996
1002
  }${
997
- entry.complete === false && progress < 1
998
- ? `<div class="sub" title="on disk as ${escapeHtml(entry.name)}.incomplete until it is whole">.incomplete</div>`
1003
+ entry.complete === false && progress < 1 && incompleteMarker
1004
+ ? `<div class="sub" title="on disk as ${escapeHtml(entry.name)}${escapeHtml(incompleteMarker)} until it is whole">${escapeHtml(incompleteMarker)}</div>`
999
1005
  : ''
1000
1006
  }</td>`;
1001
1007
  tr.onclick = () => {