signalk-backup 0.9.5 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +132 -371
- 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-BDxkTxvV.js} +3 -3
- package/public/assets/{App-DtlidnzY.js.map → App-BDxkTxvV.js.map} +1 -1
- package/public/assets/AppPanel-_OFBBplE.js +2 -0
- package/public/assets/{AppPanel-BsqUab7V.js.map → AppPanel-_OFBBplE.js.map} +1 -1
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-BTXSQ5YD.js +1 -0
- 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-D03Kssc_.js} +2 -2
- package/public/assets/{index-CeoRFgVj.js.map → index-D03Kssc_.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-XRgh0Npp.js +2 -0
- package/public/assets/{main-B1uZZe5-.js.map → main-XRgh0Npp.js.map} +1 -1
- package/public/assets/{virtualExposes-Do1tdq-X.js → virtualExposes-PNn3gXrV.js} +2 -2
- package/public/assets/{virtualExposes-Do1tdq-X.js.map → virtualExposes-PNn3gXrV.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/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-C2n2hIk1.js +0 -1
- 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,88 @@ 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
|
+
// SignalK re-runs start() on a config save with no stop(), so keeping these reported the previous upstream's readiness while connecting to a new one.
|
|
182
|
+
client = null;
|
|
183
|
+
containerAddress = null;
|
|
184
|
+
// startSafely: SignalK does not await start(), and it demotes the
|
|
185
|
+
// helper's `cancelled` error to debug so stopping mid-startup doesn't
|
|
186
|
+
// leave "Startup failed: Operation cancelled" in the plugin error box.
|
|
187
|
+
startSafely(app, () => asyncStart(merged, gen, abort.signal));
|
|
223
188
|
},
|
|
224
189
|
async stop() {
|
|
225
190
|
app.debug('Stopping signalk-backup');
|
|
226
191
|
// Invalidate any pending startup/readiness-retry work.
|
|
227
|
-
|
|
192
|
+
lifecycleGeneration++;
|
|
193
|
+
// Abort BEFORE awaiting the stop: the readiness retry loop is otherwise
|
|
194
|
+
// free to queue another attempt behind it.
|
|
195
|
+
startAbort?.abort();
|
|
196
|
+
startAbort = null;
|
|
228
197
|
stopSignalKEmitter();
|
|
229
198
|
stopDbExportTimer();
|
|
230
199
|
shutdownSmbDiscovery();
|
|
231
200
|
client = null;
|
|
232
201
|
containerAddress = null;
|
|
233
|
-
|
|
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
|
-
}
|
|
202
|
+
if (currentSettings?.managedContainer !== false) {
|
|
203
|
+
// Unregisters updates and stops (not removes) the container; never throws.
|
|
204
|
+
await container.stop();
|
|
248
205
|
}
|
|
249
206
|
app.setPluginStatus('Stopped');
|
|
250
207
|
},
|
|
251
208
|
registerWithRouter(router) {
|
|
252
209
|
// Lightweight readiness signal for admin/webapp badges.
|
|
253
210
|
router.get('/status', async (_req, res) => {
|
|
254
|
-
|
|
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
|
-
}
|
|
211
|
+
// getInfo() never throws: 'unknown' state and an empty image when the
|
|
212
|
+
// manager or runtime is absent.
|
|
213
|
+
const { state, image } = await container.getInfo();
|
|
214
|
+
const containerImage = image || `${BACKUP_IMAGE}:${resolveImageTag(currentSettings?.imageTag ?? 'auto')}`;
|
|
279
215
|
// WHY: pathMapping.hostPath is operator-facing; resolveSignalkConfigRoot is SK-internal when SK is in a container.
|
|
280
216
|
const managed = currentSettings?.managedContainer !== false;
|
|
281
217
|
const pathMapping = managed
|
|
282
|
-
? {
|
|
218
|
+
? {
|
|
219
|
+
containerPath: SK_MOUNT,
|
|
220
|
+
hostPath: await resolveSignalkConfigRootOnHost(getContainerManager())
|
|
221
|
+
}
|
|
283
222
|
: undefined;
|
|
284
223
|
res.json({
|
|
285
224
|
container: {
|
|
286
|
-
state
|
|
225
|
+
state,
|
|
287
226
|
image: containerImage,
|
|
288
227
|
managed
|
|
289
228
|
},
|
|
290
|
-
|
|
229
|
+
// `client` is published only once the upstream has answered, in both modes. Managed mode also ANDs in the live state, since `client` is cleared only in stop() and so kept reporting ready for a container that had gone away (#103).
|
|
230
|
+
ready: client !== null && (!managed || state === 'running'),
|
|
291
231
|
...(pathMapping ? { pathMapping } : {})
|
|
292
232
|
});
|
|
293
233
|
});
|
|
294
234
|
// Update detection — delegated to signalk-container's centralized
|
|
295
|
-
// update service.
|
|
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
|
-
}
|
|
235
|
+
// update service. Mounts GET /api/update/check and POST
|
|
236
|
+
// /api/update/apply — the same paths the webapp already calls.
|
|
237
|
+
container.registerUpdateRoutes(router, {
|
|
238
|
+
onApplied: async (requestedTag, resolvedTag) => {
|
|
358
239
|
// Persist requestedTag not resolved tag: saving "auto" preserves auto-tracking across upgrades.
|
|
359
|
-
if (currentSettings) {
|
|
360
|
-
|
|
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
|
-
});
|
|
240
|
+
if (!currentSettings) {
|
|
241
|
+
// Both dropped-persistence paths log: silently skipping would let
|
|
242
|
+
// a restart revert the update with nothing in the log to explain it.
|
|
243
|
+
app.error(`Updated to ${resolvedTag} but could not persist "${requestedTag}": plugin settings not loaded. A plugin restart will revert.`);
|
|
244
|
+
return;
|
|
369
245
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
246
|
+
currentSettings.imageTag = requestedTag;
|
|
247
|
+
await new Promise((resolve) => {
|
|
248
|
+
app.savePluginOptions({ ...currentSettings }, (err) => {
|
|
249
|
+
if (err) {
|
|
250
|
+
app.error(`Failed to persist new tag: ${errMsg(err)}. Container is running with ${resolvedTag} but a plugin restart will revert.`);
|
|
251
|
+
}
|
|
252
|
+
resolve();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
376
255
|
}
|
|
377
256
|
});
|
|
378
257
|
// Plugin owns the timer; backup-server only consumes the Parquet
|
|
@@ -525,7 +404,7 @@ export default function (app) {
|
|
|
525
404
|
});
|
|
526
405
|
}
|
|
527
406
|
};
|
|
528
|
-
async function asyncStart(settings, gen) {
|
|
407
|
+
async function asyncStart(settings, gen, signal) {
|
|
529
408
|
if (!settings.managedContainer) {
|
|
530
409
|
// External-server mode: skip the container, point at user-provided URL.
|
|
531
410
|
const url = settings.externalUrl.trim();
|
|
@@ -534,107 +413,48 @@ export default function (app) {
|
|
|
534
413
|
return;
|
|
535
414
|
}
|
|
536
415
|
const external = new BackupClient(url);
|
|
537
|
-
client
|
|
538
|
-
// Keep the scheme so the proxy can route HTTPS external upstreams.
|
|
416
|
+
// Only the address is published here, scheme included so the proxy can route HTTPS upstreams; `client` waits for finishExternal, or /status would report ready before the upstream had answered.
|
|
539
417
|
containerAddress = url;
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
catch (err) {
|
|
545
|
-
if (gen !== runGeneration)
|
|
418
|
+
// No container here, so the helper's ManagedContainer has nothing to do —
|
|
419
|
+
// but the retry policy is the same, so reuse its primitive directly.
|
|
420
|
+
await retryForever(async () => {
|
|
421
|
+
if (gen !== lifecycleGeneration)
|
|
546
422
|
return;
|
|
547
|
-
|
|
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)}`);
|
|
423
|
+
// retryForever only checks between attempts, so the signal must reach the probe itself.
|
|
424
|
+
await external.waitForReady(15_000, 1000, signal);
|
|
425
|
+
await finishExternal(gen, settings, external, url);
|
|
426
|
+
}, {
|
|
427
|
+
minDelayMs: READY_RETRY_MIN_MS,
|
|
428
|
+
maxDelayMs: READY_RETRY_MAX_MS,
|
|
429
|
+
signal,
|
|
430
|
+
onAttemptFailed: (err, nextDelayMs) => {
|
|
431
|
+
app.setPluginError(`External backup-server at ${url} unreachable: ${errMsg(err)} — retrying in ${Math.round(nextDelayMs / 1000)}s`);
|
|
597
432
|
}
|
|
598
|
-
}
|
|
599
|
-
if (!usedRecreate) {
|
|
600
|
-
app.setPluginStatus(`Starting ${desiredImage}...`);
|
|
601
|
-
await containers.ensureRunning(CONTAINER_NAME, buildContainerConfig(resolvedTag), {
|
|
602
|
-
onVolumeIssue
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
registerUpdateTracker(containers, settings);
|
|
606
|
-
const addr = await resolveActualAddress(containers, (m) => {
|
|
607
|
-
app.debug(m);
|
|
608
433
|
});
|
|
609
|
-
|
|
610
|
-
throw new Error('Could not resolve container address');
|
|
611
|
-
}
|
|
612
|
-
// Container is always plain HTTP on the host loopback; storing the
|
|
613
|
-
// full URL keeps the format consistent with external-mode (which
|
|
614
|
-
// may be HTTPS) so the proxy can use the same value as-is.
|
|
615
|
-
containerAddress = `http://${addr}`;
|
|
616
|
-
// `client` stays null until /api/health succeeds, so /status's
|
|
617
|
-
// `ready: client !== null` reports the truthful upstream-reachable
|
|
618
|
-
// signal rather than just "we know the address."
|
|
619
|
-
const pending = new BackupClient(containerAddress);
|
|
620
|
-
app.setPluginStatus('Waiting for backup-server to become ready...');
|
|
621
|
-
await pending.waitForReady(60_000);
|
|
622
|
-
await finishStartup(gen, settings, pending, containerAddress);
|
|
623
|
-
}
|
|
624
|
-
catch (err) {
|
|
625
|
-
if (gen !== runGeneration)
|
|
626
|
-
return;
|
|
627
|
-
app.setPluginError(`Container startup failed: ${errMsg(err)} — retrying in ${READY_RETRY_MIN_MS / 1000}s`);
|
|
628
|
-
void retryUntilReady(gen, 'Container startup failed', () => managedReadyAttempt(gen, settings, containers, resolvedTag));
|
|
434
|
+
return;
|
|
629
435
|
}
|
|
436
|
+
// Managed-container mode. readinessRetry means this resolves only once the
|
|
437
|
+
// container answers /api/health, or rejects on cancellation — it does not
|
|
438
|
+
// give up and leave the plugin wedged.
|
|
439
|
+
const { address } = await container.start(settings.imageTag, { signal });
|
|
440
|
+
if (gen !== lifecycleGeneration)
|
|
441
|
+
return;
|
|
442
|
+
if (!address)
|
|
443
|
+
throw new Error('Could not resolve container address');
|
|
444
|
+
// Full URL keeps the format consistent with external mode (which may be
|
|
445
|
+
// HTTPS) so the proxy can use the value as-is.
|
|
446
|
+
containerAddress = address;
|
|
447
|
+
// Managed mode assigns `client` only after /api/health answers, so /status reports having reached the upstream rather than just knowing its address.
|
|
448
|
+
const pending = new BackupClient(address);
|
|
449
|
+
await finishStartup(gen, settings, pending, address);
|
|
630
450
|
}
|
|
631
451
|
// Generation-guarded so a stop()/restart during a slow health probe can't resurrect stale timers or emitters.
|
|
632
452
|
async function finishStartup(gen, settings, pending, address) {
|
|
633
|
-
if (gen !==
|
|
453
|
+
if (gen !== lifecycleGeneration)
|
|
634
454
|
return;
|
|
635
455
|
client = pending;
|
|
636
456
|
await seedFirstRunSchedule(pending);
|
|
637
|
-
if (gen !==
|
|
457
|
+
if (gen !== lifecycleGeneration)
|
|
638
458
|
return;
|
|
639
459
|
startSignalKEmitter(app, address, {
|
|
640
460
|
emitSignalKDeltas: settings.emitSignalKDeltas
|
|
@@ -642,11 +462,13 @@ export default function (app) {
|
|
|
642
462
|
startDbExportTimer();
|
|
643
463
|
}
|
|
644
464
|
async function finishExternal(gen, settings, c, url) {
|
|
645
|
-
if (gen !==
|
|
465
|
+
if (gen !== lifecycleGeneration)
|
|
646
466
|
return;
|
|
467
|
+
// Deferred to here so ready cannot be true before the upstream answered.
|
|
468
|
+
client = c;
|
|
647
469
|
app.setPluginStatus(`Connected to external backup-server at ${url}`);
|
|
648
470
|
await seedFirstRunSchedule(c);
|
|
649
|
-
if (gen !==
|
|
471
|
+
if (gen !== lifecycleGeneration)
|
|
650
472
|
return;
|
|
651
473
|
startSignalKEmitter(app, url, { emitSignalKDeltas: settings.emitSignalKDeltas });
|
|
652
474
|
if (settings.databaseExport.questdb ||
|
|
@@ -656,67 +478,6 @@ export default function (app) {
|
|
|
656
478
|
`requires managed-container mode and was skipped.`);
|
|
657
479
|
}
|
|
658
480
|
}
|
|
659
|
-
// One retry-loop attempt: ensureRunning is idempotent, the drift/recreate check already ran on the first attempt, and the address is re-resolved because a container that only now came up may bind a different host port.
|
|
660
|
-
async function managedReadyAttempt(gen, settings, containers, resolvedTag) {
|
|
661
|
-
// Bail before touching podman — stop() may have already stopped the container.
|
|
662
|
-
if (gen !== runGeneration)
|
|
663
|
-
return;
|
|
664
|
-
await containers.ensureRunning(CONTAINER_NAME, buildContainerConfig(resolvedTag), {
|
|
665
|
-
onVolumeIssue
|
|
666
|
-
});
|
|
667
|
-
registerUpdateTracker(containers, settings);
|
|
668
|
-
const addr = await resolveActualAddress(containers, (m) => {
|
|
669
|
-
app.debug(m);
|
|
670
|
-
});
|
|
671
|
-
if (!addr) {
|
|
672
|
-
throw new Error('Could not resolve container address');
|
|
673
|
-
}
|
|
674
|
-
// Re-check before publishing: stop() nulls containerAddress and the proxy reads it live — a stale attempt must not resurrect it.
|
|
675
|
-
if (gen !== runGeneration)
|
|
676
|
-
return;
|
|
677
|
-
containerAddress = `http://${addr}`;
|
|
678
|
-
const pending = new BackupClient(containerAddress);
|
|
679
|
-
await pending.waitForReady(10_000);
|
|
680
|
-
await finishStartup(gen, settings, pending, containerAddress);
|
|
681
|
-
}
|
|
682
|
-
// Runs until an attempt succeeds or the generation moves on; keeps the latest failure cause in the plugin status so the admin UI stays truthful while retrying.
|
|
683
|
-
async function retryUntilReady(gen, label, attempt) {
|
|
684
|
-
let delay = READY_RETRY_MIN_MS;
|
|
685
|
-
for (let attemptNo = 1;; attemptNo++) {
|
|
686
|
-
await sleep(delay);
|
|
687
|
-
if (gen !== runGeneration)
|
|
688
|
-
return;
|
|
689
|
-
try {
|
|
690
|
-
await attempt();
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
catch (err) {
|
|
694
|
-
if (gen !== runGeneration)
|
|
695
|
-
return;
|
|
696
|
-
delay = Math.min(delay * 2, READY_RETRY_MAX_MS);
|
|
697
|
-
app.debug(`readiness retry ${attemptNo} failed: ${errMsg(err)}`);
|
|
698
|
-
app.setPluginError(`${label}: ${errMsg(err)} — retrying in ${Math.round(delay / 1000)}s`);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
// "Update available" badges via signalk-container's central update service; guarded so readiness retries don't register duplicate trackers.
|
|
703
|
-
function registerUpdateTracker(containers, settings) {
|
|
704
|
-
if (updatesRegistered)
|
|
705
|
-
return;
|
|
706
|
-
try {
|
|
707
|
-
containers.updates.register({
|
|
708
|
-
pluginId: PLUGIN_ID,
|
|
709
|
-
containerName: CONTAINER_NAME,
|
|
710
|
-
image: BACKUP_IMAGE,
|
|
711
|
-
currentTag: () => resolveImageTag(currentSettings?.imageTag ?? settings.imageTag),
|
|
712
|
-
versionSource: containers.updates.sources.githubReleases('dirkwa/signalk-backup-server')
|
|
713
|
-
});
|
|
714
|
-
updatesRegistered = true;
|
|
715
|
-
}
|
|
716
|
-
catch (err) {
|
|
717
|
-
app.debug(`updates.register failed (non-fatal): ${errMsg(err)}`);
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
481
|
/**
|
|
721
482
|
* Start the database-export interval, if any DB exporter is enabled in
|
|
722
483
|
* settings. The first export fires after one full interval — not at
|