signalk-backup 0.9.5 → 0.10.1

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.
Files changed (44) hide show
  1. package/package.json +2 -1
  2. package/plugin/backup-client.d.ts +1 -1
  3. package/plugin/backup-client.d.ts.map +1 -1
  4. package/plugin/backup-client.js +28 -3
  5. package/plugin/backup-client.js.map +1 -1
  6. package/plugin/index.d.ts.map +1 -1
  7. package/plugin/index.js +132 -371
  8. package/plugin/index.js.map +1 -1
  9. package/plugin/types.d.ts +1 -164
  10. package/plugin/types.d.ts.map +1 -1
  11. package/public/assets/{App-DtlidnzY.js → App-BDxkTxvV.js} +3 -3
  12. package/public/assets/{App-DtlidnzY.js.map → App-BDxkTxvV.js.map} +1 -1
  13. package/public/assets/AppPanel-_OFBBplE.js +2 -0
  14. package/public/assets/{AppPanel-BsqUab7V.js.map → AppPanel-_OFBBplE.js.map} +1 -1
  15. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-BTXSQ5YD.js +1 -0
  16. package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__-BWS6jj3P.js → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__-CZEusA_v.js} +1 -1
  17. package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__.mjs-tYcLt6vX.js → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__.mjs-C5oceaDt.js} +2 -2
  18. package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__.mjs-tYcLt6vX.js.map → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__.mjs-C5oceaDt.js.map} +1 -1
  19. package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__-BkXrC6cU.js → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__-Dh2Je4Q8.js} +1 -1
  20. package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BRncJTRs.js → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BJsP72ov.js} +2 -2
  21. package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BRncJTRs.js.map → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BJsP72ov.js.map} +1 -1
  22. package/public/assets/{dist-DLVcTB4j.js → dist-Cr3RQpe1.js} +4 -4
  23. package/public/assets/{dist-DLVcTB4j.js.map → dist-Cr3RQpe1.js.map} +1 -1
  24. package/public/assets/{hostInit-BW_bT2un.js → hostInit-CBfbRWDG.js} +1 -1
  25. package/public/assets/{index-CeoRFgVj.js → index-D03Kssc_.js} +2 -2
  26. package/public/assets/{index-CeoRFgVj.js.map → index-D03Kssc_.js.map} +1 -1
  27. package/public/assets/{jsx-dev-runtime-BmqEwJcR.js → jsx-dev-runtime-BvApC5zd.js} +1 -1
  28. package/public/assets/jsx-runtime-4HRoLdzw.js +1 -0
  29. package/public/assets/main-XRgh0Npp.js +2 -0
  30. package/public/assets/{main-B1uZZe5-.js.map → main-XRgh0Npp.js.map} +1 -1
  31. package/public/assets/{virtualExposes-Do1tdq-X.js → virtualExposes-PNn3gXrV.js} +2 -2
  32. package/public/assets/{virtualExposes-Do1tdq-X.js.map → virtualExposes-PNn3gXrV.js.map} +1 -1
  33. package/public/mf-entry-bootstrap-0.js +2 -2
  34. package/public/remoteEntry.js +1 -1
  35. package/public/remoteEntry.js.map +1 -1
  36. package/public/assets/AppPanel-BsqUab7V.js +0 -2
  37. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-C2n2hIk1.js +0 -1
  38. package/public/assets/jsx-runtime-uNZh9WvZ.js +0 -1
  39. package/public/assets/main-B1uZZe5-.js +0 -2
  40. /package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react__loadShare__.mjs-BC2D4UuM.js → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react__loadShare__.mjs-CRDWkJnD.js} +0 -0
  41. /package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_2_dom__loadShare__.mjs-BYNd5CgH.js → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_2_dom__loadShare__.mjs-COVU9tAo.js} +0 -0
  42. /package/public/assets/{_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-DQKQsK1P.js → _virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-F9Hr43sq.js} +0 -0
  43. /package/public/assets/{preload-helper-DDsTugmO.js → preload-helper-DCSm9-xi.js} +0 -0
  44. /package/public/assets/{rolldown-runtime-jpDsebLB.js → rolldown-runtime-Bh1tDfsg.js} +0 -0
package/plugin/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { hostname } from 'node:os';
3
+ import { ManagedContainer, getContainerManager, retryForever, startSafely } from 'signalk-container-helper';
3
4
  import { BackupClient } from './backup-client.js';
4
5
  import { registerProxy } from './proxy.js';
5
6
  import { discoverSmbHosts, shutdownSmbDiscovery } from './smb-discovery.js';
@@ -17,16 +18,9 @@ const PLUGIN_ID = 'signalk-backup';
17
18
  const SK_MOUNT = '/signalk-data';
18
19
  const API_PORT = 3010;
19
20
  const OAUTH_PORT = 53682;
20
- const SAFE_TAG = /^[a-zA-Z0-9._-]+$/;
21
21
  // Readiness-retry backoff (15s doubling to a 2min ceiling, forever): a transient boot race must not wedge the plugin until a human restarts it — on a boat that can be weeks later.
22
22
  const READY_RETRY_MIN_MS = 15_000;
23
23
  const READY_RETRY_MAX_MS = 120_000;
24
- function sleep(ms) {
25
- return new Promise((resolve) => {
26
- // unref: a pending background retry must not keep a shutting-down process alive.
27
- setTimeout(resolve, ms).unref();
28
- });
29
- }
30
24
  /**
31
25
  * Sensible default resource limits for the backup-server container.
32
26
  * Backup work is mostly I/O bound (Kopia is content-addressable so it
@@ -49,86 +43,6 @@ const DEFAULT_RESOURCES = {
49
43
  memorySwap: '1g',
50
44
  pidsLimit: 100
51
45
  };
52
- function getContainerManager() {
53
- return globalThis.__signalk_containerManager;
54
- }
55
- /**
56
- * Wait for signalk-container's API to be FULLY ready on globalThis —
57
- * both the manager object exposed AND its runtime detection complete.
58
- *
59
- * signalk-container publishes `__signalk_containerManager` synchronously
60
- * during its own start() but kicks off `detectRuntime` async, so there's
61
- * a ~1-2s window where getRuntime() returns null. signalk-backup loads
62
- * BEFORE signalk-container alphabetically and races into that window
63
- * unless we wait for both signals.
64
- */
65
- async function waitForContainerManager(maxMs, intervalMs = 500) {
66
- const deadline = Date.now() + maxMs;
67
- while (Date.now() < deadline) {
68
- const m = getContainerManager();
69
- if (m && m.getRuntime())
70
- return m;
71
- await new Promise((r) => setTimeout(r, intervalMs));
72
- }
73
- return getContainerManager();
74
- }
75
- /**
76
- * Resolve the actual host:port the backup-server container is reachable at.
77
- *
78
- * `resolveContainerAddress` is the documented API and the authoritative
79
- * answer in every deployment shape signalk-container 1.7.0+ supports —
80
- * including the in-container "share SignalK's network namespace"
81
- * (`container:<self-id>`) path, where the right URL is `127.0.0.1:3010`
82
- * and no host-port mapping exists for `listContainers()` to parse.
83
- *
84
- * We still fall through to a `listContainers().ports` parse for the
85
- * legacy port-drift case that the previous order was guarding against:
86
- * a process-local port cache disagreeing with the live podman binding
87
- * (TOCTOU between the port probe and `podman create`). That happens in
88
- * bare-metal-SK deployments only — never when SK is containerized — so
89
- * trying the API first never trades correctness for safety.
90
- *
91
- * Returns null when neither path can produce an address (genuine
92
- * misconfiguration; the caller throws a user-visible error).
93
- */
94
- async function resolveActualAddress(containers, debug) {
95
- try {
96
- const apiAnswer = await containers.resolveContainerAddress(CONTAINER_NAME, API_PORT);
97
- if (apiAnswer)
98
- return apiAnswer;
99
- }
100
- catch (err) {
101
- debug?.(`resolveContainerAddress threw: ${errMsg(err)}`);
102
- }
103
- // Fallback: parse listContainers() ports for the legacy bare-metal-SK
104
- // port-drift case where the API's process-local cache could lag the
105
- // live podman binding. Not needed for in-container deployments —
106
- // signalk-container's `container:<self-id>` branch populates the API
107
- // with the correct loopback answer.
108
- try {
109
- const list = await containers.listContainers();
110
- const found = list.find((c) => c.name === `sk-${CONTAINER_NAME}`);
111
- if (found && Array.isArray(found.ports)) {
112
- // The shape ContainerInfo we mirror only declares { name, image, state },
113
- // but the runtime object includes a `ports` array of strings like
114
- // "127.0.0.1:3010->3010/tcp". Parse the binding for our API_PORT.
115
- const ports = found.ports;
116
- const wanted = `->${API_PORT}/tcp`;
117
- for (const entry of ports) {
118
- if (!entry.endsWith(wanted))
119
- continue;
120
- const hostPart = entry.slice(0, -wanted.length);
121
- // hostPart looks like "127.0.0.1:3010" or "0.0.0.0:3010".
122
- if (hostPart.includes(':'))
123
- return hostPart;
124
- }
125
- }
126
- }
127
- catch (err) {
128
- debug?.(`listContainers fallback threw: ${errMsg(err)}`);
129
- }
130
- return null;
131
- }
132
46
  export default function (app) {
133
47
  let client = null;
134
48
  let currentSettings = null;
@@ -137,9 +51,14 @@ export default function (app) {
137
51
  // In-flight tick handle so concurrent callers (scheduler + manual
138
52
  // backup) coalesce onto the same export rather than racing.
139
53
  let dbExportInFlight = null;
140
- // Bumped on every start()/stop() so async work from an older lifecycle can detect it's stale and bail.
141
- let runGeneration = 0;
142
- let updatesRegistered = false;
54
+ // Bumped on every start()/stop() so async work from an older lifecycle can
55
+ // detect it's stale and bail. The helper's own serialization and AbortSignal
56
+ // cover the container operations; this still guards everything it knows
57
+ // nothing about — the SignalK emitter, the db-export timer, and the whole
58
+ // external-server branch, which never touches a container at all.
59
+ let lifecycleGeneration = 0;
60
+ // Cancels in-flight container work on stop(); recreated per start().
61
+ let startAbort = null;
143
62
  const buildContainerConfig = (tag) => ({
144
63
  image: BACKUP_IMAGE,
145
64
  tag,
@@ -194,6 +113,41 @@ export default function (app) {
194
113
  app.error(`mount aborted: ${issue.containerPath} (${issue.reason})`);
195
114
  }
196
115
  };
116
+ // Built once, before the plugin object: registerWithRouter() can run before
117
+ // start(), and the update routes are mounted off this instance.
118
+ const container = new ManagedContainer({
119
+ app,
120
+ pluginId: PLUGIN_ID,
121
+ // Unprefixed. The helper matches the live container by `unprefixedName`,
122
+ // so this keeps working under a non-default SIGNALK_CONTAINER_NAMESPACE —
123
+ // the hard-coded `sk-` prefix this replaces did not.
124
+ name: CONTAINER_NAME,
125
+ image: BACKUP_IMAGE,
126
+ buildConfig: buildContainerConfig,
127
+ defaultTag: 'auto',
128
+ // 'auto' → the pinned, tested backup-server version. Applied to start(),
129
+ // applyUpdate() and route input alike, and the helper validates both the
130
+ // requested and the resolved tag (replacing the old SAFE_TAG check).
131
+ resolveTag: resolveImageTag,
132
+ managerTimeoutMs: 120_000,
133
+ readiness: {
134
+ port: API_PORT,
135
+ path: '/api/health',
136
+ maxMs: 60_000
137
+ },
138
+ readinessRetry: {
139
+ minDelayMs: READY_RETRY_MIN_MS,
140
+ maxDelayMs: READY_RETRY_MAX_MS,
141
+ onAttemptFailed: (err, nextDelayMs) => {
142
+ app.setPluginError(`Container startup failed: ${errMsg(err)} — retrying in ${Math.round(nextDelayMs / 1000)}s`);
143
+ }
144
+ },
145
+ updates: {
146
+ versionSource: { githubReleases: 'dirkwa/signalk-backup-server' },
147
+ currentTag: () => resolveImageTag(currentSettings?.imageTag ?? 'auto')
148
+ },
149
+ ensureOptions: { onVolumeIssue }
150
+ });
197
151
  const plugin = {
198
152
  id: PLUGIN_ID,
199
153
  name: 'Backup',
@@ -216,163 +170,88 @@ export default function (app) {
216
170
  }
217
171
  };
218
172
  currentSettings = merged;
219
- const gen = ++runGeneration;
220
- void asyncStart(merged, gen).catch((err) => {
221
- app.setPluginError(`Startup failed: ${errMsg(err)}`);
222
- });
173
+ const gen = ++lifecycleGeneration;
174
+ // Retire any previous startup first. Without this a start() during an
175
+ // in-flight start orphans the old retry loop: readinessRetry never
176
+ // returns while it is retrying, so the generation check below cannot
177
+ // reach it, and it keeps reporting failures for a dead lifecycle.
178
+ startAbort?.abort();
179
+ const abort = new AbortController();
180
+ startAbort = abort;
181
+ // SignalK re-runs start() on a config save with no stop(), so keeping these reported the previous upstream's readiness while connecting to a new one.
182
+ client = null;
183
+ containerAddress = null;
184
+ // startSafely: SignalK does not await start(), and it demotes the
185
+ // helper's `cancelled` error to debug so stopping mid-startup doesn't
186
+ // leave "Startup failed: Operation cancelled" in the plugin error box.
187
+ startSafely(app, () => asyncStart(merged, gen, abort.signal));
223
188
  },
224
189
  async stop() {
225
190
  app.debug('Stopping signalk-backup');
226
191
  // Invalidate any pending startup/readiness-retry work.
227
- runGeneration++;
192
+ lifecycleGeneration++;
193
+ // Abort BEFORE awaiting the stop: the readiness retry loop is otherwise
194
+ // free to queue another attempt behind it.
195
+ startAbort?.abort();
196
+ startAbort = null;
228
197
  stopSignalKEmitter();
229
198
  stopDbExportTimer();
230
199
  shutdownSmbDiscovery();
231
200
  client = null;
232
201
  containerAddress = null;
233
- const containers = getContainerManager();
234
- if (containers && currentSettings?.managedContainer !== false) {
235
- try {
236
- containers.updates.unregister(PLUGIN_ID);
237
- }
238
- catch (err) {
239
- app.debug(`Error unregistering update tracker: ${errMsg(err)}`);
240
- }
241
- updatesRegistered = false;
242
- try {
243
- await containers.stop(CONTAINER_NAME);
244
- }
245
- catch (err) {
246
- app.debug(`Error stopping ${CONTAINER_NAME}: ${errMsg(err)}`);
247
- }
202
+ if (currentSettings?.managedContainer !== false) {
203
+ // Unregisters updates and stops (not removes) the container; never throws.
204
+ await container.stop();
248
205
  }
249
206
  app.setPluginStatus('Stopped');
250
207
  },
251
208
  registerWithRouter(router) {
252
209
  // Lightweight readiness signal for admin/webapp badges.
253
210
  router.get('/status', async (_req, res) => {
254
- const containers = getContainerManager();
255
- let containerState = 'unknown';
256
- let containerImage = '';
257
- if (containers) {
258
- try {
259
- containerState = await containers.getState(CONTAINER_NAME);
260
- }
261
- catch (err) {
262
- app.debug(`status: getState failed: ${errMsg(err)}`);
263
- }
264
- if (containers.getRuntime()) {
265
- try {
266
- const list = await containers.listContainers();
267
- const found = list.find((c) => c.name === `sk-${CONTAINER_NAME}`);
268
- if (found)
269
- containerImage = found.image;
270
- }
271
- catch (err) {
272
- app.debug(`status: listContainers failed: ${errMsg(err)}`);
273
- }
274
- }
275
- }
276
- if (!containerImage) {
277
- containerImage = `${BACKUP_IMAGE}:${resolveImageTag(currentSettings?.imageTag ?? 'auto')}`;
278
- }
211
+ // getInfo() never throws: 'unknown' state and an empty image when the
212
+ // manager or runtime is absent.
213
+ const { state, image } = await container.getInfo();
214
+ const containerImage = image || `${BACKUP_IMAGE}:${resolveImageTag(currentSettings?.imageTag ?? 'auto')}`;
279
215
  // WHY: pathMapping.hostPath is operator-facing; resolveSignalkConfigRoot is SK-internal when SK is in a container.
280
216
  const managed = currentSettings?.managedContainer !== false;
281
217
  const pathMapping = managed
282
- ? { containerPath: SK_MOUNT, hostPath: await resolveSignalkConfigRootOnHost(containers) }
218
+ ? {
219
+ containerPath: SK_MOUNT,
220
+ hostPath: await resolveSignalkConfigRootOnHost(getContainerManager())
221
+ }
283
222
  : undefined;
284
223
  res.json({
285
224
  container: {
286
- state: containerState,
225
+ state,
287
226
  image: containerImage,
288
227
  managed
289
228
  },
290
- ready: client !== null,
229
+ // `client` is published only once the upstream has answered, in both modes. Managed mode also ANDs in the live state, since `client` is cleared only in stop() and so kept reporting ready for a container that had gone away (#103).
230
+ ready: client !== null && (!managed || state === 'running'),
291
231
  ...(pathMapping ? { pathMapping } : {})
292
232
  });
293
233
  });
294
234
  // Update detection — delegated to signalk-container's centralized
295
- // update service. Same pattern as mayara.
296
- router.get('/api/update/check', async (_req, res) => {
297
- const containers = getContainerManager();
298
- if (!containers) {
299
- res.status(503).json({ error: 'signalk-container not available' });
300
- return;
301
- }
302
- try {
303
- const result = await containers.updates.checkOne(PLUGIN_ID);
304
- res.json(result);
305
- }
306
- catch (err) {
307
- res.status(500).json({ error: errMsg(err) });
308
- }
309
- });
310
- router.post('/api/update/apply', async (req, res) => {
311
- const containers = getContainerManager();
312
- if (!containers) {
313
- res.status(503).json({ error: 'signalk-container not available' });
314
- return;
315
- }
316
- const body = (req.body ?? {});
317
- if ('tag' in body && typeof body.tag !== 'string') {
318
- res.status(400).json({ error: 'tag must be a string' });
319
- return;
320
- }
321
- const requestedTag = (typeof body.tag === 'string' ? body.tag : undefined) ??
322
- currentSettings?.imageTag ??
323
- 'auto';
324
- if (!SAFE_TAG.test(requestedTag)) {
325
- res.status(400).json({ error: 'Invalid tag format' });
326
- return;
327
- }
328
- const tag = resolveImageTag(requestedTag);
329
- try {
330
- app.setPluginStatus(`Recreating signalk-backup-server with ${BACKUP_IMAGE}:${tag}...`);
331
- if (containers.recreate) {
332
- // 1.12.0+ — single primitive handles pull + remove + create
333
- // with consistent error semantics (any failure leaves the
334
- // container in a known state, not the "removed but recreate
335
- // failed" limbo the old triplet could produce).
336
- await containers.recreate(CONTAINER_NAME, buildContainerConfig(tag), {
337
- onVolumeIssue
338
- });
339
- }
340
- else {
341
- // signalk-container < 1.12.0 fallback: original pull + remove
342
- // + ensureRunning triplet. Remove this branch when the plugin
343
- // bumps its minimum signalk-container version.
344
- await containers.pullImage(`${BACKUP_IMAGE}:${tag}`);
345
- await containers.remove(CONTAINER_NAME);
346
- try {
347
- await containers.ensureRunning(CONTAINER_NAME, buildContainerConfig(tag), {
348
- onVolumeIssue
349
- });
350
- }
351
- catch (recreateErr) {
352
- const msg = `Container removed but recreation failed: ${errMsg(recreateErr)}. Click Update again to retry.`;
353
- app.setPluginError(msg);
354
- res.status(500).json({ error: msg });
355
- return;
356
- }
357
- }
235
+ // update service. Mounts GET /api/update/check and POST
236
+ // /api/update/apply the same paths the webapp already calls.
237
+ container.registerUpdateRoutes(router, {
238
+ onApplied: async (requestedTag, resolvedTag) => {
358
239
  // Persist requestedTag not resolved tag: saving "auto" preserves auto-tracking across upgrades.
359
- if (currentSettings) {
360
- currentSettings.imageTag = requestedTag;
361
- await new Promise((resolve) => {
362
- app.savePluginOptions({ ...currentSettings }, (err) => {
363
- if (err) {
364
- app.error(`Failed to persist new tag: ${errMsg(err)}. Container is running with ${tag} but a plugin restart will revert.`);
365
- }
366
- resolve();
367
- });
368
- });
240
+ if (!currentSettings) {
241
+ // Both dropped-persistence paths log: silently skipping would let
242
+ // a restart revert the update with nothing in the log to explain it.
243
+ app.error(`Updated to ${resolvedTag} but could not persist "${requestedTag}": plugin settings not loaded. A plugin restart will revert.`);
244
+ return;
369
245
  }
370
- app.setPluginStatus(`Updated to ${BACKUP_IMAGE}:${tag}`);
371
- res.json({ success: true, tag });
372
- }
373
- catch (err) {
374
- app.setPluginError(`Update failed: ${errMsg(err)}`);
375
- res.status(500).json({ error: errMsg(err) });
246
+ currentSettings.imageTag = requestedTag;
247
+ await new Promise((resolve) => {
248
+ app.savePluginOptions({ ...currentSettings }, (err) => {
249
+ if (err) {
250
+ app.error(`Failed to persist new tag: ${errMsg(err)}. Container is running with ${resolvedTag} but a plugin restart will revert.`);
251
+ }
252
+ resolve();
253
+ });
254
+ });
376
255
  }
377
256
  });
378
257
  // Plugin owns the timer; backup-server only consumes the Parquet
@@ -525,7 +404,7 @@ export default function (app) {
525
404
  });
526
405
  }
527
406
  };
528
- async function asyncStart(settings, gen) {
407
+ async function asyncStart(settings, gen, signal) {
529
408
  if (!settings.managedContainer) {
530
409
  // External-server mode: skip the container, point at user-provided URL.
531
410
  const url = settings.externalUrl.trim();
@@ -534,107 +413,48 @@ export default function (app) {
534
413
  return;
535
414
  }
536
415
  const external = new BackupClient(url);
537
- client = external;
538
- // Keep the scheme so the proxy can route HTTPS external upstreams.
416
+ // Only the address is published here, scheme included so the proxy can route HTTPS upstreams; `client` waits for finishExternal, or /status would report ready before the upstream had answered.
539
417
  containerAddress = url;
540
- try {
541
- await external.waitForReady(15_000);
542
- await finishExternal(gen, settings, external, url);
543
- }
544
- catch (err) {
545
- if (gen !== runGeneration)
418
+ // No container here, so the helper's ManagedContainer has nothing to do —
419
+ // but the retry policy is the same, so reuse its primitive directly.
420
+ await retryForever(async () => {
421
+ if (gen !== lifecycleGeneration)
546
422
  return;
547
- app.setPluginError(`External backup-server unreachable: ${errMsg(err)} retrying in ${READY_RETRY_MIN_MS / 1000}s`);
548
- void retryUntilReady(gen, `External backup-server at ${url} unreachable`, async () => {
549
- await external.waitForReady(10_000);
550
- await finishExternal(gen, settings, external, url);
551
- });
552
- }
553
- return;
554
- }
555
- // signalk-container exposes its API via globalThis only after its own
556
- // start() has finished. Plugin-load order is alphabetical, and
557
- // "signalk-backup" comes before "signalk-container" — so on a cold
558
- // server start, getContainerManager() is undefined for the first few
559
- // hundred ms. Poll for up to 30s before giving up.
560
- const containers = await waitForContainerManager(120_000);
561
- if (!containers) {
562
- app.setPluginError('signalk-container plugin not available after 120s. Install and enable it, then restart this plugin.');
563
- return;
564
- }
565
- if (!containers.getRuntime()) {
566
- app.setPluginError('No container runtime detected (Podman or Docker). Install one and restart signalk-container.');
567
- return;
568
- }
569
- if (!SAFE_TAG.test(settings.imageTag)) {
570
- app.setPluginError(`Invalid imageTag "${settings.imageTag}"`);
571
- return;
572
- }
573
- const resolvedTag = resolveImageTag(settings.imageTag);
574
- try {
575
- // Self-heal: if a live container exists with a different image
576
- // than the just-resolved tag (typical after a plugin upgrade that
577
- // bumped BACKUP_SERVER_VERSION), force-recreate via the explicit
578
- // primitive rather than relying on ensureRunning's drift detector.
579
- // Requires signalk-container >= 1.12.0; older installs fall through
580
- // to ensureRunning which still recreates on drift in 1.6.0+.
581
- const desiredImage = `${BACKUP_IMAGE}:${resolvedTag}`;
582
- let usedRecreate = false;
583
- if (containers.recreate) {
584
- try {
585
- const live = await containers.listContainers();
586
- const found = live.find((c) => c.name === `sk-${CONTAINER_NAME}`);
587
- if (found && found.image !== desiredImage) {
588
- app.setPluginStatus(`Recreating ${found.image} → ${desiredImage}...`);
589
- await containers.recreate(CONTAINER_NAME, buildContainerConfig(resolvedTag), {
590
- onVolumeIssue
591
- });
592
- usedRecreate = true;
593
- }
594
- }
595
- catch (probeErr) {
596
- app.debug(`self-heal probe failed (non-fatal): ${errMsg(probeErr)}`);
423
+ // retryForever only checks between attempts, so the signal must reach the probe itself.
424
+ await external.waitForReady(15_000, 1000, signal);
425
+ await finishExternal(gen, settings, external, url);
426
+ }, {
427
+ minDelayMs: READY_RETRY_MIN_MS,
428
+ maxDelayMs: READY_RETRY_MAX_MS,
429
+ signal,
430
+ onAttemptFailed: (err, nextDelayMs) => {
431
+ app.setPluginError(`External backup-server at ${url} unreachable: ${errMsg(err)} retrying in ${Math.round(nextDelayMs / 1000)}s`);
597
432
  }
598
- }
599
- if (!usedRecreate) {
600
- app.setPluginStatus(`Starting ${desiredImage}...`);
601
- await containers.ensureRunning(CONTAINER_NAME, buildContainerConfig(resolvedTag), {
602
- onVolumeIssue
603
- });
604
- }
605
- registerUpdateTracker(containers, settings);
606
- const addr = await resolveActualAddress(containers, (m) => {
607
- app.debug(m);
608
433
  });
609
- if (!addr) {
610
- throw new Error('Could not resolve container address');
611
- }
612
- // Container is always plain HTTP on the host loopback; storing the
613
- // full URL keeps the format consistent with external-mode (which
614
- // may be HTTPS) so the proxy can use the same value as-is.
615
- containerAddress = `http://${addr}`;
616
- // `client` stays null until /api/health succeeds, so /status's
617
- // `ready: client !== null` reports the truthful upstream-reachable
618
- // signal rather than just "we know the address."
619
- const pending = new BackupClient(containerAddress);
620
- app.setPluginStatus('Waiting for backup-server to become ready...');
621
- await pending.waitForReady(60_000);
622
- await finishStartup(gen, settings, pending, containerAddress);
623
- }
624
- catch (err) {
625
- if (gen !== runGeneration)
626
- return;
627
- app.setPluginError(`Container startup failed: ${errMsg(err)} — retrying in ${READY_RETRY_MIN_MS / 1000}s`);
628
- void retryUntilReady(gen, 'Container startup failed', () => managedReadyAttempt(gen, settings, containers, resolvedTag));
434
+ return;
629
435
  }
436
+ // Managed-container mode. readinessRetry means this resolves only once the
437
+ // container answers /api/health, or rejects on cancellation — it does not
438
+ // give up and leave the plugin wedged.
439
+ const { address } = await container.start(settings.imageTag, { signal });
440
+ if (gen !== lifecycleGeneration)
441
+ return;
442
+ if (!address)
443
+ throw new Error('Could not resolve container address');
444
+ // Full URL keeps the format consistent with external mode (which may be
445
+ // HTTPS) so the proxy can use the value as-is.
446
+ containerAddress = address;
447
+ // Managed mode assigns `client` only after /api/health answers, so /status reports having reached the upstream rather than just knowing its address.
448
+ const pending = new BackupClient(address);
449
+ await finishStartup(gen, settings, pending, address);
630
450
  }
631
451
  // Generation-guarded so a stop()/restart during a slow health probe can't resurrect stale timers or emitters.
632
452
  async function finishStartup(gen, settings, pending, address) {
633
- if (gen !== runGeneration)
453
+ if (gen !== lifecycleGeneration)
634
454
  return;
635
455
  client = pending;
636
456
  await seedFirstRunSchedule(pending);
637
- if (gen !== runGeneration)
457
+ if (gen !== lifecycleGeneration)
638
458
  return;
639
459
  startSignalKEmitter(app, address, {
640
460
  emitSignalKDeltas: settings.emitSignalKDeltas
@@ -642,11 +462,13 @@ export default function (app) {
642
462
  startDbExportTimer();
643
463
  }
644
464
  async function finishExternal(gen, settings, c, url) {
645
- if (gen !== runGeneration)
465
+ if (gen !== lifecycleGeneration)
646
466
  return;
467
+ // Deferred to here so ready cannot be true before the upstream answered.
468
+ client = c;
647
469
  app.setPluginStatus(`Connected to external backup-server at ${url}`);
648
470
  await seedFirstRunSchedule(c);
649
- if (gen !== runGeneration)
471
+ if (gen !== lifecycleGeneration)
650
472
  return;
651
473
  startSignalKEmitter(app, url, { emitSignalKDeltas: settings.emitSignalKDeltas });
652
474
  if (settings.databaseExport.questdb ||
@@ -656,67 +478,6 @@ export default function (app) {
656
478
  `requires managed-container mode and was skipped.`);
657
479
  }
658
480
  }
659
- // One retry-loop attempt: ensureRunning is idempotent, the drift/recreate check already ran on the first attempt, and the address is re-resolved because a container that only now came up may bind a different host port.
660
- async function managedReadyAttempt(gen, settings, containers, resolvedTag) {
661
- // Bail before touching podman — stop() may have already stopped the container.
662
- if (gen !== runGeneration)
663
- return;
664
- await containers.ensureRunning(CONTAINER_NAME, buildContainerConfig(resolvedTag), {
665
- onVolumeIssue
666
- });
667
- registerUpdateTracker(containers, settings);
668
- const addr = await resolveActualAddress(containers, (m) => {
669
- app.debug(m);
670
- });
671
- if (!addr) {
672
- throw new Error('Could not resolve container address');
673
- }
674
- // Re-check before publishing: stop() nulls containerAddress and the proxy reads it live — a stale attempt must not resurrect it.
675
- if (gen !== runGeneration)
676
- return;
677
- containerAddress = `http://${addr}`;
678
- const pending = new BackupClient(containerAddress);
679
- await pending.waitForReady(10_000);
680
- await finishStartup(gen, settings, pending, containerAddress);
681
- }
682
- // Runs until an attempt succeeds or the generation moves on; keeps the latest failure cause in the plugin status so the admin UI stays truthful while retrying.
683
- async function retryUntilReady(gen, label, attempt) {
684
- let delay = READY_RETRY_MIN_MS;
685
- for (let attemptNo = 1;; attemptNo++) {
686
- await sleep(delay);
687
- if (gen !== runGeneration)
688
- return;
689
- try {
690
- await attempt();
691
- return;
692
- }
693
- catch (err) {
694
- if (gen !== runGeneration)
695
- return;
696
- delay = Math.min(delay * 2, READY_RETRY_MAX_MS);
697
- app.debug(`readiness retry ${attemptNo} failed: ${errMsg(err)}`);
698
- app.setPluginError(`${label}: ${errMsg(err)} — retrying in ${Math.round(delay / 1000)}s`);
699
- }
700
- }
701
- }
702
- // "Update available" badges via signalk-container's central update service; guarded so readiness retries don't register duplicate trackers.
703
- function registerUpdateTracker(containers, settings) {
704
- if (updatesRegistered)
705
- return;
706
- try {
707
- containers.updates.register({
708
- pluginId: PLUGIN_ID,
709
- containerName: CONTAINER_NAME,
710
- image: BACKUP_IMAGE,
711
- currentTag: () => resolveImageTag(currentSettings?.imageTag ?? settings.imageTag),
712
- versionSource: containers.updates.sources.githubReleases('dirkwa/signalk-backup-server')
713
- });
714
- updatesRegistered = true;
715
- }
716
- catch (err) {
717
- app.debug(`updates.register failed (non-fatal): ${errMsg(err)}`);
718
- }
719
- }
720
481
  /**
721
482
  * Start the database-export interval, if any DB exporter is enabled in
722
483
  * settings. The first export fires after one full interval — not at