@camstack/server 1.2.126 → 1.2.128
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/dist/agent/agent-update-service.js +14 -11
- package/dist/api/core/cap-providers.js +4 -15
- package/dist/api/trpc/generated-cap-routers.js +25 -0
- package/dist/core/addon/addon-package.service.js +173 -38
- package/dist/core/server-update/server-update.service.js +3 -6
- package/dist/core/update-availability-emitter.js +42 -1
- package/dist/core/update-availability-store.js +148 -0
- package/dist/core/updates/agent-installed-packages.js +30 -0
- package/dist/core/updates/update-check-scheduler.js +136 -0
- package/dist/manual-boot.js +50 -1
- package/package.json +7 -7
|
@@ -63,12 +63,13 @@ exports.agentRuntimeManifestEntry = agentRuntimeManifestEntry;
|
|
|
63
63
|
const node_crypto_1 = require("node:crypto");
|
|
64
64
|
const fs = __importStar(require("node:fs"));
|
|
65
65
|
const path = __importStar(require("node:path"));
|
|
66
|
-
const index_js_1 = require("../server-root/index.js");
|
|
67
|
-
const system_exec_npm_js_1 = require("../core/server-update/system-exec-npm.js");
|
|
68
|
-
const system_ensure_prebuilds_js_1 = require("../core/server-update/system-ensure-prebuilds.js");
|
|
69
66
|
const system_1 = require("@camstack/system");
|
|
70
67
|
const types_1 = require("@camstack/types");
|
|
68
|
+
const system_ensure_prebuilds_js_1 = require("../core/server-update/system-ensure-prebuilds.js");
|
|
69
|
+
const system_exec_npm_js_1 = require("../core/server-update/system-exec-npm.js");
|
|
71
70
|
const update_availability_emitter_js_1 = require("../core/update-availability-emitter.js");
|
|
71
|
+
const update_availability_store_js_1 = require("../core/update-availability-store.js");
|
|
72
|
+
const index_js_1 = require("../server-root/index.js");
|
|
72
73
|
/**
|
|
73
74
|
* The synthetic addonId the agent's OWN runtime registers infra providers
|
|
74
75
|
* under (no addon owns them — the bootstrap does). Appears in the agent's
|
|
@@ -151,6 +152,8 @@ class AgentUpdateService extends index_js_1.RootUpdateService {
|
|
|
151
152
|
eventBus;
|
|
152
153
|
nodeId;
|
|
153
154
|
agentDataDir;
|
|
155
|
+
/** The base class keeps `logger` private; hold our own for the store. */
|
|
156
|
+
agentLogger;
|
|
154
157
|
constructor(options) {
|
|
155
158
|
const restartAgent = options.restartAgent ??
|
|
156
159
|
((requestedBy) => scheduleAgentRestart(options.logger, requestedBy, options.dataDir));
|
|
@@ -189,22 +192,22 @@ class AgentUpdateService extends index_js_1.RootUpdateService {
|
|
|
189
192
|
});
|
|
190
193
|
this.nodeId = options.nodeId ?? process.env['CAMSTACK_NODE_ID'] ?? 'agent';
|
|
191
194
|
this.agentDataDir = options.dataDir;
|
|
195
|
+
this.agentLogger = options.logger;
|
|
192
196
|
this.eventBus = options.eventBus ?? null;
|
|
193
197
|
this.updateAvailability =
|
|
194
198
|
options.eventBus !== undefined
|
|
195
|
-
? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, {
|
|
196
|
-
type: 'core',
|
|
197
|
-
id: 'agent-update-service',
|
|
198
|
-
})
|
|
199
|
+
? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, { type: 'core', id: 'agent-update-service' }, options.updateAvailabilityStore)
|
|
199
200
|
: null;
|
|
200
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Bind the agent's real event bus once the mesh is up. The dedup state is
|
|
204
|
+
* persisted under the agent's data dir from here on — an agent restarts on
|
|
205
|
+
* every root update, which is exactly when its availability list is longest.
|
|
206
|
+
*/
|
|
201
207
|
bindAvailability(eventBus, nodeId) {
|
|
202
208
|
this.eventBus = eventBus;
|
|
203
209
|
this.nodeId = nodeId;
|
|
204
|
-
this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(eventBus, {
|
|
205
|
-
type: 'core',
|
|
206
|
-
id: 'agent-update-service',
|
|
207
|
-
});
|
|
210
|
+
this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(eventBus, { type: 'core', id: 'agent-update-service' }, new update_availability_store_js_1.FileUpdateAvailabilityStore(this.agentDataDir, 'agent-update', this.agentLogger));
|
|
208
211
|
}
|
|
209
212
|
async checkServerUpdate() {
|
|
210
213
|
const result = await super.checkServerUpdate();
|
|
@@ -76,6 +76,7 @@ const server_1 = require("@trpc/server");
|
|
|
76
76
|
const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
|
|
77
77
|
const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
|
|
78
78
|
const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
|
|
79
|
+
const agent_installed_packages_js_1 = require("../../core/updates/agent-installed-packages.js");
|
|
79
80
|
const collection_preference_js_1 = require("./collection-preference.js");
|
|
80
81
|
const site_location_js_1 = require("./site-location.js");
|
|
81
82
|
// ── system ──────────────────────────────────────────────────────────
|
|
@@ -1220,18 +1221,6 @@ function isHubNode(nodeId) {
|
|
|
1220
1221
|
* filter it out to make the list look clean — the number reaching zero is the
|
|
1221
1222
|
* point of the number.
|
|
1222
1223
|
*/
|
|
1223
|
-
async function fetchAgentInstalledPackages(broker, nodeId) {
|
|
1224
|
-
const status = await broker.call('$agent.status', {}, { nodeID: nodeId, timeout: 5_000 });
|
|
1225
|
-
const out = [];
|
|
1226
|
-
for (const a of status.addons ?? []) {
|
|
1227
|
-
if (typeof a.packageName !== 'string' || typeof a.version !== 'string')
|
|
1228
|
-
continue;
|
|
1229
|
-
if ((0, addon_package_service_js_1.isFrameworkPackage)(a.packageName))
|
|
1230
|
-
continue;
|
|
1231
|
-
out.push({ name: a.packageName, version: a.version });
|
|
1232
|
-
}
|
|
1233
|
-
return out;
|
|
1234
|
-
}
|
|
1235
1224
|
function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
|
|
1236
1225
|
const broker = moleculer.broker;
|
|
1237
1226
|
const frameworkAllowSet = new Set([addon_package_service_js_1.SYSTEM_PACKAGE]);
|
|
@@ -1277,7 +1266,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
|
|
|
1277
1266
|
const nodeId = input.nodeId;
|
|
1278
1267
|
const updates = nodeId === undefined || isHubNode(nodeId)
|
|
1279
1268
|
? await ps.checkUpdates()
|
|
1280
|
-
: await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId), nodeId);
|
|
1269
|
+
: await ps.checkUpdatesForInstalled(await (0, agent_installed_packages_js_1.fetchAgentInstalledPackages)(broker, nodeId), nodeId);
|
|
1281
1270
|
return updates.map((u) => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }));
|
|
1282
1271
|
},
|
|
1283
1272
|
updatePackage: async (input) => {
|
|
@@ -1331,7 +1320,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
|
|
|
1331
1320
|
if (nodeId === undefined || isHubNode(nodeId))
|
|
1332
1321
|
return ps.checkUpdates(true);
|
|
1333
1322
|
// Agent rosters carry no hub-side cache — the diff is always live.
|
|
1334
|
-
const installed = await fetchAgentInstalledPackages(broker, nodeId);
|
|
1323
|
+
const installed = await (0, agent_installed_packages_js_1.fetchAgentInstalledPackages)(broker, nodeId);
|
|
1335
1324
|
return ps.checkUpdatesForInstalled(installed, nodeId);
|
|
1336
1325
|
},
|
|
1337
1326
|
restartServer: async () => ps.restartServer(ctx.user?.username ?? ctx.user?.id),
|
|
@@ -1399,7 +1388,7 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
|
|
|
1399
1388
|
return { success: true };
|
|
1400
1389
|
},
|
|
1401
1390
|
getAutoUpdateSettings: async () => ps.getAutoUpdateSettings(),
|
|
1402
|
-
setAutoUpdateSettings: async (input) => ps.setAutoUpdateSettings(input.channel, input.intervalSeconds),
|
|
1391
|
+
setAutoUpdateSettings: async (input) => ps.setAutoUpdateSettings(input.channel, input.intervalSeconds, input.updateCheckIntervalSeconds),
|
|
1403
1392
|
getAddonAutoUpdate: async (input) => ps.getAddonAutoUpdate(input.addonId),
|
|
1404
1393
|
setAddonAutoUpdate: async (input) => ps.setAddonAutoUpdate(input.addonId, input.channel),
|
|
1405
1394
|
applyAutoUpdateToAll: async (input) => {
|
|
@@ -5794,6 +5794,15 @@ function createCapRouter_pipelineAnalytics(getProvider, createRemoteProxy) {
|
|
|
5794
5794
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
5795
5795
|
return p.wipeAllAnalytics(methodInput);
|
|
5796
5796
|
}),
|
|
5797
|
+
reconcileFromDisk: trpc_middleware_js_1.adminProcedure
|
|
5798
|
+
.input(types_82.pipelineAnalyticsCapability.methods.reconcileFromDisk.input.loose())
|
|
5799
|
+
.output(types_82.pipelineAnalyticsCapability.methods.reconcileFromDisk.output)
|
|
5800
|
+
.mutation(async ({ input, ctx }) => {
|
|
5801
|
+
const { nodeId, ...methodInput } = input;
|
|
5802
|
+
const p = resolveProvider('pipeline-analytics', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
5803
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
5804
|
+
return p.reconcileFromDisk(methodInput);
|
|
5805
|
+
}),
|
|
5797
5806
|
deleteTracks: trpc_middleware_js_1.adminProcedure
|
|
5798
5807
|
.input(types_82.pipelineAnalyticsCapability.methods.deleteTracks.input.loose())
|
|
5799
5808
|
.output(types_82.pipelineAnalyticsCapability.methods.deleteTracks.output)
|
|
@@ -6728,6 +6737,22 @@ function createCapRouter_pipelineOrchestrator(getProvider, _createRemoteProxy) {
|
|
|
6728
6737
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
6729
6738
|
return p.getCameraStatuses(input);
|
|
6730
6739
|
}),
|
|
6740
|
+
reconcileFromDisk: trpc_middleware_js_1.adminProcedure
|
|
6741
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
6742
|
+
.output(types_84.pipelineOrchestratorCapability.methods.reconcileFromDisk.output)
|
|
6743
|
+
.mutation(async ({ input, ctx }) => {
|
|
6744
|
+
void input;
|
|
6745
|
+
const p = requireCapProvider('pipeline-orchestrator', () => getProvider(ctx));
|
|
6746
|
+
return p.reconcileFromDisk();
|
|
6747
|
+
}),
|
|
6748
|
+
getReconcileFromDiskStatus: trpc_middleware_js_1.adminProcedure
|
|
6749
|
+
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
6750
|
+
.output(types_84.pipelineOrchestratorCapability.methods.getReconcileFromDiskStatus.output)
|
|
6751
|
+
.query(async ({ input, ctx }) => {
|
|
6752
|
+
void input;
|
|
6753
|
+
const p = requireCapProvider('pipeline-orchestrator', () => getProvider(ctx));
|
|
6754
|
+
return p.getReconcileFromDiskStatus();
|
|
6755
|
+
}),
|
|
6731
6756
|
listTemplates: trpc_middleware_js_1.protectedProcedure
|
|
6732
6757
|
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
6733
6758
|
.output(types_84.pipelineOrchestratorCapability.methods.listTemplates.output)
|
|
@@ -45,8 +45,9 @@ const path = __importStar(require("node:path"));
|
|
|
45
45
|
const node_util_1 = require("node:util");
|
|
46
46
|
const system_1 = require("@camstack/system");
|
|
47
47
|
const types_1 = require("@camstack/types");
|
|
48
|
-
const package_dir_utils_js_1 = require("./package-dir-utils.js");
|
|
49
48
|
const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
|
|
49
|
+
const update_check_scheduler_js_1 = require("../updates/update-check-scheduler.js");
|
|
50
|
+
const package_dir_utils_js_1 = require("./package-dir-utils.js");
|
|
50
51
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
51
52
|
/**
|
|
52
53
|
* The primary host-provided framework package (kernel + core). Kept as a named
|
|
@@ -193,10 +194,20 @@ class AddonPackageService {
|
|
|
193
194
|
updateAvailability;
|
|
194
195
|
// -- Auto-update state ----------------------------------------------------
|
|
195
196
|
autoUpdateConfig = {
|
|
196
|
-
global: {
|
|
197
|
+
global: {
|
|
198
|
+
channel: 'off',
|
|
199
|
+
intervalSeconds: 21600,
|
|
200
|
+
updateCheckIntervalSeconds: update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
|
|
201
|
+
},
|
|
197
202
|
overrides: {},
|
|
198
203
|
};
|
|
199
204
|
autoUpdateTimer = null;
|
|
205
|
+
/**
|
|
206
|
+
* Re-arm hook for the availability poller, wired at boot (manual-boot). Kept
|
|
207
|
+
* as a callback rather than a service reference so this module never imports
|
|
208
|
+
* the scheduler's dependencies back.
|
|
209
|
+
*/
|
|
210
|
+
updateCheckRescheduler = null;
|
|
200
211
|
// -- Timing constants -----------------------------------------------------
|
|
201
212
|
// Short TTL — operators expect to see freshly-published versions
|
|
202
213
|
// within minutes of `npm publish`, not hours. The Addons page kicks
|
|
@@ -207,7 +218,12 @@ class AddonPackageService {
|
|
|
207
218
|
static VERSION_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
208
219
|
static NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
209
220
|
static REGISTRY_TIMEOUT_MS = 10_000;
|
|
210
|
-
constructor(loggingService, eventBusService, configService, addonRegistry, notificationService, toastService
|
|
221
|
+
constructor(loggingService, eventBusService, configService, addonRegistry, notificationService, toastService,
|
|
222
|
+
/**
|
|
223
|
+
* Durable dedup state for `update.available`. Injected by boot wiring
|
|
224
|
+
* (manual-boot) — unit tests leave it out and get the RAM-only emitter.
|
|
225
|
+
*/
|
|
226
|
+
updateAvailabilityStore) {
|
|
211
227
|
this.loggingService = loggingService;
|
|
212
228
|
this.eventBusService = eventBusService;
|
|
213
229
|
this.configService = configService;
|
|
@@ -215,10 +231,7 @@ class AddonPackageService {
|
|
|
215
231
|
this.notificationService = notificationService;
|
|
216
232
|
this.toastService = toastService;
|
|
217
233
|
this.logger = this.loggingService.createLogger('AddonPackageService');
|
|
218
|
-
this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(this.eventBusService, {
|
|
219
|
-
type: 'core',
|
|
220
|
-
id: 'addon-package-service',
|
|
221
|
-
});
|
|
234
|
+
this.updateAvailability = new update_availability_emitter_js_1.UpdateAvailabilityEmitter(this.eventBusService, { type: 'core', id: 'addon-package-service' }, updateAvailabilityStore);
|
|
222
235
|
// Initialize installer eagerly (no async needed).
|
|
223
236
|
// Ensures install/uninstall works before full module init completes.
|
|
224
237
|
try {
|
|
@@ -613,8 +626,8 @@ class AddonPackageService {
|
|
|
613
626
|
this.logger.info('Checking for package updates...');
|
|
614
627
|
const updates = [];
|
|
615
628
|
// Check installed addon packages in addons dir
|
|
616
|
-
const
|
|
617
|
-
updates.push(...
|
|
629
|
+
const sweep = await this.checkAddonPackageUpdates();
|
|
630
|
+
updates.push(...sweep.updates);
|
|
618
631
|
this.logger.info('Found package updates', {
|
|
619
632
|
meta: {
|
|
620
633
|
count: updates.length,
|
|
@@ -629,12 +642,7 @@ class AddonPackageService {
|
|
|
629
642
|
updates,
|
|
630
643
|
expiresAt: now + AddonPackageService.UPDATE_CACHE_TTL_MS,
|
|
631
644
|
};
|
|
632
|
-
this.
|
|
633
|
-
target: 'addon',
|
|
634
|
-
packageName: update.name,
|
|
635
|
-
currentVersion: update.currentVersion,
|
|
636
|
-
latestVersion: update.latestVersion,
|
|
637
|
-
})));
|
|
645
|
+
this.publishAddonAvailability(updates, sweep.failures);
|
|
638
646
|
return updates;
|
|
639
647
|
}
|
|
640
648
|
/** Clear the cached update check results */
|
|
@@ -659,6 +667,7 @@ class AddonPackageService {
|
|
|
659
667
|
seen.set(pkg.name, pkg.version);
|
|
660
668
|
}
|
|
661
669
|
const updates = [];
|
|
670
|
+
let failures = 0;
|
|
662
671
|
await Promise.all([...seen].map(async ([name, version]) => {
|
|
663
672
|
if (!this.isAllowedPackage(name))
|
|
664
673
|
return;
|
|
@@ -667,7 +676,16 @@ class AddonPackageService {
|
|
|
667
676
|
// keeps the UI honest (no dead "Update" button for @camstack/server).
|
|
668
677
|
if (exports.AUTO_UPDATE_EXCLUDED_PACKAGES.has(name))
|
|
669
678
|
return;
|
|
670
|
-
const
|
|
679
|
+
const lookup = await this.lookupLatestVersion(name);
|
|
680
|
+
if (!lookup.ok) {
|
|
681
|
+
failures += 1;
|
|
682
|
+
this.logger.warn('Registry lookup failed for installed package', {
|
|
683
|
+
tags: { nodeId: nodeId ?? 'hub' },
|
|
684
|
+
meta: { name, error: lookup.error },
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const latestVersion = lookup.latestVersion;
|
|
671
689
|
if (latestVersion === null || !isVersionNewer(latestVersion, version))
|
|
672
690
|
return;
|
|
673
691
|
const category = this.categorize(name);
|
|
@@ -679,13 +697,7 @@ class AddonPackageService {
|
|
|
679
697
|
requiresRestart: category === 'core',
|
|
680
698
|
});
|
|
681
699
|
}));
|
|
682
|
-
this.
|
|
683
|
-
target: 'addon',
|
|
684
|
-
packageName: update.name,
|
|
685
|
-
currentVersion: update.currentVersion,
|
|
686
|
-
latestVersion: update.latestVersion,
|
|
687
|
-
...(nodeId !== undefined ? { nodeId } : {}),
|
|
688
|
-
})), nodeId);
|
|
700
|
+
this.publishAddonAvailability(updates, failures, nodeId);
|
|
689
701
|
return updates;
|
|
690
702
|
}
|
|
691
703
|
/**
|
|
@@ -1165,6 +1177,7 @@ class AddonPackageService {
|
|
|
1165
1177
|
const hubPkgDir = (0, package_dir_utils_js_1.resolveHubClosurePackageDir)(packageName);
|
|
1166
1178
|
const buildId = hubPkgDir !== null ? (0, package_dir_utils_js_1.computeDistBuildId)(path.join(hubPkgDir, 'dist')) : null;
|
|
1167
1179
|
let latestVersion = null;
|
|
1180
|
+
let lookupFailed = false;
|
|
1168
1181
|
try {
|
|
1169
1182
|
const args = [
|
|
1170
1183
|
'view',
|
|
@@ -1177,6 +1190,7 @@ class AddonPackageService {
|
|
|
1177
1190
|
latestVersion = trimmed.length > 0 ? trimmed : null;
|
|
1178
1191
|
}
|
|
1179
1192
|
catch (err) {
|
|
1193
|
+
lookupFailed = true;
|
|
1180
1194
|
this.logger.debug('listFrameworkPackages: npm view failed', {
|
|
1181
1195
|
meta: { packageName, error: (0, types_1.errMsg)(err) },
|
|
1182
1196
|
});
|
|
@@ -1188,10 +1202,53 @@ class AddonPackageService {
|
|
|
1188
1202
|
latestVersion,
|
|
1189
1203
|
hasUpdate,
|
|
1190
1204
|
buildId,
|
|
1205
|
+
lookupFailed,
|
|
1191
1206
|
...(description !== undefined ? { description } : {}),
|
|
1192
1207
|
};
|
|
1193
1208
|
}));
|
|
1194
|
-
|
|
1209
|
+
this.publishFrameworkAvailability(rows);
|
|
1210
|
+
return rows.map(({ lookupFailed: _lookupFailed, ...row }) => row);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Announce framework updates. This surface SHOWED `@camstack/system` had an
|
|
1214
|
+
* update and emitted nothing, so the operator was never told — it was the
|
|
1215
|
+
* only discovery path with no publish at all.
|
|
1216
|
+
*
|
|
1217
|
+
* Two rules it must not break:
|
|
1218
|
+
* - **Strictly newer only.** `hasUpdate` is `latest !== current`, which is
|
|
1219
|
+
* true for a hub running a `-dev.<timestamp>` build AHEAD of the npm tag.
|
|
1220
|
+
* Announcing that would be telling the operator to downgrade.
|
|
1221
|
+
* - **A failed lookup publishes nothing.** Not even an empty snapshot: an
|
|
1222
|
+
* empty snapshot means "up to date" and would clear the dedup state, so
|
|
1223
|
+
* the next successful poll would re-announce everything.
|
|
1224
|
+
*/
|
|
1225
|
+
publishFrameworkAvailability(rows) {
|
|
1226
|
+
const failed = rows.filter((row) => row.lookupFailed);
|
|
1227
|
+
if (failed.length > 0) {
|
|
1228
|
+
this.logger.warn('Framework update check incomplete — availability not published', {
|
|
1229
|
+
meta: { packages: failed.map((row) => row.packageName) },
|
|
1230
|
+
});
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
const candidates = [];
|
|
1234
|
+
for (const row of rows) {
|
|
1235
|
+
if (row.latestVersion === null || row.currentVersion === 'unknown')
|
|
1236
|
+
continue;
|
|
1237
|
+
if (!isVersionNewer(row.latestVersion, row.currentVersion))
|
|
1238
|
+
continue;
|
|
1239
|
+
candidates.push({
|
|
1240
|
+
target: 'server',
|
|
1241
|
+
packageName: row.packageName,
|
|
1242
|
+
currentVersion: row.currentVersion,
|
|
1243
|
+
latestVersion: row.latestVersion,
|
|
1244
|
+
nodeId: this.resolveNodeId(),
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
this.updateAvailability.publishSnapshot('server', candidates, this.resolveNodeId());
|
|
1248
|
+
}
|
|
1249
|
+
/** Node id stamped on availability events emitted by THIS process. */
|
|
1250
|
+
resolveNodeId() {
|
|
1251
|
+
return process.env['CAMSTACK_NODE_ID'] ?? 'hub';
|
|
1195
1252
|
}
|
|
1196
1253
|
// =========================================================================
|
|
1197
1254
|
// Reload
|
|
@@ -1224,21 +1281,39 @@ class AddonPackageService {
|
|
|
1224
1281
|
// =========================================================================
|
|
1225
1282
|
// Auto-update settings
|
|
1226
1283
|
// =========================================================================
|
|
1284
|
+
/**
|
|
1285
|
+
* Register the availability poller's re-arm hook. Called once at boot so a
|
|
1286
|
+
* `setAutoUpdateSettings` write takes effect without a restart.
|
|
1287
|
+
*/
|
|
1288
|
+
setUpdateCheckRescheduler(reschedule) {
|
|
1289
|
+
this.updateCheckRescheduler = reschedule;
|
|
1290
|
+
}
|
|
1227
1291
|
/** Get global auto-update settings */
|
|
1228
1292
|
getAutoUpdateSettings() {
|
|
1229
1293
|
return { ...this.autoUpdateConfig.global };
|
|
1230
1294
|
}
|
|
1231
1295
|
/** Set global auto-update settings and restart the timer */
|
|
1232
|
-
async setAutoUpdateSettings(channel, intervalSeconds) {
|
|
1296
|
+
async setAutoUpdateSettings(channel, intervalSeconds, updateCheckIntervalSeconds) {
|
|
1297
|
+
const previousCheckInterval = this.autoUpdateConfig.global.updateCheckIntervalSeconds;
|
|
1298
|
+
const nextCheckInterval = updateCheckIntervalSeconds === undefined
|
|
1299
|
+
? previousCheckInterval
|
|
1300
|
+
: (0, update_check_scheduler_js_1.clampUpdateCheckInterval)(updateCheckIntervalSeconds);
|
|
1233
1301
|
this.autoUpdateConfig = {
|
|
1234
1302
|
...this.autoUpdateConfig,
|
|
1235
1303
|
global: {
|
|
1236
1304
|
channel,
|
|
1237
1305
|
intervalSeconds: intervalSeconds ?? this.autoUpdateConfig.global.intervalSeconds,
|
|
1306
|
+
updateCheckIntervalSeconds: nextCheckInterval,
|
|
1238
1307
|
},
|
|
1239
1308
|
};
|
|
1240
1309
|
this.saveAutoUpdateConfig();
|
|
1241
1310
|
this.scheduleAutoUpdate();
|
|
1311
|
+
if (updateCheckIntervalSeconds === undefined)
|
|
1312
|
+
return;
|
|
1313
|
+
this.logger.info('Update-check interval changed', {
|
|
1314
|
+
meta: { from: previousCheckInterval, to: nextCheckInterval },
|
|
1315
|
+
});
|
|
1316
|
+
this.updateCheckRescheduler?.();
|
|
1242
1317
|
}
|
|
1243
1318
|
/** Get per-addon auto-update override */
|
|
1244
1319
|
getAddonAutoUpdate(addonId) {
|
|
@@ -1395,10 +1470,14 @@ class AddonPackageService {
|
|
|
1395
1470
|
const global = asRecord(raw['global']);
|
|
1396
1471
|
const channel = asString(global['channel']);
|
|
1397
1472
|
const validChannel = channel === 'latest' || channel === 'beta' ? channel : 'off';
|
|
1473
|
+
const rawCheckInterval = global['updateCheckIntervalSeconds'];
|
|
1398
1474
|
return {
|
|
1399
1475
|
global: {
|
|
1400
1476
|
channel: validChannel,
|
|
1401
1477
|
intervalSeconds: typeof global['intervalSeconds'] === 'number' ? global['intervalSeconds'] : 3600,
|
|
1478
|
+
updateCheckIntervalSeconds: typeof rawCheckInterval === 'number'
|
|
1479
|
+
? (0, update_check_scheduler_js_1.clampUpdateCheckInterval)(rawCheckInterval)
|
|
1480
|
+
: update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
|
|
1402
1481
|
},
|
|
1403
1482
|
overrides: Object.fromEntries(Object.entries(asRecord(raw['overrides'])).map(([k, v]) => {
|
|
1404
1483
|
const s = asString(v);
|
|
@@ -1414,7 +1493,14 @@ class AddonPackageService {
|
|
|
1414
1493
|
meta: { error: (0, types_1.errMsg)(err) },
|
|
1415
1494
|
});
|
|
1416
1495
|
}
|
|
1417
|
-
return {
|
|
1496
|
+
return {
|
|
1497
|
+
global: {
|
|
1498
|
+
channel: 'off',
|
|
1499
|
+
intervalSeconds: 21600,
|
|
1500
|
+
updateCheckIntervalSeconds: update_check_scheduler_js_1.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS,
|
|
1501
|
+
},
|
|
1502
|
+
overrides: {},
|
|
1503
|
+
};
|
|
1418
1504
|
}
|
|
1419
1505
|
/** Save auto-update config to disk */
|
|
1420
1506
|
saveAutoUpdateConfig() {
|
|
@@ -1440,12 +1526,17 @@ class AddonPackageService {
|
|
|
1440
1526
|
/**
|
|
1441
1527
|
* Check addon packages for updates by reading installed versions from
|
|
1442
1528
|
* data/addons/{name}/package.json and comparing against npm registry.
|
|
1529
|
+
*
|
|
1530
|
+
* Reports `failures` alongside the diff: a registry lookup that could not be
|
|
1531
|
+
* MADE is not evidence that a package is up to date, and the availability
|
|
1532
|
+
* publish branches on it.
|
|
1443
1533
|
*/
|
|
1444
1534
|
async checkAddonPackageUpdates() {
|
|
1445
1535
|
const addonsDir = this.resolveAddonsDir();
|
|
1446
1536
|
const updates = [];
|
|
1537
|
+
let failures = 0;
|
|
1447
1538
|
if (!fs.existsSync(addonsDir))
|
|
1448
|
-
return updates;
|
|
1539
|
+
return { updates, failures };
|
|
1449
1540
|
// Collect all package.json paths -- handles both flat and scoped layouts
|
|
1450
1541
|
const pkgJsonPaths = [];
|
|
1451
1542
|
const topDirs = fs
|
|
@@ -1491,7 +1582,16 @@ class AddonPackageService {
|
|
|
1491
1582
|
if (source === 'workspace')
|
|
1492
1583
|
continue;
|
|
1493
1584
|
}
|
|
1494
|
-
const
|
|
1585
|
+
const lookup = await this.lookupLatestVersion(name);
|
|
1586
|
+
if (!lookup.ok) {
|
|
1587
|
+
failures += 1;
|
|
1588
|
+
this.logger.warn('Registry lookup failed for installed addon package', {
|
|
1589
|
+
tags: { nodeId: this.resolveNodeId() },
|
|
1590
|
+
meta: { name, error: lookup.error },
|
|
1591
|
+
});
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
const latestVersion = lookup.latestVersion;
|
|
1495
1595
|
if (!latestVersion)
|
|
1496
1596
|
continue;
|
|
1497
1597
|
if (isVersionNewer(latestVersion, version)) {
|
|
@@ -1505,13 +1605,16 @@ class AddonPackageService {
|
|
|
1505
1605
|
}
|
|
1506
1606
|
}
|
|
1507
1607
|
catch (error) {
|
|
1608
|
+
// The package could not be evaluated at all (unreadable manifest, …).
|
|
1609
|
+
// Counted as a failure for the same reason a registry miss is.
|
|
1610
|
+
failures += 1;
|
|
1508
1611
|
const msg = (0, types_1.errMsg)(error);
|
|
1509
|
-
this.logger.
|
|
1612
|
+
this.logger.warn('Failed to check updates for addon', {
|
|
1510
1613
|
meta: { pkgJsonPath, error: msg },
|
|
1511
1614
|
});
|
|
1512
1615
|
}
|
|
1513
1616
|
}
|
|
1514
|
-
return updates;
|
|
1617
|
+
return { updates, failures };
|
|
1515
1618
|
}
|
|
1516
1619
|
// =========================================================================
|
|
1517
1620
|
// Private: npm registry helpers
|
|
@@ -1522,7 +1625,7 @@ class AddonPackageService {
|
|
|
1522
1625
|
* Honours `CAMSTACK_NPM_REGISTRY` so update checks resolve against
|
|
1523
1626
|
* the same registry the installer/pack paths use. Without this, a
|
|
1524
1627
|
* per-node `listUpdates` (which diffs an agent's roster via
|
|
1525
|
-
* `checkUpdatesForInstalled` → `
|
|
1628
|
+
* `checkUpdatesForInstalled` → `lookupLatestVersion`) would bypass a
|
|
1526
1629
|
* private registry — including the e2e harness's verdaccio — and
|
|
1527
1630
|
* silently report "no update" for packages that only exist there.
|
|
1528
1631
|
* Trailing slashes are stripped so the `${base}/${name}` join is clean.
|
|
@@ -1532,29 +1635,61 @@ class AddonPackageService {
|
|
|
1532
1635
|
const base = override && override.length > 0 ? override : AddonPackageService.NPM_REGISTRY;
|
|
1533
1636
|
return base.replace(/\/+$/, '');
|
|
1534
1637
|
}
|
|
1535
|
-
/**
|
|
1536
|
-
|
|
1638
|
+
/**
|
|
1639
|
+
* Ask the registry for a package's latest version, distinguishing "there is
|
|
1640
|
+
* no such package" (a definitive answer) from "I could not ask" (a failure).
|
|
1641
|
+
* The difference decides whether an availability SNAPSHOT may be published —
|
|
1642
|
+
* see {@link RegistryLookup}.
|
|
1643
|
+
*/
|
|
1644
|
+
async lookupLatestVersion(packageName) {
|
|
1537
1645
|
try {
|
|
1538
1646
|
const encodedName = packageName.replace('/', '%2F');
|
|
1539
1647
|
const url = `${this.resolveRegistryBase()}/${encodedName}/latest`;
|
|
1540
1648
|
const response = await fetch(url, {
|
|
1541
1649
|
signal: AbortSignal.timeout(AddonPackageService.REGISTRY_TIMEOUT_MS),
|
|
1542
1650
|
});
|
|
1651
|
+
if (response.status === 404)
|
|
1652
|
+
return { ok: true, latestVersion: null };
|
|
1543
1653
|
if (!response.ok) {
|
|
1544
1654
|
this.logger.debug('Registry returned non-ok status', {
|
|
1545
1655
|
meta: { packageName, status: response.status },
|
|
1546
1656
|
});
|
|
1547
|
-
return
|
|
1657
|
+
return { ok: false, error: `registry status ${response.status}` };
|
|
1548
1658
|
}
|
|
1549
1659
|
const data = await fetchJsonObject(response);
|
|
1550
1660
|
const version = asString(data['version']);
|
|
1551
|
-
return version || null;
|
|
1661
|
+
return { ok: true, latestVersion: version || null };
|
|
1552
1662
|
}
|
|
1553
1663
|
catch (error) {
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1664
|
+
return { ok: false, error: (0, types_1.errMsg)(error) };
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Announce the addon-update set for one scope (hub or a node).
|
|
1669
|
+
*
|
|
1670
|
+
* `failures > 0` means at least one registry lookup could not be made, so
|
|
1671
|
+
* the set is INCOMPLETE — publish it as candidates (additive) rather than a
|
|
1672
|
+
* snapshot (authoritative). A snapshot built from a partial sweep silently
|
|
1673
|
+
* marks the unreachable packages "up to date", clears their dedup state and
|
|
1674
|
+
* re-announces them on the next good poll.
|
|
1675
|
+
*/
|
|
1676
|
+
publishAddonAvailability(updates, failures, nodeId) {
|
|
1677
|
+
const candidates = updates.map((update) => ({
|
|
1678
|
+
target: 'addon',
|
|
1679
|
+
packageName: update.name,
|
|
1680
|
+
currentVersion: update.currentVersion,
|
|
1681
|
+
latestVersion: update.latestVersion,
|
|
1682
|
+
...(nodeId !== undefined ? { nodeId } : {}),
|
|
1683
|
+
}));
|
|
1684
|
+
if (failures > 0) {
|
|
1685
|
+
this.logger.warn('Addon update check incomplete — availability published as partial', {
|
|
1686
|
+
tags: { nodeId: nodeId ?? 'hub' },
|
|
1687
|
+
meta: { failures, published: candidates.length },
|
|
1688
|
+
});
|
|
1689
|
+
this.updateAvailability.publishCandidates(candidates);
|
|
1690
|
+
return;
|
|
1557
1691
|
}
|
|
1692
|
+
this.updateAvailability.publishSnapshot('addon', candidates, nodeId);
|
|
1558
1693
|
}
|
|
1559
1694
|
/** Fetch npm search results for camstack addon packages (cached 5 min) */
|
|
1560
1695
|
async fetchSearchFromNpm() {
|
|
@@ -53,9 +53,9 @@ exports.ServerUpdateService = void 0;
|
|
|
53
53
|
*/
|
|
54
54
|
const path = __importStar(require("node:path"));
|
|
55
55
|
const index_js_1 = require("../../server-root/index.js");
|
|
56
|
-
const system_exec_npm_js_1 = require("./system-exec-npm.js");
|
|
57
|
-
const system_ensure_prebuilds_js_1 = require("./system-ensure-prebuilds.js");
|
|
58
56
|
const update_availability_emitter_js_1 = require("../update-availability-emitter.js");
|
|
57
|
+
const system_ensure_prebuilds_js_1 = require("./system-ensure-prebuilds.js");
|
|
58
|
+
const system_exec_npm_js_1 = require("./system-exec-npm.js");
|
|
59
59
|
class ServerUpdateService extends index_js_1.RootUpdateService {
|
|
60
60
|
updateAvailability;
|
|
61
61
|
nodeId;
|
|
@@ -95,10 +95,7 @@ class ServerUpdateService extends index_js_1.RootUpdateService {
|
|
|
95
95
|
});
|
|
96
96
|
this.updateAvailability =
|
|
97
97
|
options.eventBus !== undefined
|
|
98
|
-
? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, {
|
|
99
|
-
type: 'core',
|
|
100
|
-
id: 'server-update-service',
|
|
101
|
-
})
|
|
98
|
+
? new update_availability_emitter_js_1.UpdateAvailabilityEmitter(options.eventBus, { type: 'core', id: 'server-update-service' }, options.updateAvailabilityStore)
|
|
102
99
|
: null;
|
|
103
100
|
this.nodeId = options.nodeId ?? 'hub';
|
|
104
101
|
}
|
|
@@ -14,15 +14,33 @@ const types_1 = require("@camstack/types");
|
|
|
14
14
|
*
|
|
15
15
|
* A successful empty snapshot clears prior state for that scope; failed checks
|
|
16
16
|
* should not call this method.
|
|
17
|
+
*
|
|
18
|
+
* When a {@link IUpdateAvailabilityStore} is injected the dedup state is
|
|
19
|
+
* DURABLE: a restart does not re-announce what the operator already saw, and a
|
|
20
|
+
* genuinely newer version still announces. Without one the emitter stays
|
|
21
|
+
* RAM-only (the shape every unit test uses).
|
|
17
22
|
*/
|
|
18
23
|
class UpdateAvailabilityEmitter {
|
|
19
24
|
eventBus;
|
|
20
25
|
source;
|
|
26
|
+
store;
|
|
21
27
|
latestByKey = new Map();
|
|
22
28
|
held = new Map();
|
|
23
|
-
|
|
29
|
+
/** Set when in-memory state diverged from what the store last saw. */
|
|
30
|
+
dirty = false;
|
|
31
|
+
constructor(eventBus, source, store) {
|
|
24
32
|
this.eventBus = eventBus;
|
|
25
33
|
this.source = source;
|
|
34
|
+
this.store = store;
|
|
35
|
+
const persisted = store?.load() ?? null;
|
|
36
|
+
if (persisted === null)
|
|
37
|
+
return;
|
|
38
|
+
for (const [key, version] of Object.entries(persisted.latestByKey)) {
|
|
39
|
+
this.latestByKey.set(key, version);
|
|
40
|
+
}
|
|
41
|
+
for (const [key, candidate] of Object.entries(persisted.held)) {
|
|
42
|
+
this.held.set(key, candidate);
|
|
43
|
+
}
|
|
26
44
|
}
|
|
27
45
|
publishSnapshot(target, candidates, nodeId) {
|
|
28
46
|
const scope = nodeId ?? 'hub';
|
|
@@ -44,7 +62,9 @@ class UpdateAvailabilityEmitter {
|
|
|
44
62
|
continue;
|
|
45
63
|
this.held.delete(key);
|
|
46
64
|
this.latestByKey.delete(key);
|
|
65
|
+
this.dirty = true;
|
|
47
66
|
}
|
|
67
|
+
this.persistIfDirty();
|
|
48
68
|
if (!latestChanged)
|
|
49
69
|
return;
|
|
50
70
|
this.emitList(target);
|
|
@@ -58,6 +78,7 @@ class UpdateAvailabilityEmitter {
|
|
|
58
78
|
if (this.note(candidate))
|
|
59
79
|
latestChanged = true;
|
|
60
80
|
}
|
|
81
|
+
this.persistIfDirty();
|
|
61
82
|
if (!latestChanged)
|
|
62
83
|
return;
|
|
63
84
|
for (const target of targets)
|
|
@@ -66,10 +87,30 @@ class UpdateAvailabilityEmitter {
|
|
|
66
87
|
note(candidate) {
|
|
67
88
|
const key = this.key(candidate);
|
|
68
89
|
const previous = this.latestByKey.get(key);
|
|
90
|
+
const previousHeld = this.held.get(key);
|
|
91
|
+
if (previousHeld === undefined ||
|
|
92
|
+
previousHeld.currentVersion !== candidate.currentVersion ||
|
|
93
|
+
previousHeld.latestVersion !== candidate.latestVersion ||
|
|
94
|
+
previousHeld.nodeId !== candidate.nodeId) {
|
|
95
|
+
this.dirty = true;
|
|
96
|
+
}
|
|
69
97
|
this.held.set(key, candidate);
|
|
70
98
|
this.latestByKey.set(key, candidate.latestVersion);
|
|
71
99
|
return previous !== candidate.latestVersion;
|
|
72
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Flush to the durable store. Called BEFORE the emit so a crash between the
|
|
103
|
+
* two costs a duplicate announcement, never a silent one.
|
|
104
|
+
*/
|
|
105
|
+
persistIfDirty() {
|
|
106
|
+
if (!this.dirty)
|
|
107
|
+
return;
|
|
108
|
+
this.dirty = false;
|
|
109
|
+
this.store?.save({
|
|
110
|
+
latestByKey: Object.fromEntries(this.latestByKey),
|
|
111
|
+
held: Object.fromEntries(this.held),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
73
114
|
emitList(target) {
|
|
74
115
|
const list = [...this.held.values()].filter((candidate) => candidate.target === target);
|
|
75
116
|
if (list.length === 0)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.FileUpdateAvailabilityStore = exports.UPDATE_AVAILABILITY_DIR = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* Durable backing for {@link UpdateAvailabilityEmitter}'s dedup state.
|
|
39
|
+
*
|
|
40
|
+
* The emitter's whole job is "announce a (package, version) ONCE". Keeping
|
|
41
|
+
* that in RAM meant every hub restart re-announced everything the operator had
|
|
42
|
+
* already seen — and hubs restart on every framework update, which is exactly
|
|
43
|
+
* the moment the list is longest.
|
|
44
|
+
*
|
|
45
|
+
* The mechanism is deliberately the one the neighbouring update code already
|
|
46
|
+
* uses: a small JSON file under the node's data dir, next to
|
|
47
|
+
* `auto-update.json`, `runtime-state.json` and `.restart-pending`. No new
|
|
48
|
+
* database, no settings-store dependency (the emitter runs on the AGENT too,
|
|
49
|
+
* where the hub's SQLite settings store does not exist).
|
|
50
|
+
*/
|
|
51
|
+
const fs = __importStar(require("node:fs"));
|
|
52
|
+
const path = __importStar(require("node:path"));
|
|
53
|
+
const types_1 = require("@camstack/types");
|
|
54
|
+
/** Directory under the data dir that holds one file per emitter scope. */
|
|
55
|
+
exports.UPDATE_AVAILABILITY_DIR = 'update-availability';
|
|
56
|
+
/**
|
|
57
|
+
* One file per emitter scope (`addon-packages`, `server-update`,
|
|
58
|
+
* `agent-update`) so two emitters in the same process never overwrite each
|
|
59
|
+
* other's dedup state.
|
|
60
|
+
*/
|
|
61
|
+
class FileUpdateAvailabilityStore {
|
|
62
|
+
scopeId;
|
|
63
|
+
logger;
|
|
64
|
+
filePath;
|
|
65
|
+
constructor(dataDir, scopeId, logger) {
|
|
66
|
+
this.scopeId = scopeId;
|
|
67
|
+
this.logger = logger;
|
|
68
|
+
this.filePath = path.join(dataDir, exports.UPDATE_AVAILABILITY_DIR, `${scopeId}.json`);
|
|
69
|
+
}
|
|
70
|
+
load() {
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.existsSync(this.filePath))
|
|
73
|
+
return null;
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf-8'));
|
|
75
|
+
return parseState(parsed);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// A corrupt file must not look like "nothing was ever announced" AND
|
|
79
|
+
// must not crash boot. It re-announces once, then heals on the next save.
|
|
80
|
+
this.logger.warn('Update-availability state unreadable; starting from empty', {
|
|
81
|
+
meta: { scopeId: this.scopeId, path: this.filePath, error: (0, types_1.errMsg)(error) },
|
|
82
|
+
});
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
save(state) {
|
|
87
|
+
try {
|
|
88
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
89
|
+
const tmp = `${this.filePath}.tmp`;
|
|
90
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
91
|
+
fs.renameSync(tmp, this.filePath);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// Losing a write degrades to the old RAM-only behaviour (a re-announce
|
|
95
|
+
// after restart) — never a failed update check.
|
|
96
|
+
this.logger.warn('Failed to persist update-availability state', {
|
|
97
|
+
meta: { scopeId: this.scopeId, path: this.filePath, error: (0, types_1.errMsg)(error) },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.FileUpdateAvailabilityStore = FileUpdateAvailabilityStore;
|
|
103
|
+
function parseState(raw) {
|
|
104
|
+
if (raw === null || typeof raw !== 'object')
|
|
105
|
+
return null;
|
|
106
|
+
const latestRaw = Reflect.get(raw, 'latestByKey');
|
|
107
|
+
const heldRaw = Reflect.get(raw, 'held');
|
|
108
|
+
if (latestRaw === null || typeof latestRaw !== 'object')
|
|
109
|
+
return null;
|
|
110
|
+
if (heldRaw === null || typeof heldRaw !== 'object')
|
|
111
|
+
return null;
|
|
112
|
+
const latestByKey = {};
|
|
113
|
+
for (const [key, value] of Object.entries(latestRaw)) {
|
|
114
|
+
if (typeof value === 'string')
|
|
115
|
+
latestByKey[key] = value;
|
|
116
|
+
}
|
|
117
|
+
const held = {};
|
|
118
|
+
for (const [key, value] of Object.entries(heldRaw)) {
|
|
119
|
+
const candidate = parseCandidate(value);
|
|
120
|
+
if (candidate !== null)
|
|
121
|
+
held[key] = candidate;
|
|
122
|
+
}
|
|
123
|
+
return { latestByKey, held };
|
|
124
|
+
}
|
|
125
|
+
function parseCandidate(raw) {
|
|
126
|
+
if (raw === null || typeof raw !== 'object')
|
|
127
|
+
return null;
|
|
128
|
+
const target = Reflect.get(raw, 'target');
|
|
129
|
+
const packageName = Reflect.get(raw, 'packageName');
|
|
130
|
+
const currentVersion = Reflect.get(raw, 'currentVersion');
|
|
131
|
+
const latestVersion = Reflect.get(raw, 'latestVersion');
|
|
132
|
+
const nodeId = Reflect.get(raw, 'nodeId');
|
|
133
|
+
if (target !== 'addon' && target !== 'server')
|
|
134
|
+
return null;
|
|
135
|
+
if (typeof packageName !== 'string')
|
|
136
|
+
return null;
|
|
137
|
+
if (typeof currentVersion !== 'string')
|
|
138
|
+
return null;
|
|
139
|
+
if (typeof latestVersion !== 'string')
|
|
140
|
+
return null;
|
|
141
|
+
return {
|
|
142
|
+
target,
|
|
143
|
+
packageName,
|
|
144
|
+
currentVersion,
|
|
145
|
+
latestVersion,
|
|
146
|
+
...(typeof nodeId === 'string' ? { nodeId } : {}),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AGENT_STATUS_TIMEOUT_MS = void 0;
|
|
4
|
+
exports.fetchAgentInstalledPackages = fetchAgentInstalledPackages;
|
|
5
|
+
/**
|
|
6
|
+
* The agent addon roster, as the hub sees it.
|
|
7
|
+
*
|
|
8
|
+
* An agent has no npm runtime: it reports what it has installed via
|
|
9
|
+
* `$agent.status` and the hub does the registry diff
|
|
10
|
+
* (`AddonPackageService.checkUpdatesForInstalled`). Extracted from
|
|
11
|
+
* `cap-providers.ts` so the availability poller and the `addons.listUpdates`
|
|
12
|
+
* cap route read the roster through ONE function — a second copy would drift
|
|
13
|
+
* the framework filter and make the two surfaces disagree about what is
|
|
14
|
+
* installed on a node.
|
|
15
|
+
*/
|
|
16
|
+
const addon_package_service_js_1 = require("../addon/addon-package.service.js");
|
|
17
|
+
/** Timeout for `$agent.status`; a node that cannot answer this fast is unwell. */
|
|
18
|
+
exports.AGENT_STATUS_TIMEOUT_MS = 5_000;
|
|
19
|
+
async function fetchAgentInstalledPackages(broker, nodeId) {
|
|
20
|
+
const status = await broker.call('$agent.status', {}, { nodeID: nodeId, timeout: exports.AGENT_STATUS_TIMEOUT_MS });
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const addon of status.addons ?? []) {
|
|
23
|
+
if (typeof addon.packageName !== 'string' || typeof addon.version !== 'string')
|
|
24
|
+
continue;
|
|
25
|
+
if ((0, addon_package_service_js_1.isFrameworkPackage)(addon.packageName))
|
|
26
|
+
continue;
|
|
27
|
+
out.push({ name: addon.packageName, version: addon.version });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UpdateCheckScheduler = exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS = exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS = exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS = void 0;
|
|
4
|
+
exports.clampUpdateCheckInterval = clampUpdateCheckInterval;
|
|
5
|
+
const types_1 = require("@camstack/types");
|
|
6
|
+
/**
|
|
7
|
+
* Default poll cadence: 6 hours. Registry lookups are cheap but not free, and
|
|
8
|
+
* an operator who is told about a publish within a quarter-day is told in time.
|
|
9
|
+
*/
|
|
10
|
+
exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS = 6 * 60 * 60;
|
|
11
|
+
/**
|
|
12
|
+
* Floor: 15 minutes. Below this the sweep starts overlapping its own npm
|
|
13
|
+
* lookups on a cluster of any size, and the notification stops being a
|
|
14
|
+
* notification. `setAutoUpdateSettings` clamps to this rather than refusing,
|
|
15
|
+
* so a bad value degrades to "poll briskly" instead of "poll never".
|
|
16
|
+
*/
|
|
17
|
+
exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS = 15 * 60;
|
|
18
|
+
/** Ceiling: 7 days. Anything longer is indistinguishable from disabled. */
|
|
19
|
+
exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS = 7 * 24 * 60 * 60;
|
|
20
|
+
/** Grace after boot before the first sweep, so registry I/O never races boot. */
|
|
21
|
+
const DEFAULT_INITIAL_DELAY_MS = 90_000;
|
|
22
|
+
/** Clamp an operator-supplied interval into the supported window. */
|
|
23
|
+
function clampUpdateCheckInterval(seconds) {
|
|
24
|
+
if (!Number.isFinite(seconds))
|
|
25
|
+
return exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS;
|
|
26
|
+
const whole = Math.floor(seconds);
|
|
27
|
+
if (whole < exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS)
|
|
28
|
+
return exports.MIN_UPDATE_CHECK_INTERVAL_SECONDS;
|
|
29
|
+
if (whole > exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS)
|
|
30
|
+
return exports.MAX_UPDATE_CHECK_INTERVAL_SECONDS;
|
|
31
|
+
return whole;
|
|
32
|
+
}
|
|
33
|
+
class UpdateCheckScheduler {
|
|
34
|
+
logger;
|
|
35
|
+
targets;
|
|
36
|
+
getIntervalSeconds;
|
|
37
|
+
initialDelayMs;
|
|
38
|
+
timer = null;
|
|
39
|
+
bootTimer = null;
|
|
40
|
+
intervalSeconds = exports.DEFAULT_UPDATE_CHECK_INTERVAL_SECONDS;
|
|
41
|
+
sweeping = false;
|
|
42
|
+
constructor(options) {
|
|
43
|
+
this.logger = options.logger;
|
|
44
|
+
this.targets = options.targets;
|
|
45
|
+
this.getIntervalSeconds = options.getIntervalSeconds;
|
|
46
|
+
this.initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
|
|
47
|
+
}
|
|
48
|
+
/** Interval currently armed, after clamping. Exposed for assertions + logs. */
|
|
49
|
+
currentIntervalSeconds() {
|
|
50
|
+
return this.intervalSeconds;
|
|
51
|
+
}
|
|
52
|
+
/** Arm the poller. Safe to call twice — the previous timers are replaced. */
|
|
53
|
+
start() {
|
|
54
|
+
this.arm();
|
|
55
|
+
if (this.bootTimer !== null)
|
|
56
|
+
clearTimeout(this.bootTimer);
|
|
57
|
+
this.bootTimer = setTimeout(() => {
|
|
58
|
+
this.bootTimer = null;
|
|
59
|
+
void this.runSweep();
|
|
60
|
+
}, this.initialDelayMs);
|
|
61
|
+
this.bootTimer.unref?.();
|
|
62
|
+
}
|
|
63
|
+
/** Re-read the interval from settings and re-arm if it moved. */
|
|
64
|
+
reschedule() {
|
|
65
|
+
const next = clampUpdateCheckInterval(this.getIntervalSeconds());
|
|
66
|
+
if (this.timer !== null && next === this.intervalSeconds)
|
|
67
|
+
return;
|
|
68
|
+
this.arm();
|
|
69
|
+
}
|
|
70
|
+
stop() {
|
|
71
|
+
if (this.timer !== null)
|
|
72
|
+
clearInterval(this.timer);
|
|
73
|
+
if (this.bootTimer !== null)
|
|
74
|
+
clearTimeout(this.bootTimer);
|
|
75
|
+
this.timer = null;
|
|
76
|
+
this.bootTimer = null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One full sweep. Never throws and never runs concurrently with itself — an
|
|
80
|
+
* overlapping tick (slow registry, long agent fan-out) is dropped WITH a log
|
|
81
|
+
* rather than doubling the npm traffic.
|
|
82
|
+
*/
|
|
83
|
+
async runSweep() {
|
|
84
|
+
if (this.sweeping) {
|
|
85
|
+
this.logger.warn('Update check skipped — previous sweep still running');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.sweeping = true;
|
|
89
|
+
try {
|
|
90
|
+
const nodes = this.targets.listNodes();
|
|
91
|
+
await this.attempt('hub addon packages', 'hub', () => this.targets.checkHubAddons());
|
|
92
|
+
await this.attempt('framework packages', 'hub', () => this.targets.checkFrameworkPackages());
|
|
93
|
+
for (const node of nodes) {
|
|
94
|
+
if (!node.isOnline) {
|
|
95
|
+
// D58: never dispatch at a node the registry already called offline —
|
|
96
|
+
// and a dropped branch is never silent.
|
|
97
|
+
this.logger.warn('Update check skipped — node offline', { tags: { nodeId: node.id } });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
await this.attempt('node server package', node.id, () => this.targets.checkNodeServerUpdate(node.id, node.isHub));
|
|
101
|
+
if (node.isHub)
|
|
102
|
+
continue;
|
|
103
|
+
await this.attempt('agent addon packages', node.id, () => this.targets.checkAgentAddons(node.id));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.sweeping = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
arm() {
|
|
111
|
+
if (this.timer !== null)
|
|
112
|
+
clearInterval(this.timer);
|
|
113
|
+
this.intervalSeconds = clampUpdateCheckInterval(this.getIntervalSeconds());
|
|
114
|
+
this.logger.info('Update check scheduled', {
|
|
115
|
+
meta: { intervalSeconds: this.intervalSeconds },
|
|
116
|
+
});
|
|
117
|
+
this.timer = setInterval(() => {
|
|
118
|
+
void this.runSweep();
|
|
119
|
+
}, this.intervalSeconds * 1_000);
|
|
120
|
+
this.timer.unref?.();
|
|
121
|
+
}
|
|
122
|
+
async attempt(what, nodeId, run) {
|
|
123
|
+
try {
|
|
124
|
+
await run();
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// Logged, not published: an empty snapshot would read as "up to date"
|
|
128
|
+
// and destroy the dedup state the operator's notifications rely on.
|
|
129
|
+
this.logger.warn(`Update check failed — ${what}`, {
|
|
130
|
+
tags: { nodeId },
|
|
131
|
+
meta: { error: (0, types_1.errMsg)(error) },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
exports.UpdateCheckScheduler = UpdateCheckScheduler;
|
package/dist/manual-boot.js
CHANGED
|
@@ -77,6 +77,9 @@ const server_update_service_1 = require("./core/server-update/server-update.serv
|
|
|
77
77
|
const storage_service_1 = require("./core/storage/storage.service");
|
|
78
78
|
const stream_probe_service_1 = require("./core/streaming/stream-probe.service");
|
|
79
79
|
const topology_emitter_service_1 = require("./core/topology/topology-emitter.service");
|
|
80
|
+
const update_availability_store_js_1 = require("./core/update-availability-store.js");
|
|
81
|
+
const agent_installed_packages_js_1 = require("./core/updates/agent-installed-packages.js");
|
|
82
|
+
const update_check_scheduler_js_1 = require("./core/updates/update-check-scheduler.js");
|
|
80
83
|
// ---------------------------------------------------------------------------
|
|
81
84
|
// Service container — narrowing via `instanceof`, no casts.
|
|
82
85
|
// ---------------------------------------------------------------------------
|
|
@@ -157,7 +160,10 @@ async function bootManual(opts) {
|
|
|
157
160
|
// AddonWidgetsService — needs AddonRegistryService for the bundled-addon
|
|
158
161
|
// dist sub-folder lookup (see service docstring).
|
|
159
162
|
const addonWidgetsService = new addon_widgets_service_1.AddonWidgetsService(loggingService, capabilityService, addonRegistryService);
|
|
160
|
-
|
|
163
|
+
// Durable `update.available` dedup lives next to `auto-update.json`, so a
|
|
164
|
+
// hub restart never re-announces what the operator already saw.
|
|
165
|
+
const availabilityDataDir = path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data');
|
|
166
|
+
const addonPackageService = new addon_package_service_1.AddonPackageService(loggingService, eventBusService, configService, addonRegistryService, notificationWrapper, toastWrapper, new update_availability_store_js_1.FileUpdateAvailabilityStore(availabilityDataDir, 'addon-packages', loggingService.createLogger('UpdateAvailability')));
|
|
161
167
|
// Addon back-fill delivery seam. Wired here (not in the AgentRegistryService
|
|
162
168
|
// constructor) because it needs AddonPackageService, which is a later layer.
|
|
163
169
|
// Bytes come from THE HUB'S OWN INSTALLED COPY whenever it has one, and only
|
|
@@ -234,9 +240,45 @@ async function bootManual(opts) {
|
|
|
234
240
|
logger: loggingService.createLogger('ServerUpdate'),
|
|
235
241
|
restartServer: (requestedBy) => addonPackageService.restartServer(requestedBy),
|
|
236
242
|
eventBus: eventBusService,
|
|
243
|
+
updateAvailabilityStore: new update_availability_store_js_1.FileUpdateAvailabilityStore(availabilityDataDir, 'server-update', loggingService.createLogger('UpdateAvailability')),
|
|
237
244
|
});
|
|
238
245
|
const topologyEmitterService = new topology_emitter_service_1.TopologyEmitterService(eventBusService, agentRegistryService, addonRegistryService, (0, cap_providers_1.createNodeRootPackageLookup)(moleculerService, serverUpdateService));
|
|
239
246
|
const postBootService = new post_boot_service_1.PostBootService(addonRegistryService, eventBusService, loggingService);
|
|
247
|
+
// ---- Update availability poller ---------------------------------------
|
|
248
|
+
// "Tell me, don't install." Deliberately NOT gated on the auto-update
|
|
249
|
+
// channel — that gate is why the only periodic publisher was dead on every
|
|
250
|
+
// live hub (`{"channel":"off"}`). See UpdateCheckScheduler.
|
|
251
|
+
const updateCheckLogger = loggingService.createLogger('UpdateCheck');
|
|
252
|
+
const updateCheckScheduler = new update_check_scheduler_js_1.UpdateCheckScheduler({
|
|
253
|
+
logger: updateCheckLogger,
|
|
254
|
+
getIntervalSeconds: () => addonPackageService.getAutoUpdateSettings().updateCheckIntervalSeconds,
|
|
255
|
+
targets: {
|
|
256
|
+
listNodes: () => agentRegistryService.listNodeLiveness(),
|
|
257
|
+
checkHubAddons: () => addonPackageService.checkUpdates(true),
|
|
258
|
+
checkFrameworkPackages: () => addonPackageService.listFrameworkPackages(),
|
|
259
|
+
checkAgentAddons: async (nodeId) => {
|
|
260
|
+
const broker = moleculerService.broker;
|
|
261
|
+
const installed = await (0, agent_installed_packages_js_1.fetchAgentInstalledPackages)(broker, nodeId);
|
|
262
|
+
return addonPackageService.checkUpdatesForInstalled(installed, nodeId);
|
|
263
|
+
},
|
|
264
|
+
checkNodeServerUpdate: async (nodeId, isHub) => {
|
|
265
|
+
if (isHub)
|
|
266
|
+
return serverUpdateService.checkServerUpdate();
|
|
267
|
+
const proxy = moleculerService.createCapabilityProxy('server-management', nodeId);
|
|
268
|
+
if (proxy === null) {
|
|
269
|
+
// A node with no reachable `server-management` cannot be checked —
|
|
270
|
+
// say so rather than counting it as up to date.
|
|
271
|
+
updateCheckLogger.warn('Update check skipped — server-management unreachable', {
|
|
272
|
+
tags: { nodeId },
|
|
273
|
+
});
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
return proxy['checkServerUpdate']?.({});
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
addonPackageService.setUpdateCheckRescheduler(() => updateCheckScheduler.reschedule());
|
|
281
|
+
updateCheckScheduler.start();
|
|
240
282
|
// ---- Container ---------------------------------------------------------
|
|
241
283
|
const container = new ServiceContainer();
|
|
242
284
|
container.register(config_service_1.ConfigService, configService);
|
|
@@ -262,6 +304,7 @@ async function bootManual(opts) {
|
|
|
262
304
|
container.register(server_update_service_1.ServerUpdateService, serverUpdateService);
|
|
263
305
|
container.register(topology_emitter_service_1.TopologyEmitterService, topologyEmitterService);
|
|
264
306
|
container.register(post_boot_service_1.PostBootService, postBootService);
|
|
307
|
+
container.register(update_check_scheduler_js_1.UpdateCheckScheduler, updateCheckScheduler);
|
|
265
308
|
// ---- Fastify instance --------------------------------------------------
|
|
266
309
|
const fastify = (0, fastify_1.default)(fastifyOpts);
|
|
267
310
|
// /health and /health/* — registered in `main.ts` via
|
|
@@ -278,6 +321,12 @@ async function bootManual(opts) {
|
|
|
278
321
|
const logErr = (label, err) => {
|
|
279
322
|
console.error(`[manual-boot] ${label} destroy failed:`, err);
|
|
280
323
|
};
|
|
324
|
+
try {
|
|
325
|
+
updateCheckScheduler.stop();
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
logErr('UpdateCheckScheduler', err);
|
|
329
|
+
}
|
|
281
330
|
try {
|
|
282
331
|
topologyEmitterService.onModuleDestroy();
|
|
283
332
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.128",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
36
|
+
"@camstack/addon-admin-ui": "1.2.77",
|
|
37
37
|
"@camstack/addon-agent-ui": "1.2.20",
|
|
38
38
|
"@camstack/addon-auth": "1.2.22",
|
|
39
39
|
"@camstack/addon-decoder-nodeav": "1.2.19",
|
|
40
40
|
"@camstack/addon-notifiers": "1.2.24",
|
|
41
|
-
"@camstack/addon-pipeline": "1.2.
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.93",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.76",
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.91",
|
|
44
44
|
"@camstack/sdk": "1.2.22",
|
|
45
45
|
"@camstack/shm-ring": "1.1.19",
|
|
46
46
|
"@camstack/system": "1.2.102",
|
|
47
|
-
"@camstack/types": "1.2.
|
|
48
|
-
"@camstack/ui-library": "1.2.
|
|
47
|
+
"@camstack/types": "1.2.85",
|
|
48
|
+
"@camstack/ui-library": "1.2.58",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|