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.
- package/package.json +2 -1
- package/plugin/backup-client.d.ts +1 -1
- package/plugin/backup-client.d.ts.map +1 -1
- package/plugin/backup-client.js +28 -3
- package/plugin/backup-client.js.map +1 -1
- package/plugin/index.d.ts.map +1 -1
- package/plugin/index.js +126 -369
- package/plugin/index.js.map +1 -1
- package/plugin/types.d.ts +1 -164
- package/plugin/types.d.ts.map +1 -1
- package/public/assets/{App-DtlidnzY.js → App-DJTv4N42.js} +3 -3
- package/public/assets/{App-DtlidnzY.js.map → App-DJTv4N42.js.map} +1 -1
- package/public/assets/AppPanel-BLjK4PTq.js +2 -0
- package/public/assets/{AppPanel-BsqUab7V.js.map → AppPanel-BLjK4PTq.js.map} +1 -1
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/public/assets/{dist-DLVcTB4j.js → dist-Cr3RQpe1.js} +4 -4
- package/public/assets/{dist-DLVcTB4j.js.map → dist-Cr3RQpe1.js.map} +1 -1
- package/public/assets/{hostInit-BW_bT2un.js → hostInit-CBfbRWDG.js} +1 -1
- package/public/assets/{index-CeoRFgVj.js → index-eDLwC5ye.js} +2 -2
- package/public/assets/{index-CeoRFgVj.js.map → index-eDLwC5ye.js.map} +1 -1
- package/public/assets/{jsx-dev-runtime-BmqEwJcR.js → jsx-dev-runtime-BvApC5zd.js} +1 -1
- package/public/assets/jsx-runtime-4HRoLdzw.js +1 -0
- package/public/assets/main-pXUwEryv.js +2 -0
- package/public/assets/{main-B1uZZe5-.js.map → main-pXUwEryv.js.map} +1 -1
- package/public/assets/{virtualExposes-Do1tdq-X.js → virtualExposes-B5IFEpCJ.js} +2 -2
- package/public/assets/{virtualExposes-Do1tdq-X.js.map → virtualExposes-B5IFEpCJ.js.map} +1 -1
- package/public/mf-entry-bootstrap-0.js +2 -2
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/assets/AppPanel-BsqUab7V.js +0 -2
- package/public/assets/jsx-runtime-uNZh9WvZ.js +0 -1
- package/public/assets/main-B1uZZe5-.js +0 -2
- /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
- /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
- /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
- /package/public/assets/{preload-helper-DDsTugmO.js → preload-helper-DCSm9-xi.js} +0 -0
- /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
|
|
141
|
-
|
|
142
|
-
|
|
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 = ++
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
? {
|
|
215
|
+
? {
|
|
216
|
+
containerPath: SK_MOUNT,
|
|
217
|
+
hostPath: await resolveSignalkConfigRootOnHost(getContainerManager())
|
|
218
|
+
}
|
|
283
219
|
: undefined;
|
|
284
220
|
res.json({
|
|
285
221
|
container: {
|
|
286
|
-
state
|
|
222
|
+
state,
|
|
287
223
|
image: containerImage,
|
|
288
224
|
managed
|
|
289
225
|
},
|
|
290
|
-
|
|
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.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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 !==
|
|
451
|
+
if (gen !== lifecycleGeneration)
|
|
634
452
|
return;
|
|
635
453
|
client = pending;
|
|
636
454
|
await seedFirstRunSchedule(pending);
|
|
637
|
-
if (gen !==
|
|
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 !==
|
|
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 !==
|
|
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
|