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.
- package/package.json +4 -3
- package/plugin/backup-client.d.ts +1 -1
- package/plugin/backup-client.d.ts.map +1 -1
- package/plugin/backup-client.js +38 -4
- package/plugin/backup-client.js.map +1 -1
- package/plugin/database-export/grafana.d.ts.map +1 -1
- package/plugin/database-export/grafana.js +1 -3
- package/plugin/database-export/grafana.js.map +1 -1
- package/plugin/database-export/index.d.ts.map +1 -1
- package/plugin/database-export/index.js +3 -3
- package/plugin/database-export/index.js.map +1 -1
- package/plugin/database-export/questdb.d.ts.map +1 -1
- package/plugin/database-export/questdb.js +1 -3
- package/plugin/database-export/questdb.js.map +1 -1
- package/plugin/database-export/signalk-database.d.ts.map +1 -1
- package/plugin/database-export/signalk-database.js +1 -3
- package/plugin/database-export/signalk-database.js.map +1 -1
- package/plugin/database-export/staging-routes.d.ts.map +1 -1
- package/plugin/database-export/staging-routes.js +1 -3
- package/plugin/database-export/staging-routes.js.map +1 -1
- package/plugin/errors.d.ts +2 -0
- package/plugin/errors.d.ts.map +1 -0
- package/plugin/errors.js +25 -0
- package/plugin/errors.js.map +1 -0
- package/plugin/index.d.ts.map +1 -1
- package/plugin/index.js +154 -311
- package/plugin/index.js.map +1 -1
- package/plugin/proxy.d.ts.map +1 -1
- package/plugin/proxy.js +1 -3
- package/plugin/proxy.js.map +1 -1
- package/plugin/restore-host-write.d.ts.map +1 -1
- package/plugin/restore-host-write.js +1 -3
- package/plugin/restore-host-write.js.map +1 -1
- package/plugin/types.d.ts +1 -164
- package/plugin/types.d.ts.map +1 -1
- package/public/assets/App-DJTv4N42.js +3 -0
- package/public/assets/{App-Er5pmiQ3.js.map → App-DJTv4N42.js.map} +1 -1
- package/public/assets/AppPanel-BLjK4PTq.js +2 -0
- package/public/assets/AppPanel-BLjK4PTq.js.map +1 -0
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup-B9dkCYcU.js +1 -0
- package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react__loadShare__.mjs-CRDWkJnD.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__-CZEusA_v.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__.mjs-C5oceaDt.js +2 -0
- 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
- package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_1_jsx_mf_2_runtime__loadShare__-Dh2Je4Q8.js +1 -0
- 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
- 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
- package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__loadShare__react_mf_2_dom__loadShare__.mjs-COVU9tAo.js +1 -0
- 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
- package/public/assets/dist-Cr3RQpe1.js +5 -0
- package/public/assets/dist-Cr3RQpe1.js.map +1 -0
- package/public/assets/hostInit-CBfbRWDG.js +1 -0
- package/public/assets/index-eDLwC5ye.js +2 -0
- package/public/assets/index-eDLwC5ye.js.map +1 -0
- package/public/assets/jsx-dev-runtime-BvApC5zd.js +1 -0
- package/public/assets/jsx-runtime-4HRoLdzw.js +1 -0
- package/public/assets/main-pXUwEryv.js +2 -0
- package/public/assets/{main-Du2fwlp_.js.map → main-pXUwEryv.js.map} +1 -1
- package/public/assets/preload-helper-DCSm9-xi.js +1 -0
- package/public/assets/virtualExposes-B5IFEpCJ.js +2 -0
- package/public/assets/virtualExposes-B5IFEpCJ.js.map +1 -0
- package/public/index.html +1 -1
- package/public/mf-entry-bootstrap-0.js +17 -0
- package/public/remoteEntry.js +1 -1
- package/public/remoteEntry.js.map +1 -1
- package/public/assets/App-Er5pmiQ3.js +0 -3
- package/public/assets/AppPanel-Bm45xPqR.js +0 -2
- package/public/assets/AppPanel-Bm45xPqR.js.map +0 -1
- package/public/assets/_virtual_mf-localSharedImportMap___mfe_internal__signalk_mf_2_backup__mf_owner__1-xuNj2MfB.js +0 -1
- package/public/assets/_virtual_mf___mfe_internal__signalk_mf_2_backup__mf_owner__1__loadShare__react__loadShare__.js-DUF5rz7t.js +0 -1
- 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
- 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
- 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
- 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
- package/public/assets/dist-Bibv8K2h.js +0 -5
- package/public/assets/dist-Bibv8K2h.js.map +0 -1
- package/public/assets/hostInit-Ba-iqzVX.js +0 -1
- package/public/assets/index-Bg1Dz2o0.js +0 -2
- package/public/assets/index-Bg1Dz2o0.js.map +0 -1
- package/public/assets/jsx-dev-runtime-BVMEoATa.js +0 -2
- package/public/assets/jsx-dev-runtime-BVMEoATa.js.map +0 -1
- package/public/assets/jsx-runtime-D2TqfpMv.js +0 -1
- package/public/assets/main-Du2fwlp_.js +0 -2
- package/public/assets/mf-entry-bootstrap-0-832b1316.js +0 -36
- package/public/assets/virtualExposes-rRSEXNaO.js +0 -2
- package/public/assets/virtualExposes-rRSEXNaO.js.map +0 -1
- package/public/assets/virtual_mf-exposes-ssr___mfe_internal__signalk-backup__remoteEntry_js-DACl3ZMG.js +0 -2
- package/public/assets/virtual_mf-exposes-ssr___mfe_internal__signalk-backup__remoteEntry_js-DACl3ZMG.js.map +0 -1
- package/public/assets/vite-preload-helper-Fl0ucuR7.js +0 -1
- package/public/remoteEntry.ssr.js +0 -2
- 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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
? {
|
|
215
|
+
? {
|
|
216
|
+
containerPath: SK_MOUNT,
|
|
217
|
+
hostPath: await resolveSignalkConfigRootOnHost(getContainerManager())
|
|
218
|
+
}
|
|
269
219
|
: undefined;
|
|
270
220
|
res.json({
|
|
271
221
|
container: {
|
|
272
|
-
state
|
|
222
|
+
state,
|
|
273
223
|
image: containerImage,
|
|
274
224
|
managed
|
|
275
225
|
},
|
|
276
|
-
|
|
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.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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
|
-
|
|
557
|
-
|
|
453
|
+
client = pending;
|
|
454
|
+
await seedFirstRunSchedule(pending);
|
|
455
|
+
if (gen !== lifecycleGeneration)
|
|
558
456
|
return;
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
/**
|