signalk-backup 0.9.4 → 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 (91) hide show
  1. package/package.json +4 -3
  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 +38 -4
  5. package/plugin/backup-client.js.map +1 -1
  6. package/plugin/database-export/grafana.d.ts.map +1 -1
  7. package/plugin/database-export/grafana.js +1 -3
  8. package/plugin/database-export/grafana.js.map +1 -1
  9. package/plugin/database-export/index.d.ts.map +1 -1
  10. package/plugin/database-export/index.js +3 -3
  11. package/plugin/database-export/index.js.map +1 -1
  12. package/plugin/database-export/questdb.d.ts.map +1 -1
  13. package/plugin/database-export/questdb.js +1 -3
  14. package/plugin/database-export/questdb.js.map +1 -1
  15. package/plugin/database-export/signalk-database.d.ts.map +1 -1
  16. package/plugin/database-export/signalk-database.js +1 -3
  17. package/plugin/database-export/signalk-database.js.map +1 -1
  18. package/plugin/database-export/staging-routes.d.ts.map +1 -1
  19. package/plugin/database-export/staging-routes.js +1 -3
  20. package/plugin/database-export/staging-routes.js.map +1 -1
  21. package/plugin/errors.d.ts +2 -0
  22. package/plugin/errors.d.ts.map +1 -0
  23. package/plugin/errors.js +25 -0
  24. package/plugin/errors.js.map +1 -0
  25. package/plugin/index.d.ts.map +1 -1
  26. package/plugin/index.js +154 -311
  27. package/plugin/index.js.map +1 -1
  28. package/plugin/proxy.d.ts.map +1 -1
  29. package/plugin/proxy.js +1 -3
  30. package/plugin/proxy.js.map +1 -1
  31. package/plugin/restore-host-write.d.ts.map +1 -1
  32. package/plugin/restore-host-write.js +1 -3
  33. package/plugin/restore-host-write.js.map +1 -1
  34. package/plugin/types.d.ts +1 -164
  35. package/plugin/types.d.ts.map +1 -1
  36. package/public/assets/App-DJTv4N42.js +3 -0
  37. package/public/assets/{App-Er5pmiQ3.js.map → App-DJTv4N42.js.map} +1 -1
  38. package/public/assets/AppPanel-BLjK4PTq.js +2 -0
  39. package/public/assets/AppPanel-BLjK4PTq.js.map +1 -0
  40. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-B9dkCYcU.js +1 -0
  41. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react__loadShare__.mjs-CRDWkJnD.js +1 -0
  42. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__-CZEusA_v.js +1 -0
  43. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_dev_mf_2_runtime__loadShare__.mjs-C5oceaDt.js +2 -0
  44. package/public/assets/_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 -0
  45. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__-Dh2Je4Q8.js +1 -0
  46. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BJsP72ov.js +2 -0
  47. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.mjs-BJsP72ov.js.map +1 -0
  48. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_2_dom__loadShare__.mjs-COVU9tAo.js +1 -0
  49. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-F9Hr43sq.js +1 -0
  50. package/public/assets/dist-Cr3RQpe1.js +5 -0
  51. package/public/assets/dist-Cr3RQpe1.js.map +1 -0
  52. package/public/assets/hostInit-CBfbRWDG.js +1 -0
  53. package/public/assets/index-eDLwC5ye.js +2 -0
  54. package/public/assets/index-eDLwC5ye.js.map +1 -0
  55. package/public/assets/jsx-dev-runtime-BvApC5zd.js +1 -0
  56. package/public/assets/jsx-runtime-4HRoLdzw.js +1 -0
  57. package/public/assets/main-pXUwEryv.js +2 -0
  58. package/public/assets/{main-Du2fwlp_.js.map → main-pXUwEryv.js.map} +1 -1
  59. package/public/assets/preload-helper-DCSm9-xi.js +1 -0
  60. package/public/assets/virtualExposes-B5IFEpCJ.js +2 -0
  61. package/public/assets/virtualExposes-B5IFEpCJ.js.map +1 -0
  62. package/public/index.html +1 -1
  63. package/public/mf-entry-bootstrap-0.js +17 -0
  64. package/public/remoteEntry.js +1 -1
  65. package/public/remoteEntry.js.map +1 -1
  66. package/public/assets/App-Er5pmiQ3.js +0 -3
  67. package/public/assets/AppPanel-Bm45xPqR.js +0 -2
  68. package/public/assets/AppPanel-Bm45xPqR.js.map +0 -1
  69. package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup__mf_owner__1-xuNj2MfB.js +0 -1
  70. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__mf_owner__1__loadShare__react__loadShare__.js-DUF5rz7t.js +0 -1
  71. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__mf_owner__1__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-z1_hkJ0O.js +0 -2
  72. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__mf_owner__1__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__.js-z1_hkJ0O.js.map +0 -1
  73. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__mf_owner__1__loadShare__react_mf_2_dom__loadShare__.js-DXBr3ZZh.js +0 -1
  74. package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__mf_owner__1__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-DXPnZd0o.js +0 -1
  75. package/public/assets/dist-Bibv8K2h.js +0 -5
  76. package/public/assets/dist-Bibv8K2h.js.map +0 -1
  77. package/public/assets/hostInit-Ba-iqzVX.js +0 -1
  78. package/public/assets/index-Bg1Dz2o0.js +0 -2
  79. package/public/assets/index-Bg1Dz2o0.js.map +0 -1
  80. package/public/assets/jsx-dev-runtime-BVMEoATa.js +0 -2
  81. package/public/assets/jsx-dev-runtime-BVMEoATa.js.map +0 -1
  82. package/public/assets/jsx-runtime-D2TqfpMv.js +0 -1
  83. package/public/assets/main-Du2fwlp_.js +0 -2
  84. package/public/assets/mf-entry-bootstrap-0-832b1316.js +0 -36
  85. package/public/assets/virtualExposes-rRSEXNaO.js +0 -2
  86. package/public/assets/virtualExposes-rRSEXNaO.js.map +0 -1
  87. package/public/assets/virtual_mf-exposes-ssr___mfe_internal__signalk-backup__remoteEntry_js-DACl3ZMG.js +0 -2
  88. package/public/assets/virtual_mf-exposes-ssr___mfe_internal__signalk-backup__remoteEntry_js-DACl3ZMG.js.map +0 -1
  89. package/public/assets/vite-preload-helper-Fl0ucuR7.js +0 -1
  90. package/public/remoteEntry.ssr.js +0 -2
  91. package/public/remoteEntry.ssr.js.map +0 -1
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';
@@ -10,13 +11,16 @@ import { registerStagingRoutes } from './database-export/staging-routes.js';
10
11
  import { registerHostRestoreRoutes } from './restore-host-write.js';
11
12
  import { startSignalKEmitter, stopSignalKEmitter } from './signalk-deltas.js';
12
13
  import { resolveSignalkBaseUrl as resolveBaseUrl } from './signalk-base-url.js';
14
+ import { errMsg } from './errors.js';
13
15
  const BACKUP_IMAGE = 'ghcr.io/dirkwa/signalk-backup-server';
14
16
  const CONTAINER_NAME = 'signalk-backup-server';
15
17
  const PLUGIN_ID = 'signalk-backup';
16
18
  const SK_MOUNT = '/signalk-data';
17
19
  const API_PORT = 3010;
18
20
  const OAUTH_PORT = 53682;
19
- const SAFE_TAG = /^[a-zA-Z0-9._-]+$/;
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
+ const READY_RETRY_MIN_MS = 15_000;
23
+ const READY_RETRY_MAX_MS = 120_000;
20
24
  /**
21
25
  * Sensible default resource limits for the backup-server container.
22
26
  * Backup work is mostly I/O bound (Kopia is content-addressable so it
@@ -39,89 +43,6 @@ const DEFAULT_RESOURCES = {
39
43
  memorySwap: '1g',
40
44
  pidsLimit: 100
41
45
  };
42
- function getContainerManager() {
43
- return globalThis.__signalk_containerManager;
44
- }
45
- /**
46
- * Wait for signalk-container's API to be FULLY ready on globalThis —
47
- * both the manager object exposed AND its runtime detection complete.
48
- *
49
- * signalk-container publishes `__signalk_containerManager` synchronously
50
- * during its own start() but kicks off `detectRuntime` async, so there's
51
- * a ~1-2s window where getRuntime() returns null. signalk-backup loads
52
- * BEFORE signalk-container alphabetically and races into that window
53
- * unless we wait for both signals.
54
- */
55
- async function waitForContainerManager(maxMs, intervalMs = 500) {
56
- const deadline = Date.now() + maxMs;
57
- while (Date.now() < deadline) {
58
- const m = getContainerManager();
59
- if (m && m.getRuntime())
60
- return m;
61
- await new Promise((r) => setTimeout(r, intervalMs));
62
- }
63
- return getContainerManager();
64
- }
65
- function errMsg(err) {
66
- return err instanceof Error ? err.message : String(err);
67
- }
68
- /**
69
- * Resolve the actual host:port the backup-server container is reachable at.
70
- *
71
- * `resolveContainerAddress` is the documented API and the authoritative
72
- * answer in every deployment shape signalk-container 1.7.0+ supports —
73
- * including the in-container "share SignalK's network namespace"
74
- * (`container:<self-id>`) path, where the right URL is `127.0.0.1:3010`
75
- * and no host-port mapping exists for `listContainers()` to parse.
76
- *
77
- * We still fall through to a `listContainers().ports` parse for the
78
- * legacy port-drift case that the previous order was guarding against:
79
- * a process-local port cache disagreeing with the live podman binding
80
- * (TOCTOU between the port probe and `podman create`). That happens in
81
- * bare-metal-SK deployments only — never when SK is containerized — so
82
- * trying the API first never trades correctness for safety.
83
- *
84
- * Returns null when neither path can produce an address (genuine
85
- * misconfiguration; the caller throws a user-visible error).
86
- */
87
- async function resolveActualAddress(containers, debug) {
88
- try {
89
- const apiAnswer = await containers.resolveContainerAddress(CONTAINER_NAME, API_PORT);
90
- if (apiAnswer)
91
- return apiAnswer;
92
- }
93
- catch (err) {
94
- debug?.(`resolveContainerAddress threw: ${err instanceof Error ? err.message : String(err)}`);
95
- }
96
- // Fallback: parse listContainers() ports for the legacy bare-metal-SK
97
- // port-drift case where the API's process-local cache could lag the
98
- // live podman binding. Not needed for in-container deployments —
99
- // signalk-container's `container:<self-id>` branch populates the API
100
- // with the correct loopback answer.
101
- try {
102
- const list = await containers.listContainers();
103
- const found = list.find((c) => c.name === `sk-${CONTAINER_NAME}`);
104
- if (found && Array.isArray(found.ports)) {
105
- // The shape ContainerInfo we mirror only declares { name, image, state },
106
- // but the runtime object includes a `ports` array of strings like
107
- // "127.0.0.1:3010->3010/tcp". Parse the binding for our API_PORT.
108
- const ports = found.ports;
109
- const wanted = `->${API_PORT}/tcp`;
110
- for (const entry of ports) {
111
- if (!entry.endsWith(wanted))
112
- continue;
113
- const hostPart = entry.slice(0, -wanted.length);
114
- // hostPart looks like "127.0.0.1:3010" or "0.0.0.0:3010".
115
- if (hostPart.includes(':'))
116
- return hostPart;
117
- }
118
- }
119
- }
120
- catch (err) {
121
- debug?.(`listContainers fallback threw: ${err instanceof Error ? err.message : String(err)}`);
122
- }
123
- return null;
124
- }
125
46
  export default function (app) {
126
47
  let client = null;
127
48
  let currentSettings = null;
@@ -130,6 +51,14 @@ export default function (app) {
130
51
  // In-flight tick handle so concurrent callers (scheduler + manual
131
52
  // backup) coalesce onto the same export rather than racing.
132
53
  let dbExportInFlight = null;
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;
133
62
  const buildContainerConfig = (tag) => ({
134
63
  image: BACKUP_IMAGE,
135
64
  tag,
@@ -184,6 +113,41 @@ export default function (app) {
184
113
  app.error(`mount aborted: ${issue.containerPath} (${issue.reason})`);
185
114
  }
186
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
+ });
187
151
  const plugin = {
188
152
  id: PLUGIN_ID,
189
153
  name: 'Backup',
@@ -206,159 +170,85 @@ export default function (app) {
206
170
  }
207
171
  };
208
172
  currentSettings = merged;
209
- void asyncStart(merged).catch((err) => {
210
- app.setPluginError(`Startup failed: ${errMsg(err)}`);
211
- });
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));
212
185
  },
213
186
  async stop() {
214
187
  app.debug('Stopping signalk-backup');
188
+ // Invalidate any pending startup/readiness-retry work.
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;
215
194
  stopSignalKEmitter();
216
195
  stopDbExportTimer();
217
196
  shutdownSmbDiscovery();
218
197
  client = null;
219
198
  containerAddress = null;
220
- const containers = getContainerManager();
221
- if (containers && currentSettings?.managedContainer !== false) {
222
- try {
223
- containers.updates.unregister(PLUGIN_ID);
224
- }
225
- catch (err) {
226
- app.debug(`Error unregistering update tracker: ${errMsg(err)}`);
227
- }
228
- try {
229
- await containers.stop(CONTAINER_NAME);
230
- }
231
- catch (err) {
232
- app.debug(`Error stopping ${CONTAINER_NAME}: ${errMsg(err)}`);
233
- }
199
+ if (currentSettings?.managedContainer !== false) {
200
+ // Unregisters updates and stops (not removes) the container; never throws.
201
+ await container.stop();
234
202
  }
235
203
  app.setPluginStatus('Stopped');
236
204
  },
237
205
  registerWithRouter(router) {
238
206
  // Lightweight readiness signal for admin/webapp badges.
239
207
  router.get('/status', async (_req, res) => {
240
- const containers = getContainerManager();
241
- let containerState = 'unknown';
242
- let containerImage = '';
243
- if (containers) {
244
- try {
245
- containerState = await containers.getState(CONTAINER_NAME);
246
- }
247
- catch (err) {
248
- app.debug(`status: getState failed: ${errMsg(err)}`);
249
- }
250
- if (containers.getRuntime()) {
251
- try {
252
- const list = await containers.listContainers();
253
- const found = list.find((c) => c.name === `sk-${CONTAINER_NAME}`);
254
- if (found)
255
- containerImage = found.image;
256
- }
257
- catch (err) {
258
- app.debug(`status: listContainers failed: ${errMsg(err)}`);
259
- }
260
- }
261
- }
262
- if (!containerImage) {
263
- containerImage = `${BACKUP_IMAGE}:${resolveImageTag(currentSettings?.imageTag ?? 'auto')}`;
264
- }
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')}`;
265
212
  // WHY: pathMapping.hostPath is operator-facing; resolveSignalkConfigRoot is SK-internal when SK is in a container.
266
213
  const managed = currentSettings?.managedContainer !== false;
267
214
  const pathMapping = managed
268
- ? { containerPath: SK_MOUNT, hostPath: await resolveSignalkConfigRootOnHost(containers) }
215
+ ? {
216
+ containerPath: SK_MOUNT,
217
+ hostPath: await resolveSignalkConfigRootOnHost(getContainerManager())
218
+ }
269
219
  : undefined;
270
220
  res.json({
271
221
  container: {
272
- state: containerState,
222
+ state,
273
223
  image: containerImage,
274
224
  managed
275
225
  },
276
- 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'),
277
228
  ...(pathMapping ? { pathMapping } : {})
278
229
  });
279
230
  });
280
231
  // Update detection — delegated to signalk-container's centralized
281
- // update service. Same pattern as mayara.
282
- router.get('/api/update/check', async (_req, res) => {
283
- const containers = getContainerManager();
284
- if (!containers) {
285
- res.status(503).json({ error: 'signalk-container not available' });
286
- return;
287
- }
288
- try {
289
- const result = await containers.updates.checkOne(PLUGIN_ID);
290
- res.json(result);
291
- }
292
- catch (err) {
293
- res.status(500).json({ error: errMsg(err) });
294
- }
295
- });
296
- router.post('/api/update/apply', async (req, res) => {
297
- const containers = getContainerManager();
298
- if (!containers) {
299
- res.status(503).json({ error: 'signalk-container not available' });
300
- return;
301
- }
302
- const body = (req.body ?? {});
303
- if ('tag' in body && typeof body.tag !== 'string') {
304
- res.status(400).json({ error: 'tag must be a string' });
305
- return;
306
- }
307
- const requestedTag = (typeof body.tag === 'string' ? body.tag : undefined) ??
308
- currentSettings?.imageTag ??
309
- 'auto';
310
- if (!SAFE_TAG.test(requestedTag)) {
311
- res.status(400).json({ error: 'Invalid tag format' });
312
- return;
313
- }
314
- const tag = resolveImageTag(requestedTag);
315
- try {
316
- app.setPluginStatus(`Recreating signalk-backup-server with ${BACKUP_IMAGE}:${tag}...`);
317
- if (containers.recreate) {
318
- // 1.12.0+ — single primitive handles pull + remove + create
319
- // with consistent error semantics (any failure leaves the
320
- // container in a known state, not the "removed but recreate
321
- // failed" limbo the old triplet could produce).
322
- await containers.recreate(CONTAINER_NAME, buildContainerConfig(tag), {
323
- onVolumeIssue
324
- });
325
- }
326
- else {
327
- // signalk-container < 1.12.0 fallback: original pull + remove
328
- // + ensureRunning triplet. Remove this branch when the plugin
329
- // bumps its minimum signalk-container version.
330
- await containers.pullImage(`${BACKUP_IMAGE}:${tag}`);
331
- await containers.remove(CONTAINER_NAME);
332
- try {
333
- await containers.ensureRunning(CONTAINER_NAME, buildContainerConfig(tag), {
334
- onVolumeIssue
335
- });
336
- }
337
- catch (recreateErr) {
338
- const msg = `Container removed but recreation failed: ${errMsg(recreateErr)}. Click Update again to retry.`;
339
- app.setPluginError(msg);
340
- res.status(500).json({ error: msg });
341
- return;
342
- }
343
- }
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) => {
344
236
  // Persist requestedTag not resolved tag: saving "auto" preserves auto-tracking across upgrades.
345
- if (currentSettings) {
346
- currentSettings.imageTag = requestedTag;
347
- await new Promise((resolve) => {
348
- app.savePluginOptions({ ...currentSettings }, (err) => {
349
- if (err) {
350
- app.error(`Failed to persist new tag: ${errMsg(err)}. Container is running with ${tag} but a plugin restart will revert.`);
351
- }
352
- resolve();
353
- });
354
- });
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;
355
242
  }
356
- app.setPluginStatus(`Updated to ${BACKUP_IMAGE}:${tag}`);
357
- res.json({ success: true, tag });
358
- }
359
- catch (err) {
360
- app.setPluginError(`Update failed: ${errMsg(err)}`);
361
- 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
+ });
362
252
  }
363
253
  });
364
254
  // Plugin owns the timer; backup-server only consumes the Parquet
@@ -511,7 +401,7 @@ export default function (app) {
511
401
  });
512
402
  }
513
403
  };
514
- async function asyncStart(settings) {
404
+ async function asyncStart(settings, gen, signal) {
515
405
  if (!settings.managedContainer) {
516
406
  // External-server mode: skip the container, point at user-provided URL.
517
407
  const url = settings.externalUrl.trim();
@@ -519,116 +409,69 @@ export default function (app) {
519
409
  app.setPluginError('managedContainer is disabled but externalUrl is empty. Set externalUrl in plugin config.');
520
410
  return;
521
411
  }
522
- client = new BackupClient(url);
412
+ const external = new BackupClient(url);
413
+ client = external;
523
414
  // Keep the scheme so the proxy can route HTTPS external upstreams.
524
415
  containerAddress = url;
525
- try {
526
- await client.waitForReady(15_000);
527
- app.setPluginStatus(`Connected to external backup-server at ${url}`);
528
- await seedFirstRunSchedule(client);
529
- startSignalKEmitter(app, url, { emitSignalKDeltas: settings.emitSignalKDeltas });
530
- if (settings.databaseExport.questdb ||
531
- settings.databaseExport.grafana ||
532
- settings.databaseExport.signalkDatabase) {
533
- app.setPluginStatus(`Connected to external backup-server. Note: database export ` +
534
- `requires managed-container mode and was skipped.`);
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)
420
+ return;
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`);
535
430
  }
536
- }
537
- catch (err) {
538
- app.setPluginError(`External backup-server unreachable: ${errMsg(err)}`);
539
- }
431
+ });
540
432
  return;
541
433
  }
542
- // signalk-container exposes its API via globalThis only after its own
543
- // start() has finished. Plugin-load order is alphabetical, and
544
- // "signalk-backup" comes before "signalk-container" — so on a cold
545
- // server start, getContainerManager() is undefined for the first few
546
- // hundred ms. Poll for up to 30s before giving up.
547
- const containers = await waitForContainerManager(120_000);
548
- if (!containers) {
549
- app.setPluginError('signalk-container plugin not available after 120s. Install and enable it, then restart this plugin.');
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)
550
439
  return;
551
- }
552
- if (!containers.getRuntime()) {
553
- app.setPluginError('No container runtime detected (Podman or Docker). Install one and restart signalk-container.');
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);
448
+ }
449
+ // Generation-guarded so a stop()/restart during a slow health probe can't resurrect stale timers or emitters.
450
+ async function finishStartup(gen, settings, pending, address) {
451
+ if (gen !== lifecycleGeneration)
554
452
  return;
555
- }
556
- if (!SAFE_TAG.test(settings.imageTag)) {
557
- app.setPluginError(`Invalid imageTag "${settings.imageTag}"`);
453
+ client = pending;
454
+ await seedFirstRunSchedule(pending);
455
+ if (gen !== lifecycleGeneration)
558
456
  return;
559
- }
560
- const resolvedTag = resolveImageTag(settings.imageTag);
561
- try {
562
- // Self-heal: if a live container exists with a different image
563
- // than the just-resolved tag (typical after a plugin upgrade that
564
- // bumped BACKUP_SERVER_VERSION), force-recreate via the explicit
565
- // primitive rather than relying on ensureRunning's drift detector.
566
- // Requires signalk-container >= 1.12.0; older installs fall through
567
- // to ensureRunning which still recreates on drift in 1.6.0+.
568
- const desiredImage = `${BACKUP_IMAGE}:${resolvedTag}`;
569
- let usedRecreate = false;
570
- if (containers.recreate) {
571
- try {
572
- const live = await containers.listContainers();
573
- const found = live.find((c) => c.name === `sk-${CONTAINER_NAME}`);
574
- if (found && found.image !== desiredImage) {
575
- app.setPluginStatus(`Recreating ${found.image} → ${desiredImage}...`);
576
- await containers.recreate(CONTAINER_NAME, buildContainerConfig(resolvedTag), {
577
- onVolumeIssue
578
- });
579
- usedRecreate = true;
580
- }
581
- }
582
- catch (probeErr) {
583
- app.debug(`self-heal probe failed (non-fatal): ${errMsg(probeErr)}`);
584
- }
585
- }
586
- if (!usedRecreate) {
587
- app.setPluginStatus(`Starting ${desiredImage}...`);
588
- await containers.ensureRunning(CONTAINER_NAME, buildContainerConfig(resolvedTag), {
589
- onVolumeIssue
590
- });
591
- }
592
- // Register with the central update service so users see "update available"
593
- // badges in the signalk-container config panel without us writing custom
594
- // GitHub-poll logic.
595
- try {
596
- containers.updates.register({
597
- pluginId: PLUGIN_ID,
598
- containerName: CONTAINER_NAME,
599
- image: BACKUP_IMAGE,
600
- currentTag: () => resolveImageTag(currentSettings?.imageTag ?? settings.imageTag),
601
- versionSource: containers.updates.sources.githubReleases('dirkwa/signalk-backup-server')
602
- });
603
- }
604
- catch (err) {
605
- app.debug(`updates.register failed (non-fatal): ${errMsg(err)}`);
606
- }
607
- const addr = await resolveActualAddress(containers, (m) => {
608
- app.debug(m);
609
- });
610
- if (!addr) {
611
- throw new Error('Could not resolve container address');
612
- }
613
- // Container is always plain HTTP on the host loopback; storing the
614
- // full URL keeps the format consistent with external-mode (which
615
- // may be HTTPS) so the proxy can use the same value as-is.
616
- containerAddress = `http://${addr}`;
617
- // `client` stays null until /api/health succeeds, so /status's
618
- // `ready: client !== null` reports the truthful upstream-reachable
619
- // signal rather than just "we know the address."
620
- const pending = new BackupClient(containerAddress);
621
- app.setPluginStatus('Waiting for backup-server to become ready...');
622
- await pending.waitForReady(60_000);
623
- client = pending;
624
- await seedFirstRunSchedule(client);
625
- startSignalKEmitter(app, containerAddress, {
626
- emitSignalKDeltas: settings.emitSignalKDeltas
627
- });
628
- startDbExportTimer();
629
- }
630
- catch (err) {
631
- app.setPluginError(`Container startup failed: ${errMsg(err)}`);
457
+ startSignalKEmitter(app, address, {
458
+ emitSignalKDeltas: settings.emitSignalKDeltas
459
+ });
460
+ startDbExportTimer();
461
+ }
462
+ async function finishExternal(gen, settings, c, url) {
463
+ if (gen !== lifecycleGeneration)
464
+ return;
465
+ app.setPluginStatus(`Connected to external backup-server at ${url}`);
466
+ await seedFirstRunSchedule(c);
467
+ if (gen !== lifecycleGeneration)
468
+ return;
469
+ startSignalKEmitter(app, url, { emitSignalKDeltas: settings.emitSignalKDeltas });
470
+ if (settings.databaseExport.questdb ||
471
+ settings.databaseExport.grafana ||
472
+ settings.databaseExport.signalkDatabase) {
473
+ app.setPluginStatus(`Connected to external backup-server. Note: database export ` +
474
+ `requires managed-container mode and was skipped.`);
632
475
  }
633
476
  }
634
477
  /**