signalk-backup 0.9.5 → 0.10.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.
Files changed (43) 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 +126 -369
  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-DJTv4N42.js} +3 -3
  12. package/public/assets/{App-DtlidnzY.js.map → App-DJTv4N42.js.map} +1 -1
  13. package/public/assets/AppPanel-BLjK4PTq.js +2 -0
  14. package/public/assets/{AppPanel-BsqUab7V.js.map → AppPanel-BLjK4PTq.js.map} +1 -1
  15. package/public/assets/{_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-C2n2hIk1.js → _virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-B9dkCYcU.js} +1 -1
  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-eDLwC5ye.js} +2 -2
  26. package/public/assets/{index-CeoRFgVj.js.map → index-eDLwC5ye.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-pXUwEryv.js +2 -0
  30. package/public/assets/{main-B1uZZe5-.js.map → main-pXUwEryv.js.map} +1 -1
  31. package/public/assets/{virtualExposes-Do1tdq-X.js → virtualExposes-B5IFEpCJ.js} +2 -2
  32. package/public/assets/{virtualExposes-Do1tdq-X.js.map → virtualExposes-B5IFEpCJ.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/jsx-runtime-uNZh9WvZ.js +0 -1
  38. package/public/assets/main-B1uZZe5-.js +0 -2
  39. /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
  40. /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
  41. /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
  42. /package/public/assets/{preload-helper-DDsTugmO.js → preload-helper-DCSm9-xi.js} +0 -0
  43. /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,85 @@ 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
+ // startSafely: SignalK does not await start(), and it demotes the
182
+ // helper's `cancelled` error to debug so stopping mid-startup doesn't
183
+ // leave "Startup failed: Operation cancelled" in the plugin error box.
184
+ startSafely(app, () => asyncStart(merged, gen, abort.signal));
223
185
  },
224
186
  async stop() {
225
187
  app.debug('Stopping signalk-backup');
226
188
  // Invalidate any pending startup/readiness-retry work.
227
- runGeneration++;
189
+ lifecycleGeneration++;
190
+ // Abort BEFORE awaiting the stop: the readiness retry loop is otherwise
191
+ // free to queue another attempt behind it.
192
+ startAbort?.abort();
193
+ startAbort = null;
228
194
  stopSignalKEmitter();
229
195
  stopDbExportTimer();
230
196
  shutdownSmbDiscovery();
231
197
  client = null;
232
198
  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
- }
199
+ if (currentSettings?.managedContainer !== false) {
200
+ // Unregisters updates and stops (not removes) the container; never throws.
201
+ await container.stop();
248
202
  }
249
203
  app.setPluginStatus('Stopped');
250
204
  },
251
205
  registerWithRouter(router) {
252
206
  // Lightweight readiness signal for admin/webapp badges.
253
207
  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
- }
208
+ // getInfo() never throws: 'unknown' state and an empty image when the
209
+ // manager or runtime is absent.
210
+ const { state, image } = await container.getInfo();
211
+ const containerImage = image || `${BACKUP_IMAGE}:${resolveImageTag(currentSettings?.imageTag ?? 'auto')}`;
279
212
  // WHY: pathMapping.hostPath is operator-facing; resolveSignalkConfigRoot is SK-internal when SK is in a container.
280
213
  const managed = currentSettings?.managedContainer !== false;
281
214
  const pathMapping = managed
282
- ? { containerPath: SK_MOUNT, hostPath: await resolveSignalkConfigRootOnHost(containers) }
215
+ ? {
216
+ containerPath: SK_MOUNT,
217
+ hostPath: await resolveSignalkConfigRootOnHost(getContainerManager())
218
+ }
283
219
  : undefined;
284
220
  res.json({
285
221
  container: {
286
- state: containerState,
222
+ state,
287
223
  image: containerImage,
288
224
  managed
289
225
  },
290
- ready: client !== null,
226
+ // Managed mode ANDs in the live state: `client` is cleared only in stop(), so on its own it kept reporting ready for a container that had gone away (#103).
227
+ ready: client !== null && (!managed || state === 'running'),
291
228
  ...(pathMapping ? { pathMapping } : {})
292
229
  });
293
230
  });
294
231
  // 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
- }
232
+ // update service. Mounts GET /api/update/check and POST
233
+ // /api/update/apply the same paths the webapp already calls.
234
+ container.registerUpdateRoutes(router, {
235
+ onApplied: async (requestedTag, resolvedTag) => {
358
236
  // 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
- });
237
+ if (!currentSettings) {
238
+ // Both dropped-persistence paths log: silently skipping would let
239
+ // a restart revert the update with nothing in the log to explain it.
240
+ app.error(`Updated to ${resolvedTag} but could not persist "${requestedTag}": plugin settings not loaded. A plugin restart will revert.`);
241
+ return;
369
242
  }
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) });
243
+ currentSettings.imageTag = requestedTag;
244
+ await new Promise((resolve) => {
245
+ app.savePluginOptions({ ...currentSettings }, (err) => {
246
+ if (err) {
247
+ app.error(`Failed to persist new tag: ${errMsg(err)}. Container is running with ${resolvedTag} but a plugin restart will revert.`);
248
+ }
249
+ resolve();
250
+ });
251
+ });
376
252
  }
377
253
  });
378
254
  // Plugin owns the timer; backup-server only consumes the Parquet
@@ -525,7 +401,7 @@ export default function (app) {
525
401
  });
526
402
  }
527
403
  };
528
- async function asyncStart(settings, gen) {
404
+ async function asyncStart(settings, gen, signal) {
529
405
  if (!settings.managedContainer) {
530
406
  // External-server mode: skip the container, point at user-provided URL.
531
407
  const url = settings.externalUrl.trim();
@@ -537,104 +413,46 @@ export default function (app) {
537
413
  client = external;
538
414
  // Keep the scheme so the proxy can route HTTPS external upstreams.
539
415
  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)
416
+ // No container here, so the helper's ManagedContainer has nothing to do —
417
+ // but the retry policy is the same, so reuse its primitive directly.
418
+ await retryForever(async () => {
419
+ if (gen !== lifecycleGeneration)
546
420
  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)}`);
421
+ // retryForever only checks between attempts, so the signal must reach the probe itself.
422
+ await external.waitForReady(15_000, 1000, signal);
423
+ await finishExternal(gen, settings, external, url);
424
+ }, {
425
+ minDelayMs: READY_RETRY_MIN_MS,
426
+ maxDelayMs: READY_RETRY_MAX_MS,
427
+ signal,
428
+ onAttemptFailed: (err, nextDelayMs) => {
429
+ app.setPluginError(`External backup-server at ${url} unreachable: ${errMsg(err)} retrying in ${Math.round(nextDelayMs / 1000)}s`);
597
430
  }
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
431
  });
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));
432
+ return;
629
433
  }
434
+ // Managed-container mode. readinessRetry means this resolves only once the
435
+ // container answers /api/health, or rejects on cancellation — it does not
436
+ // give up and leave the plugin wedged.
437
+ const { address } = await container.start(settings.imageTag, { signal });
438
+ if (gen !== lifecycleGeneration)
439
+ return;
440
+ if (!address)
441
+ throw new Error('Could not resolve container address');
442
+ // Full URL keeps the format consistent with external mode (which may be
443
+ // HTTPS) so the proxy can use the value as-is.
444
+ containerAddress = address;
445
+ // Managed mode assigns `client` only after /api/health answers, so /status reports having reached the upstream rather than just knowing its address.
446
+ const pending = new BackupClient(address);
447
+ await finishStartup(gen, settings, pending, address);
630
448
  }
631
449
  // Generation-guarded so a stop()/restart during a slow health probe can't resurrect stale timers or emitters.
632
450
  async function finishStartup(gen, settings, pending, address) {
633
- if (gen !== runGeneration)
451
+ if (gen !== lifecycleGeneration)
634
452
  return;
635
453
  client = pending;
636
454
  await seedFirstRunSchedule(pending);
637
- if (gen !== runGeneration)
455
+ if (gen !== lifecycleGeneration)
638
456
  return;
639
457
  startSignalKEmitter(app, address, {
640
458
  emitSignalKDeltas: settings.emitSignalKDeltas
@@ -642,11 +460,11 @@ export default function (app) {
642
460
  startDbExportTimer();
643
461
  }
644
462
  async function finishExternal(gen, settings, c, url) {
645
- if (gen !== runGeneration)
463
+ if (gen !== lifecycleGeneration)
646
464
  return;
647
465
  app.setPluginStatus(`Connected to external backup-server at ${url}`);
648
466
  await seedFirstRunSchedule(c);
649
- if (gen !== runGeneration)
467
+ if (gen !== lifecycleGeneration)
650
468
  return;
651
469
  startSignalKEmitter(app, url, { emitSignalKDeltas: settings.emitSignalKDeltas });
652
470
  if (settings.databaseExport.questdb ||
@@ -656,67 +474,6 @@ export default function (app) {
656
474
  `requires managed-container mode and was skipped.`);
657
475
  }
658
476
  }
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
477
  /**
721
478
  * Start the database-export interval, if any DB exporter is enabled in
722
479
  * settings. The first export fires after one full interval — not at