@attraccess/plugin-wago 0.1.0-nightly.1083.1 → 0.1.0-nightly.1086.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/dist/index.js
CHANGED
|
@@ -3190,17 +3190,19 @@ function validEventPayload(data, kind, canonical) {
|
|
|
3190
3190
|
return true;
|
|
3191
3191
|
}
|
|
3192
3192
|
function validStatePayload(data, kind, canonical) {
|
|
3193
|
-
if (kind
|
|
3193
|
+
if (kind !== "state") return true;
|
|
3194
|
+
if (data.manualOutputChannelIds !== void 0 && (!Array.isArray(data.manualOutputChannelIds) || data.manualOutputChannelIds.length > MAX_CHANNELS || !data.manualOutputChannelIds.every(identifier)))
|
|
3194
3195
|
return false;
|
|
3195
|
-
|
|
3196
|
+
return !canonical || validCanonicalState(data);
|
|
3197
|
+
}
|
|
3198
|
+
function validCanonicalState(data) {
|
|
3199
|
+
if (data.readiness !== void 0 && (!isObject(data.readiness) || data.readiness.hardwareAvailable !== void 0 && typeof data.readiness.hardwareAvailable !== "boolean"))
|
|
3196
3200
|
return false;
|
|
3197
|
-
if (
|
|
3201
|
+
if (!(data.contentHash === null || typeof data.contentHash === "string" && /^[0-9a-f]{64}$/i.test(data.contentHash)))
|
|
3198
3202
|
return false;
|
|
3199
|
-
|
|
3203
|
+
return typeof data.connected === "boolean" && (data.revision === null || Number.isSafeInteger(data.revision) && data.revision >= 0) && isObject(data.outputs) && (data.inputs === void 0 || isObject(data.inputs)) && [...Object.values(data.outputs), ...Object.values(isObject(data.inputs) ? data.inputs : {})].every(
|
|
3200
3204
|
(value) => typeof value === "boolean"
|
|
3201
|
-
)
|
|
3202
|
-
return false;
|
|
3203
|
-
return true;
|
|
3205
|
+
);
|
|
3204
3206
|
}
|
|
3205
3207
|
function freshness(timestamp, now = Date.now(), maxAge = 9e4) {
|
|
3206
3208
|
if (!timestamp) return "missing";
|
|
@@ -3785,27 +3787,13 @@ function wagoFlowPreview(config, kind, controller, snapshot, names) {
|
|
|
3785
3787
|
function localizedPreview(config, kind, controller, snapshot, names, german) {
|
|
3786
3788
|
const text = (english, translated) => german ? translated : english;
|
|
3787
3789
|
const channel = snapshot?.logicalChannels.find((item) => item.id === config.channelId);
|
|
3788
|
-
const point = snapshot?.physicalPoints?.find((item) => item.id === channel?.physicalPointId);
|
|
3789
|
-
let channelName = channel ? typeof names[channel.id] === "string" ? String(names[channel.id]) : channel.id : typeof config.channelId === "string" ? `${config.channelId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt");
|
|
3790
|
-
if (point?.modbus) {
|
|
3791
|
-
const device = snapshot?.modbus?.devices.find((item) => item.id === point.modbus?.deviceId);
|
|
3792
|
-
if (device) channelName = `${channelName} \xB7 ${device.name}`;
|
|
3793
|
-
} else if (point?.hardwareProfile === "751-9301") {
|
|
3794
|
-
const terminal = DIGITAL_TERMINALS.find((item) => item.channel === point.channel)?.label;
|
|
3795
|
-
if (terminal && !channelName.includes(terminal)) channelName += ` \xB7 ${terminal}`;
|
|
3796
|
-
}
|
|
3797
3790
|
const rows = [
|
|
3798
|
-
{
|
|
3799
|
-
|
|
3800
|
-
value: controller?.name || controller?.hardwareId || (typeof config.controllerId === "number" ? `Controller ${config.controllerId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt"))
|
|
3801
|
-
},
|
|
3802
|
-
{ label: text("Channel", "Kanal"), value: channelName }
|
|
3791
|
+
{ label: text("Device", "Ger\xE4t"), value: deviceLabel(config, controller, text) },
|
|
3792
|
+
{ label: text("Channel", "Kanal"), value: channelLabel(config, channel, snapshot, names, text) }
|
|
3803
3793
|
];
|
|
3804
|
-
if (kind === "command")
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
}
|
|
3808
|
-
const category = config.category === "measurement" ? text("Measurement", "Messwert") : config.category === "fault" ? text("Fault", "Fehler") : config.category === "state" ? channel?.capabilities.includes("input") ? text("Input state", "Eingangszustand") : text("Output state", "Ausgangszustand") : void 0;
|
|
3794
|
+
if (kind === "command")
|
|
3795
|
+
return [...rows, { label: text("Action", "Aktion"), value: commandAction(config, channel, text) }];
|
|
3796
|
+
const category = categoryLabel(config, channel, text);
|
|
3809
3797
|
if (kind === "event")
|
|
3810
3798
|
return [
|
|
3811
3799
|
...rows,
|
|
@@ -3816,12 +3804,11 @@ function localizedPreview(config, kind, controller, snapshot, names, german) {
|
|
|
3816
3804
|
];
|
|
3817
3805
|
if (kind === "read")
|
|
3818
3806
|
return [...rows, { label: text("Read", "Lesen"), value: category ?? text("Not selected", "Nicht ausgew\xE4hlt") }];
|
|
3819
|
-
const expected = typeof config.equals === "boolean" ? config.equals ? text("ON", "EIN") : text("OFF", "AUS") : typeof config.equals === "number" && Number.isFinite(config.equals) ? `${config.equals} (${text("wire value", "Rohwert")})` : text("Not set", "Nicht festgelegt");
|
|
3820
3807
|
return [
|
|
3821
3808
|
...rows,
|
|
3822
3809
|
{
|
|
3823
3810
|
label: text("Wait for", "Warten auf"),
|
|
3824
|
-
value: `${category ?? text("Not selected", "Nicht ausgew\xE4hlt")} = ${
|
|
3811
|
+
value: `${category ?? text("Not selected", "Nicht ausgew\xE4hlt")} = ${expectedValue(config, text)}`
|
|
3825
3812
|
},
|
|
3826
3813
|
{
|
|
3827
3814
|
label: text("Timeout", "Zeitlimit"),
|
|
@@ -3829,6 +3816,38 @@ function localizedPreview(config, kind, controller, snapshot, names, german) {
|
|
|
3829
3816
|
}
|
|
3830
3817
|
];
|
|
3831
3818
|
}
|
|
3819
|
+
function deviceLabel(config, controller, text) {
|
|
3820
|
+
return controller?.name || controller?.hardwareId || (typeof config.controllerId === "number" ? `Controller ${config.controllerId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt"));
|
|
3821
|
+
}
|
|
3822
|
+
function channelLabel(config, channel, snapshot, names, text) {
|
|
3823
|
+
const point = snapshot?.physicalPoints?.find((item) => item.id === channel?.physicalPointId);
|
|
3824
|
+
let channelName = channel ? typeof names[channel.id] === "string" ? String(names[channel.id]) : channel.id : typeof config.channelId === "string" ? `${config.channelId} (${text("unavailable", "nicht verf\xFCgbar")})` : text("Not selected", "Nicht ausgew\xE4hlt");
|
|
3825
|
+
if (point?.modbus) {
|
|
3826
|
+
const device = snapshot?.modbus?.devices.find((item) => item.id === point.modbus?.deviceId);
|
|
3827
|
+
if (device) channelName = `${channelName} \xB7 ${device.name}`;
|
|
3828
|
+
} else if (point?.hardwareProfile === "751-9301") {
|
|
3829
|
+
const terminal = DIGITAL_TERMINALS.find((item) => item.channel === point.channel)?.label;
|
|
3830
|
+
if (terminal && !channelName.includes(terminal)) channelName += ` \xB7 ${terminal}`;
|
|
3831
|
+
}
|
|
3832
|
+
return channelName;
|
|
3833
|
+
}
|
|
3834
|
+
function commandAction(config, channel, text) {
|
|
3835
|
+
if (config.action === "set")
|
|
3836
|
+
return config.value === true ? text("Turn ON", "Einschalten") : config.value === false ? text("Turn OFF", "Ausschalten") : text("Select ON/OFF", "EIN/AUS ausw\xE4hlen");
|
|
3837
|
+
if (config.action === "pulse")
|
|
3838
|
+
return `${text("Pulse", "Impuls")}${channel?.pulse ? ` \xB7 ${duration(channel.pulse.durationMs)}` : ""}`;
|
|
3839
|
+
return text("Not selected", "Nicht ausgew\xE4hlt");
|
|
3840
|
+
}
|
|
3841
|
+
function categoryLabel(config, channel, text) {
|
|
3842
|
+
if (config.category === "measurement") return text("Measurement", "Messwert");
|
|
3843
|
+
if (config.category === "fault") return text("Fault", "Fehler");
|
|
3844
|
+
if (config.category !== "state") return void 0;
|
|
3845
|
+
return channel?.capabilities.includes("input") ? text("Input state", "Eingangszustand") : text("Output state", "Ausgangszustand");
|
|
3846
|
+
}
|
|
3847
|
+
function expectedValue(config, text) {
|
|
3848
|
+
if (typeof config.equals === "boolean") return config.equals ? text("ON", "EIN") : text("OFF", "AUS");
|
|
3849
|
+
return typeof config.equals === "number" && Number.isFinite(config.equals) ? `${config.equals} (${text("wire value", "Rohwert")})` : text("Not set", "Nicht festgelegt");
|
|
3850
|
+
}
|
|
3832
3851
|
function duration(milliseconds) {
|
|
3833
3852
|
return milliseconds < 1e3 ? `${milliseconds} ms` : `${milliseconds / 1e3} s`;
|
|
3834
3853
|
}
|
|
@@ -5441,22 +5460,9 @@ var WagoControllerEnrollment = class extends WagoRevisions {
|
|
|
5441
5460
|
// backend/controllers/subscriptions.ts
|
|
5442
5461
|
var WagoSubscriptions = class extends WagoControllerEnrollment {
|
|
5443
5462
|
async onHeartbeat(hardwareId, payload) {
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
} catch (error) {
|
|
5448
|
-
this.context.logger.warn(
|
|
5449
|
-
`Ignoring invalid WAGO heartbeat: ${error instanceof Error ? error.message : String(error)}`
|
|
5450
|
-
);
|
|
5451
|
-
return;
|
|
5452
|
-
}
|
|
5453
|
-
if (heartbeat.hardwareId !== hardwareId) return;
|
|
5454
|
-
let rawHeartbeat;
|
|
5455
|
-
try {
|
|
5456
|
-
rawHeartbeat = JSON.parse(payload.toString("utf8"));
|
|
5457
|
-
} catch {
|
|
5458
|
-
return;
|
|
5459
|
-
}
|
|
5463
|
+
const parsed = this.parseHeartbeatPayload(hardwareId, payload);
|
|
5464
|
+
if (!parsed) return;
|
|
5465
|
+
const { heartbeat, rawHeartbeat } = parsed;
|
|
5460
5466
|
const canonical = canonicalEnvelope(rawHeartbeat, "heartbeat");
|
|
5461
5467
|
const controller = await this.controllers.findOneBy({ hardwareId });
|
|
5462
5468
|
if (!controller || controller.trustState !== "claimed" || !canonical && heartbeat.sequence !== void 0 && heartbeat.sequence < controller.lastSequence)
|
|
@@ -5466,23 +5472,47 @@ var WagoSubscriptions = class extends WagoControllerEnrollment {
|
|
|
5466
5472
|
const admitted = this.diagnostics.ingest(controller.id, "heartbeat", payload);
|
|
5467
5473
|
if (canTrackDiagnostics && !admitted) return;
|
|
5468
5474
|
const heartbeatAt = admitted ? this.diagnostics.read(controller.id).heartbeatAt : typeof rawHeartbeat.timestamp === "string" ? rawHeartbeat.timestamp : void 0;
|
|
5469
|
-
|
|
5470
|
-
if (canonical && (
|
|
5475
|
+
if (canonical && !acceptedCanonicalHeartbeat(rawHeartbeat, heartbeatAt, controller.lastHeartbeatAt)) return;
|
|
5476
|
+
if (canonical && (admitted || !canTrackDiagnostics))
|
|
5477
|
+
this.reportRuntimeStatus(controller.id, heartbeat, rawHeartbeat);
|
|
5478
|
+
if (controller.lastHeartbeatAt && freshness(controller.lastSeenAt, Date.now(), 3e4) === "fresh" && !heartbeatMetadataChanged(controller, heartbeat))
|
|
5471
5479
|
return;
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5480
|
+
await this.checkpointHeartbeat(controller, heartbeat, canonical, heartbeatAt, now);
|
|
5481
|
+
}
|
|
5482
|
+
/** Parse a heartbeat for the expected controller; malformed packets are ignored. */
|
|
5483
|
+
parseHeartbeatPayload(hardwareId, payload) {
|
|
5484
|
+
let heartbeat;
|
|
5485
|
+
try {
|
|
5486
|
+
heartbeat = parseHeartbeat(payload);
|
|
5487
|
+
} catch (error) {
|
|
5488
|
+
this.context.logger.warn(
|
|
5489
|
+
`Ignoring invalid WAGO heartbeat: ${error instanceof Error ? error.message : String(error)}`
|
|
5490
|
+
);
|
|
5491
|
+
return void 0;
|
|
5482
5492
|
}
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
return;
|
|
5493
|
+
if (heartbeat.hardwareId !== hardwareId) return void 0;
|
|
5494
|
+
try {
|
|
5495
|
+
return { heartbeat, rawHeartbeat: JSON.parse(payload.toString("utf8")) };
|
|
5496
|
+
} catch {
|
|
5497
|
+
return void 0;
|
|
5498
|
+
}
|
|
5499
|
+
}
|
|
5500
|
+
reportRuntimeStatus(controllerId, heartbeat, rawHeartbeat) {
|
|
5501
|
+
if (typeof rawHeartbeat.streamId !== "string" || typeof rawHeartbeat.timestamp !== "string") return;
|
|
5502
|
+
this.runtimeStatusHandler?.(controllerId, {
|
|
5503
|
+
imageId: heartbeat.runtimeImageId ?? "",
|
|
5504
|
+
runtimeVersion: heartbeat.runtimeVersion,
|
|
5505
|
+
streamId: rawHeartbeat.streamId,
|
|
5506
|
+
timestamp: Date.parse(rawHeartbeat.timestamp),
|
|
5507
|
+
receivedAt: Date.now(),
|
|
5508
|
+
sequence: rawHeartbeat.sequence,
|
|
5509
|
+
...heartbeat.runtimePolicyToken ? { runtimePolicyToken: heartbeat.runtimePolicyToken } : {}
|
|
5510
|
+
});
|
|
5511
|
+
}
|
|
5512
|
+
/** Persist a heartbeat checkpoint. Canonical heartbeats record their source
|
|
5513
|
+
* time; legacy heartbeats advance the sequence and use receipt time.
|
|
5514
|
+
*/
|
|
5515
|
+
async checkpointHeartbeat(controller, heartbeat, canonical, heartbeatAt, now) {
|
|
5486
5516
|
controller.protocolVersion = heartbeat.protocolVersion;
|
|
5487
5517
|
controller.runtimeVersion = heartbeat.runtimeVersion;
|
|
5488
5518
|
controller.capabilities = JSON.stringify(heartbeat.capabilities);
|
|
@@ -5717,6 +5747,14 @@ var WagoSubscriptions = class extends WagoControllerEnrollment {
|
|
|
5717
5747
|
return rebuild;
|
|
5718
5748
|
}
|
|
5719
5749
|
};
|
|
5750
|
+
function acceptedCanonicalHeartbeat(rawHeartbeat, heartbeatAt, lastHeartbeatAt) {
|
|
5751
|
+
if (!validEnvelope(rawHeartbeat, Date.now()) || !heartbeatAt) return false;
|
|
5752
|
+
const persistedHeartbeatAt = sourceTime(lastHeartbeatAt);
|
|
5753
|
+
return persistedHeartbeatAt === null || (sourceTime(heartbeatAt) ?? 0) >= persistedHeartbeatAt;
|
|
5754
|
+
}
|
|
5755
|
+
function heartbeatMetadataChanged(controller, heartbeat) {
|
|
5756
|
+
return controller.protocolVersion !== heartbeat.protocolVersion || controller.runtimeVersion !== heartbeat.runtimeVersion || controller.capabilities !== JSON.stringify(heartbeat.capabilities) || controller.compatibilityError !== compatibilityError(heartbeat);
|
|
5757
|
+
}
|
|
5720
5758
|
|
|
5721
5759
|
// backend/controllers/service.ts
|
|
5722
5760
|
var WagoService = class extends WagoSubscriptions {
|
|
@@ -9223,6 +9261,11 @@ var RuntimeUpdateError = class extends Error {
|
|
|
9223
9261
|
var LEASE_MS = 30 * 6e4;
|
|
9224
9262
|
var CURRENT_RECHECK_MS = 5 * 6e4;
|
|
9225
9263
|
var OPERATION_MS = 25 * 6e4;
|
|
9264
|
+
function inspectionBlocker(inspection) {
|
|
9265
|
+
if (!inspection.claimed || !inspection.managed) return "management_required";
|
|
9266
|
+
if (!inspection.online) return "offline";
|
|
9267
|
+
return inspection.compatible ? null : "incompatible";
|
|
9268
|
+
}
|
|
9226
9269
|
var WagoRuntimeUpdateCoordinator = class {
|
|
9227
9270
|
constructor(store, desired, host, audit, now = Date.now, concurrency = 2) {
|
|
9228
9271
|
__publicField(this, "store", store);
|
|
@@ -9245,133 +9288,12 @@ var WagoRuntimeUpdateCoordinator = class {
|
|
|
9245
9288
|
if (this.stopped || this.running.has(controllerId) || this.running.size >= this.concurrency) return "busy";
|
|
9246
9289
|
this.running.add(controllerId);
|
|
9247
9290
|
let acquired = false;
|
|
9248
|
-
const
|
|
9291
|
+
const lease = this.createLease(controllerId);
|
|
9292
|
+
const { owner, operation, timer } = lease;
|
|
9249
9293
|
try {
|
|
9250
9294
|
acquired = await this.store.acquire(controllerId, owner, this.now(), this.now() + LEASE_MS);
|
|
9251
9295
|
if (!acquired) return "busy";
|
|
9252
|
-
|
|
9253
|
-
await advanceRuntimeRetry(record2, retry, persist);
|
|
9254
|
-
if (record2 && ["current", "failed"].includes(record2.phase) && record2.token) {
|
|
9255
|
-
if (!await acknowledge(record2)) return "deferred";
|
|
9256
|
-
}
|
|
9257
|
-
if (record2 && active.has(record2.phase)) {
|
|
9258
|
-
if (record2.phase === "recovery_required" && record2.retryAt > this.now()) return "deferred";
|
|
9259
|
-
if (!record2.token || !record2.previousImageId) throw new RuntimeUpdateError("recovery");
|
|
9260
|
-
record2.phase = "recovering";
|
|
9261
|
-
await persist(record2);
|
|
9262
|
-
try {
|
|
9263
|
-
await this.host.recover(controllerId, record2.token, record2.previousImageId, operation.signal);
|
|
9264
|
-
assertOwned();
|
|
9265
|
-
} catch {
|
|
9266
|
-
record2.phase = "recovery_required";
|
|
9267
|
-
record2.failure = "recovery";
|
|
9268
|
-
record2.retryAt = this.now() + 6e4;
|
|
9269
|
-
await persist(record2);
|
|
9270
|
-
return "settled";
|
|
9271
|
-
}
|
|
9272
|
-
record2.phase = "failed";
|
|
9273
|
-
record2.failure = "interrupted";
|
|
9274
|
-
record2.retryAt = this.now();
|
|
9275
|
-
await persist(record2);
|
|
9276
|
-
if (!await acknowledge(record2)) return "deferred";
|
|
9277
|
-
return "settled";
|
|
9278
|
-
}
|
|
9279
|
-
const desired = await this.desired();
|
|
9280
|
-
assertOwned();
|
|
9281
|
-
const contradictedCurrent = record2?.phase === "current" && observedImageId !== void 0 && observedImageId !== (record2.currentImageId ?? record2.desiredImageId);
|
|
9282
|
-
if (record2 && !contradictedCurrent && record2.desiredImageId === desired.imageId && record2.retryAt > this.now())
|
|
9283
|
-
return "deferred";
|
|
9284
|
-
const attempt = (record2?.desiredImageId === desired.imageId ? record2.attempt : 0) + 1;
|
|
9285
|
-
let inspection;
|
|
9286
|
-
try {
|
|
9287
|
-
inspection = await this.host.inspect(controllerId, operation.signal);
|
|
9288
|
-
assertOwned();
|
|
9289
|
-
} catch (error) {
|
|
9290
|
-
await persist({
|
|
9291
|
-
controllerId,
|
|
9292
|
-
phase: "blocked",
|
|
9293
|
-
token: null,
|
|
9294
|
-
desiredImageId: desired.imageId,
|
|
9295
|
-
desiredRuntimeVersion: desired.manifest.runtimeVersion,
|
|
9296
|
-
previousRuntimeVersion: record2?.desiredImageId === desired.imageId ? record2.previousRuntimeVersion ?? null : null,
|
|
9297
|
-
desiredDigest: desired.digest,
|
|
9298
|
-
buildId: desired.buildId,
|
|
9299
|
-
previousImageId: record2?.desiredImageId === desired.imageId ? record2.previousImageId : null,
|
|
9300
|
-
attempt,
|
|
9301
|
-
startedAt: this.now(),
|
|
9302
|
-
updatedAt: this.now(),
|
|
9303
|
-
retryAt: this.retryAt(attempt),
|
|
9304
|
-
failure: error instanceof RuntimeUpdateError ? error.failure : "offline"
|
|
9305
|
-
});
|
|
9306
|
-
return "settled";
|
|
9307
|
-
}
|
|
9308
|
-
const previous = record2;
|
|
9309
|
-
const retainPrevious = previous?.desiredImageId === desired.imageId && inspection.imageId === desired.imageId;
|
|
9310
|
-
record2 = {
|
|
9311
|
-
controllerId,
|
|
9312
|
-
phase: "blocked",
|
|
9313
|
-
token: null,
|
|
9314
|
-
desiredImageId: desired.imageId,
|
|
9315
|
-
desiredRuntimeVersion: desired.manifest.runtimeVersion,
|
|
9316
|
-
previousRuntimeVersion: retainPrevious ? previous.previousRuntimeVersion ?? inspection.runtimeVersion ?? null : inspection.runtimeVersion ?? null,
|
|
9317
|
-
desiredDigest: desired.digest,
|
|
9318
|
-
buildId: desired.buildId,
|
|
9319
|
-
...desired.installerSha256 ? { installerSha256: desired.installerSha256 } : {},
|
|
9320
|
-
previousImageId: retainPrevious ? previous.previousImageId ?? inspection.imageId : inspection.imageId,
|
|
9321
|
-
attempt,
|
|
9322
|
-
startedAt: this.now(),
|
|
9323
|
-
updatedAt: this.now(),
|
|
9324
|
-
retryAt: 0,
|
|
9325
|
-
failure: null,
|
|
9326
|
-
cleanupAttempt: 0,
|
|
9327
|
-
cleanupRetryAt: 0
|
|
9328
|
-
};
|
|
9329
|
-
const blocker = !inspection.claimed || !inspection.managed ? "management_required" : !inspection.online ? "offline" : !inspection.compatible ? "incompatible" : null;
|
|
9330
|
-
if (blocker) {
|
|
9331
|
-
record2.failure = blocker;
|
|
9332
|
-
record2.retryAt = this.retryAt(attempt);
|
|
9333
|
-
await persist(record2);
|
|
9334
|
-
return "settled";
|
|
9335
|
-
}
|
|
9336
|
-
if (this.host.prepare) {
|
|
9337
|
-
record2.phase = "preparing";
|
|
9338
|
-
await persist(record2);
|
|
9339
|
-
try {
|
|
9340
|
-
await this.host.prepare(controllerId, desired, operation.signal);
|
|
9341
|
-
await this.assertCurrent(desired.imageId);
|
|
9342
|
-
assertOwned();
|
|
9343
|
-
} catch (error) {
|
|
9344
|
-
assertOwned();
|
|
9345
|
-
record2.phase = "blocked";
|
|
9346
|
-
record2.failure = error instanceof RuntimeUpdateError ? error.failure : "incompatible";
|
|
9347
|
-
record2.retryAt = this.retryAt(attempt);
|
|
9348
|
-
await persist(record2);
|
|
9349
|
-
return "settled";
|
|
9350
|
-
}
|
|
9351
|
-
}
|
|
9352
|
-
const targetImageId = runtimeTargetImageId(desired, inspection);
|
|
9353
|
-
if (inspection.imageId === targetImageId)
|
|
9354
|
-
return await this.verifyExisting(
|
|
9355
|
-
controllerId,
|
|
9356
|
-
desired,
|
|
9357
|
-
record2,
|
|
9358
|
-
operation,
|
|
9359
|
-
assertOwned,
|
|
9360
|
-
persist,
|
|
9361
|
-
attempt,
|
|
9362
|
-
targetImageId
|
|
9363
|
-
);
|
|
9364
|
-
return await this.applyRollout(
|
|
9365
|
-
controllerId,
|
|
9366
|
-
desired,
|
|
9367
|
-
record2,
|
|
9368
|
-
inspection,
|
|
9369
|
-
operation,
|
|
9370
|
-
assertOwned,
|
|
9371
|
-
persist,
|
|
9372
|
-
acknowledge,
|
|
9373
|
-
attempt
|
|
9374
|
-
);
|
|
9296
|
+
return await this.reconcileOwned(controllerId, retry, observedImageId, lease);
|
|
9375
9297
|
} finally {
|
|
9376
9298
|
clearTimeout(timer);
|
|
9377
9299
|
operation.abort();
|
|
@@ -9384,6 +9306,146 @@ var WagoRuntimeUpdateCoordinator = class {
|
|
|
9384
9306
|
}
|
|
9385
9307
|
}
|
|
9386
9308
|
}
|
|
9309
|
+
async reconcileOwned(controllerId, retry, observedImageId, lease) {
|
|
9310
|
+
const { operation, assertOwned, persist, acknowledge } = lease;
|
|
9311
|
+
let record2 = await this.store.load(controllerId);
|
|
9312
|
+
await advanceRuntimeRetry(record2, retry, persist);
|
|
9313
|
+
if (record2 && ["current", "failed"].includes(record2.phase) && record2.token) {
|
|
9314
|
+
if (!await acknowledge(record2)) return "deferred";
|
|
9315
|
+
}
|
|
9316
|
+
if (record2 && active.has(record2.phase)) return await this.recoverInterrupted(controllerId, record2, lease);
|
|
9317
|
+
const desired = await this.desired();
|
|
9318
|
+
assertOwned();
|
|
9319
|
+
const contradictedCurrent = record2?.phase === "current" && observedImageId !== void 0 && observedImageId !== (record2.currentImageId ?? record2.desiredImageId);
|
|
9320
|
+
if (record2 && !contradictedCurrent && record2.desiredImageId === desired.imageId && record2.retryAt > this.now())
|
|
9321
|
+
return "deferred";
|
|
9322
|
+
const attempt = (record2?.desiredImageId === desired.imageId ? record2.attempt : 0) + 1;
|
|
9323
|
+
let inspection;
|
|
9324
|
+
try {
|
|
9325
|
+
inspection = await this.host.inspect(controllerId, operation.signal);
|
|
9326
|
+
assertOwned();
|
|
9327
|
+
} catch (error) {
|
|
9328
|
+
await persist(this.uninspectedRecord(controllerId, desired, record2, attempt, error));
|
|
9329
|
+
return "settled";
|
|
9330
|
+
}
|
|
9331
|
+
record2 = this.plannedRecord(controllerId, desired, record2, inspection, attempt);
|
|
9332
|
+
const blocker = inspectionBlocker(inspection);
|
|
9333
|
+
if (blocker) {
|
|
9334
|
+
record2.failure = blocker;
|
|
9335
|
+
record2.retryAt = this.retryAt(attempt);
|
|
9336
|
+
await persist(record2);
|
|
9337
|
+
return "settled";
|
|
9338
|
+
}
|
|
9339
|
+
if (!await this.prepareHost(controllerId, desired, record2, lease, attempt)) return "settled";
|
|
9340
|
+
const targetImageId = runtimeTargetImageId(desired, inspection);
|
|
9341
|
+
if (inspection.imageId === targetImageId)
|
|
9342
|
+
return await this.verifyExisting(
|
|
9343
|
+
controllerId,
|
|
9344
|
+
desired,
|
|
9345
|
+
record2,
|
|
9346
|
+
operation,
|
|
9347
|
+
assertOwned,
|
|
9348
|
+
persist,
|
|
9349
|
+
attempt,
|
|
9350
|
+
targetImageId
|
|
9351
|
+
);
|
|
9352
|
+
return await this.applyRollout(
|
|
9353
|
+
controllerId,
|
|
9354
|
+
desired,
|
|
9355
|
+
record2,
|
|
9356
|
+
inspection,
|
|
9357
|
+
operation,
|
|
9358
|
+
assertOwned,
|
|
9359
|
+
persist,
|
|
9360
|
+
acknowledge,
|
|
9361
|
+
attempt
|
|
9362
|
+
);
|
|
9363
|
+
}
|
|
9364
|
+
/** Resumes an interrupted rollout found in an active phase by asking the host to recover. */
|
|
9365
|
+
async recoverInterrupted(controllerId, record2, { operation, assertOwned, persist, acknowledge }) {
|
|
9366
|
+
if (record2.phase === "recovery_required" && record2.retryAt > this.now()) return "deferred";
|
|
9367
|
+
if (!record2.token || !record2.previousImageId) throw new RuntimeUpdateError("recovery");
|
|
9368
|
+
record2.phase = "recovering";
|
|
9369
|
+
await persist(record2);
|
|
9370
|
+
try {
|
|
9371
|
+
await this.host.recover(controllerId, record2.token, record2.previousImageId, operation.signal);
|
|
9372
|
+
assertOwned();
|
|
9373
|
+
} catch {
|
|
9374
|
+
record2.phase = "recovery_required";
|
|
9375
|
+
record2.failure = "recovery";
|
|
9376
|
+
record2.retryAt = this.now() + 6e4;
|
|
9377
|
+
await persist(record2);
|
|
9378
|
+
return "settled";
|
|
9379
|
+
}
|
|
9380
|
+
record2.phase = "failed";
|
|
9381
|
+
record2.failure = "interrupted";
|
|
9382
|
+
record2.retryAt = this.now();
|
|
9383
|
+
await persist(record2);
|
|
9384
|
+
if (!await acknowledge(record2)) return "deferred";
|
|
9385
|
+
return "settled";
|
|
9386
|
+
}
|
|
9387
|
+
/** Blocked record for a controller whose host inspection failed. */
|
|
9388
|
+
uninspectedRecord(controllerId, desired, record2, attempt, error) {
|
|
9389
|
+
const sameTarget = record2?.desiredImageId === desired.imageId;
|
|
9390
|
+
return {
|
|
9391
|
+
controllerId,
|
|
9392
|
+
phase: "blocked",
|
|
9393
|
+
token: null,
|
|
9394
|
+
desiredImageId: desired.imageId,
|
|
9395
|
+
desiredRuntimeVersion: desired.manifest.runtimeVersion,
|
|
9396
|
+
previousRuntimeVersion: sameTarget ? record2.previousRuntimeVersion ?? null : null,
|
|
9397
|
+
desiredDigest: desired.digest,
|
|
9398
|
+
buildId: desired.buildId,
|
|
9399
|
+
previousImageId: sameTarget ? record2.previousImageId : null,
|
|
9400
|
+
attempt,
|
|
9401
|
+
startedAt: this.now(),
|
|
9402
|
+
updatedAt: this.now(),
|
|
9403
|
+
retryAt: this.retryAt(attempt),
|
|
9404
|
+
failure: error instanceof RuntimeUpdateError ? error.failure : "offline"
|
|
9405
|
+
};
|
|
9406
|
+
}
|
|
9407
|
+
/** Fresh blocked record for a new attempt, retaining the rollback target of a repeated one. */
|
|
9408
|
+
plannedRecord(controllerId, desired, previous, inspection, attempt) {
|
|
9409
|
+
const retainPrevious = previous?.desiredImageId === desired.imageId && inspection.imageId === desired.imageId;
|
|
9410
|
+
return {
|
|
9411
|
+
controllerId,
|
|
9412
|
+
phase: "blocked",
|
|
9413
|
+
token: null,
|
|
9414
|
+
desiredImageId: desired.imageId,
|
|
9415
|
+
desiredRuntimeVersion: desired.manifest.runtimeVersion,
|
|
9416
|
+
previousRuntimeVersion: retainPrevious ? previous.previousRuntimeVersion ?? inspection.runtimeVersion ?? null : inspection.runtimeVersion ?? null,
|
|
9417
|
+
desiredDigest: desired.digest,
|
|
9418
|
+
buildId: desired.buildId,
|
|
9419
|
+
...desired.installerSha256 ? { installerSha256: desired.installerSha256 } : {},
|
|
9420
|
+
previousImageId: retainPrevious ? previous.previousImageId ?? inspection.imageId : inspection.imageId,
|
|
9421
|
+
attempt,
|
|
9422
|
+
startedAt: this.now(),
|
|
9423
|
+
updatedAt: this.now(),
|
|
9424
|
+
retryAt: 0,
|
|
9425
|
+
failure: null,
|
|
9426
|
+
cleanupAttempt: 0,
|
|
9427
|
+
cleanupRetryAt: 0
|
|
9428
|
+
};
|
|
9429
|
+
}
|
|
9430
|
+
/** Runs the optional host preparation; false once a blocked record has been persisted. */
|
|
9431
|
+
async prepareHost(controllerId, desired, record2, { operation, assertOwned, persist }, attempt) {
|
|
9432
|
+
if (!this.host.prepare) return true;
|
|
9433
|
+
record2.phase = "preparing";
|
|
9434
|
+
await persist(record2);
|
|
9435
|
+
try {
|
|
9436
|
+
await this.host.prepare(controllerId, desired, operation.signal);
|
|
9437
|
+
await this.assertCurrent(desired.imageId);
|
|
9438
|
+
assertOwned();
|
|
9439
|
+
} catch (error) {
|
|
9440
|
+
assertOwned();
|
|
9441
|
+
record2.phase = "blocked";
|
|
9442
|
+
record2.failure = error instanceof RuntimeUpdateError ? error.failure : "incompatible";
|
|
9443
|
+
record2.retryAt = this.retryAt(attempt);
|
|
9444
|
+
await persist(record2);
|
|
9445
|
+
return false;
|
|
9446
|
+
}
|
|
9447
|
+
return true;
|
|
9448
|
+
}
|
|
9387
9449
|
async verifyExisting(controllerId, desired, record2, operation, assertOwned, persist, attempt, targetImageId) {
|
|
9388
9450
|
const since = record2.startedAt;
|
|
9389
9451
|
try {
|
|
@@ -10302,27 +10364,12 @@ var WagoManagedRuntimeEnrollment = class extends WagoManagedRuntimeServiceState
|
|
|
10302
10364
|
}
|
|
10303
10365
|
}
|
|
10304
10366
|
async publicStatus(access, requestedControllerId) {
|
|
10305
|
-
|
|
10306
|
-
try {
|
|
10307
|
-
const stored = await this.loadSession(access.sessionId);
|
|
10308
|
-
if (!stored) throw new Error();
|
|
10309
|
-
this.credentials(stored);
|
|
10310
|
-
} catch {
|
|
10311
|
-
await this.setActiveState(access.sessionId, "recovery_required").catch(() => void 0);
|
|
10312
|
-
access = await this.access.findOneBy({ sessionId: access.sessionId });
|
|
10313
|
-
}
|
|
10314
|
-
}
|
|
10367
|
+
access = await this.revalidatedAccess(access);
|
|
10315
10368
|
const row = access?.controllerId ? await this.updates.findOneBy({ controllerId: access.controllerId }) : null;
|
|
10316
10369
|
const controllerId = requestedControllerId ?? access?.controllerId;
|
|
10317
10370
|
const controller = controllerId ? await this.controllers.findOneBy({ id: controllerId }) : null;
|
|
10318
10371
|
const desired = controller ? await this.artifacts.current().catch(() => null) : null;
|
|
10319
|
-
const managementSetup = access?.state === "verified" ? this.
|
|
10320
|
-
state: "waiting",
|
|
10321
|
-
reason: controller ? await this.enrolmentWaitReason(
|
|
10322
|
-
controller,
|
|
10323
|
-
await this.sessions.findOneBy({ id: access.sessionId })
|
|
10324
|
-
) ?? "scheduled" : "connection"
|
|
10325
|
-
} : void 0;
|
|
10372
|
+
const managementSetup = access?.state === "verified" ? await this.managementSetup(access, controller, controllerId) : void 0;
|
|
10326
10373
|
const managementFailure = access && ["verified", "recovery_required"].includes(access.state) ? (await this.sessions.findOneBy({ id: access.sessionId }))?.failureReason : null;
|
|
10327
10374
|
return {
|
|
10328
10375
|
...managementFailure ? { managementFailure } : {},
|
|
@@ -10331,22 +10378,51 @@ var WagoManagedRuntimeEnrollment = class extends WagoManagedRuntimeServiceState
|
|
|
10331
10378
|
keyFingerprint: access?.keyFingerprint ?? null,
|
|
10332
10379
|
update: row?.metadata ? JSON.parse(row.metadata) : null,
|
|
10333
10380
|
...managementSetup ? { managementSetup } : {},
|
|
10334
|
-
...controller ? {
|
|
10335
|
-
|
|
10336
|
-
runningVersion: controller.runtimeVersion,
|
|
10337
|
-
runningImageId: this.heartbeats.get(controller.id)?.imageId || null,
|
|
10338
|
-
desiredVersion: desired?.manifest.runtimeVersion ?? null,
|
|
10339
|
-
desiredImageId: desired && "imageId" in desired ? runtimeTargetImageId(desired, {
|
|
10340
|
-
imageId: this.heartbeats.get(controller.id)?.imageId ?? "",
|
|
10341
|
-
runtimeVersion: this.heartbeats.get(controller.id)?.runtimeVersion ?? controller.runtimeVersion
|
|
10342
|
-
}) : null
|
|
10343
|
-
}
|
|
10344
|
-
} : {},
|
|
10345
|
-
...access?.controllerId && this.wago.isRuntimeUpdateRequired ? { runtimeUpdateRequired: this.wago.isRuntimeUpdateRequired(access.controllerId) } : {},
|
|
10346
|
-
...access?.controllerId && this.reconciliationFailures.has(access.controllerId) ? { blocker: this.reconciliationFailures.get(access.controllerId) } : {},
|
|
10381
|
+
...controller ? { runtime: this.runtimeStatus(controller, desired) } : {},
|
|
10382
|
+
...this.runtimeAttention(access?.controllerId),
|
|
10347
10383
|
physicalQualification: "unverified"
|
|
10348
10384
|
};
|
|
10349
10385
|
}
|
|
10386
|
+
/** Marks active access whose stored envelope no longer verifies as needing recovery. */
|
|
10387
|
+
async revalidatedAccess(access) {
|
|
10388
|
+
if (!access || ["retiring", "retired"].includes(access.state)) return access;
|
|
10389
|
+
try {
|
|
10390
|
+
const stored = await this.loadSession(access.sessionId);
|
|
10391
|
+
if (!stored) throw new Error();
|
|
10392
|
+
this.credentials(stored);
|
|
10393
|
+
return access;
|
|
10394
|
+
} catch {
|
|
10395
|
+
await this.setActiveState(access.sessionId, "recovery_required").catch(() => void 0);
|
|
10396
|
+
return this.access.findOneBy({ sessionId: access.sessionId });
|
|
10397
|
+
}
|
|
10398
|
+
}
|
|
10399
|
+
/** Live automatic SSH-completion progress, or the prerequisite it is still waiting for. */
|
|
10400
|
+
async managementSetup(access, controller, controllerId) {
|
|
10401
|
+
return this.enrolmentProgress.get(controllerId ?? 0) ?? {
|
|
10402
|
+
state: "waiting",
|
|
10403
|
+
reason: controller ? await this.enrolmentWaitReason(controller, await this.sessions.findOneBy({ id: access.sessionId })) ?? "scheduled" : "connection"
|
|
10404
|
+
};
|
|
10405
|
+
}
|
|
10406
|
+
/** Running versus desired runtime image for the controller's latest heartbeat. */
|
|
10407
|
+
runtimeStatus(controller, desired) {
|
|
10408
|
+
const heartbeat = this.heartbeats.get(controller.id);
|
|
10409
|
+
return {
|
|
10410
|
+
runningVersion: controller.runtimeVersion,
|
|
10411
|
+
runningImageId: heartbeat?.imageId || null,
|
|
10412
|
+
desiredVersion: desired?.manifest.runtimeVersion ?? null,
|
|
10413
|
+
desiredImageId: desired && "imageId" in desired ? runtimeTargetImageId(desired, {
|
|
10414
|
+
imageId: heartbeat?.imageId ?? "",
|
|
10415
|
+
runtimeVersion: heartbeat?.runtimeVersion ?? controller.runtimeVersion
|
|
10416
|
+
}) : null
|
|
10417
|
+
};
|
|
10418
|
+
}
|
|
10419
|
+
/** Runtime update demand and the last reconciliation blocker for a bound controller. */
|
|
10420
|
+
runtimeAttention(controllerId) {
|
|
10421
|
+
return {
|
|
10422
|
+
...controllerId && this.wago.isRuntimeUpdateRequired ? { runtimeUpdateRequired: this.wago.isRuntimeUpdateRequired(controllerId) } : {},
|
|
10423
|
+
...controllerId && this.reconciliationFailures.has(controllerId) ? { blocker: this.reconciliationFailures.get(controllerId) } : {}
|
|
10424
|
+
};
|
|
10425
|
+
}
|
|
10350
10426
|
async sessionStatus(sessionId) {
|
|
10351
10427
|
return this.publicStatus(await this.access.findOneBy({ sessionId }));
|
|
10352
10428
|
}
|
|
@@ -10797,14 +10873,9 @@ var WagoManagedRuntimeService = class extends WagoManagedRuntimeReconciliation {
|
|
|
10797
10873
|
super(context, wago, artifacts, readiness);
|
|
10798
10874
|
}
|
|
10799
10875
|
async completeEnrolment(controller) {
|
|
10800
|
-
|
|
10801
|
-
|
|
10802
|
-
|
|
10803
|
-
const session = await this.sessions.findOneBy({ id: bound.sessionId });
|
|
10804
|
-
if (!session || !["awaiting_verification", "completed"].includes(session.state)) return false;
|
|
10805
|
-
if (session.hardwareId !== controller.hardwareId || session.mqttServerId !== controller.mqttServerId) return false;
|
|
10806
|
-
let access = await this.loadSession(session.id);
|
|
10807
|
-
if (!access || ["retiring", "retired", "pending"].includes(access.state)) return false;
|
|
10876
|
+
const candidate = await this.enrolmentCandidate(controller);
|
|
10877
|
+
if (!candidate) return false;
|
|
10878
|
+
const { bound, session, access } = candidate;
|
|
10808
10879
|
try {
|
|
10809
10880
|
if (this.credentials(access).hardwareId !== controller.hardwareId) throw new Error();
|
|
10810
10881
|
} catch {
|
|
@@ -10820,129 +10891,172 @@ var WagoManagedRuntimeService = class extends WagoManagedRuntimeReconciliation {
|
|
|
10820
10891
|
const owner = (0, import_node_crypto17.randomBytes)(16).toString("hex");
|
|
10821
10892
|
if (!await this.operations.acquire(access.fingerprint, owner, Date.now(), Date.now() + 22 * 6e4)) return false;
|
|
10822
10893
|
const operation = new AbortController();
|
|
10823
|
-
|
|
10824
|
-
|
|
10825
|
-
|
|
10826
|
-
|
|
10827
|
-
|
|
10894
|
+
const attempt = {
|
|
10895
|
+
controller,
|
|
10896
|
+
session,
|
|
10897
|
+
owner,
|
|
10898
|
+
operation,
|
|
10899
|
+
// Key confirmation, acceptance and cutover can each wait 310s for a
|
|
10900
|
+
// supervisor gate. The rollback window starts only once SSH actually changes.
|
|
10901
|
+
timer: setTimeout(() => operation.abort(), 18 * 6e4).unref(),
|
|
10902
|
+
stage: "status",
|
|
10903
|
+
operationId: (0, import_node_crypto17.randomUUID)(),
|
|
10904
|
+
principal: null,
|
|
10905
|
+
attempted: false
|
|
10906
|
+
};
|
|
10828
10907
|
this.enrolmentProgress.set(controller.id, { state: "running", reason: "preparation" });
|
|
10829
10908
|
try {
|
|
10830
|
-
await this.
|
|
10831
|
-
access = await this.loadSession(session.id);
|
|
10832
|
-
if (!access || ["retiring", "retired", "pending"].includes(access.state)) return false;
|
|
10833
|
-
const remoteStatus = await this.connection(access, `access-status ${access.token}`, operation.signal);
|
|
10834
|
-
if (remoteStatus === "cutover\n" && access.state !== "recovery_required") return false;
|
|
10835
|
-
if (!["open\n", "committed\n", "cutover\n"].includes(remoteStatus)) throw new Error("Unknown management receipt");
|
|
10836
|
-
const committed = remoteStatus === "committed\n";
|
|
10837
|
-
if (committed) {
|
|
10838
|
-
clearTimeout(timer);
|
|
10839
|
-
timer = setTimeout(() => operation.abort(), 18e4).unref();
|
|
10840
|
-
}
|
|
10841
|
-
stage = "audit";
|
|
10842
|
-
principal = JSON.parse(session.initiatingPrincipal ?? "null");
|
|
10843
|
-
if (!principal) throw new Error("Management audit initiator unavailable");
|
|
10844
|
-
await this.securityAudit(session.id, "security_apply", principal, operationId, "attempted");
|
|
10845
|
-
attempted = true;
|
|
10846
|
-
await this.sessions.update(session.id, { updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
10847
|
-
await this.access.update(access.sessionId, { controllerId: controller.id });
|
|
10848
|
-
if (remoteStatus === "cutover\n") {
|
|
10849
|
-
stage = "rollback";
|
|
10850
|
-
clearTimeout(timer);
|
|
10851
|
-
timer = setTimeout(() => operation.abort(), 5e4).unref();
|
|
10852
|
-
await this.connection(access, `access-restore ${access.token}`, operation.signal);
|
|
10853
|
-
await this.securityAudit(session.id, "security_apply", principal, operationId, "failed");
|
|
10854
|
-
return false;
|
|
10855
|
-
}
|
|
10856
|
-
if (!committed) {
|
|
10857
|
-
stage = "ownership";
|
|
10858
|
-
if (!session.deliveryToken || !session.dockerProvisionToken)
|
|
10859
|
-
throw new Error("Commissioning ownership unavailable");
|
|
10860
|
-
stage = "proof";
|
|
10861
|
-
await this.prove(access, operation.signal);
|
|
10862
|
-
stage = "recovery_login";
|
|
10863
|
-
if (!await this.rootProbe(access.host, access.fingerprint, this.credentials(access).recoveryPassword))
|
|
10864
|
-
throw new Error("Root recovery path is unverified");
|
|
10865
|
-
await this.setActiveState(access.sessionId, "verified");
|
|
10866
|
-
stage = "key_commit";
|
|
10867
|
-
await this.connection(access, `access-key-commit ${access.token}`, operation.signal);
|
|
10868
|
-
stage = "proof";
|
|
10869
|
-
await this.prove(access, operation.signal);
|
|
10870
|
-
stage = "acceptance";
|
|
10871
|
-
if (this.rootAcceptance) {
|
|
10872
|
-
await this.rootAcceptance(
|
|
10873
|
-
access.host,
|
|
10874
|
-
access.fingerprint,
|
|
10875
|
-
this.credentials(access).recoveryPassword,
|
|
10876
|
-
session.deliveryToken,
|
|
10877
|
-
{
|
|
10878
|
-
assertOwned: () => this.operations.assertOwned(access.fingerprint, owner),
|
|
10879
|
-
signal: operation.signal,
|
|
10880
|
-
deadline: Date.now() + 12 * 6e4
|
|
10881
|
-
},
|
|
10882
|
-
{ token: access.token, helper: managedHostHelper(await this.desired()), watchdog: managedWatchdogScript() }
|
|
10883
|
-
);
|
|
10884
|
-
} else await this.connection(access, `commissioning-accept ${session.deliveryToken}`, operation.signal);
|
|
10885
|
-
stage = "cutover";
|
|
10886
|
-
this.enrolmentProgress.set(controller.id, { state: "running", reason: "ssh_cutover" });
|
|
10887
|
-
await this.connection(access, `access-cutover ${access.token}`, operation.signal);
|
|
10888
|
-
clearTimeout(timer);
|
|
10889
|
-
timer = setTimeout(() => operation.abort(), 18e4).unref();
|
|
10890
|
-
await new Promise((resolve2) => setTimeout(resolve2, 3e3));
|
|
10891
|
-
}
|
|
10892
|
-
stage = "policy";
|
|
10893
|
-
await this.prove(access, operation.signal);
|
|
10894
|
-
if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
|
|
10895
|
-
throw new Error("Managed SSH policy is unverified");
|
|
10896
|
-
stage = "root_login";
|
|
10897
|
-
if (await this.rootProbe(access.host, access.fingerprint, this.credentials(access).recoveryPassword))
|
|
10898
|
-
throw new Error("Root SSH remains enabled");
|
|
10899
|
-
await this.prove(access, operation.signal);
|
|
10900
|
-
if (!committed) {
|
|
10901
|
-
this.enrolmentProgress.set(controller.id, { state: "running", reason: "reboot" });
|
|
10902
|
-
stage = "boot";
|
|
10903
|
-
const boot = await this.connection(access, `access-boot ${access.token}`, operation.signal);
|
|
10904
|
-
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(boot))
|
|
10905
|
-
throw new Error("Controller boot identity unavailable");
|
|
10906
|
-
stage = "reboot";
|
|
10907
|
-
await this.connection(access, `access-reboot ${access.token}`, operation.signal).catch(() => void 0);
|
|
10908
|
-
let rebootVerified = false;
|
|
10909
|
-
for (let attempt = 0; attempt < 60; attempt++) {
|
|
10910
|
-
operation.signal.throwIfAborted();
|
|
10911
|
-
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
10912
|
-
try {
|
|
10913
|
-
const next = await this.connection(access, `access-boot ${access.token}`, operation.signal);
|
|
10914
|
-
if (next !== boot && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(next)) {
|
|
10915
|
-
await this.prove(access, operation.signal);
|
|
10916
|
-
if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
|
|
10917
|
-
throw new Error("Post-reboot SSH policy unverified");
|
|
10918
|
-
if (await this.rootProbe(access.host, access.fingerprint, this.credentials(access).recoveryPassword))
|
|
10919
|
-
throw new Error("Root SSH enabled after reboot");
|
|
10920
|
-
await this.prove(access, operation.signal);
|
|
10921
|
-
rebootVerified = true;
|
|
10922
|
-
break;
|
|
10923
|
-
}
|
|
10924
|
-
} catch {
|
|
10925
|
-
}
|
|
10926
|
-
}
|
|
10927
|
-
if (!rebootVerified) throw new Error("Managed reboot verification failed");
|
|
10928
|
-
}
|
|
10929
|
-
await this.operations.assertOwned(access.fingerprint, owner);
|
|
10930
|
-
stage = "confirmation";
|
|
10931
|
-
this.enrolmentProgress.set(controller.id, { state: "running", reason: "confirmation" });
|
|
10932
|
-
if (!committed) await this.connection(access, `access-commit ${access.token}`, operation.signal);
|
|
10933
|
-
await this.setActiveState(access.sessionId, "managed");
|
|
10934
|
-
await this.finishSession(session.id);
|
|
10935
|
-
await this.securityAudit(session.id, "security_apply", principal, operationId, "succeeded");
|
|
10936
|
-
return true;
|
|
10909
|
+
return await this.runEnrolment(attempt, access.fingerprint);
|
|
10937
10910
|
} catch (error) {
|
|
10938
|
-
return await this.failEnrolment(
|
|
10911
|
+
return await this.failEnrolment(
|
|
10912
|
+
session,
|
|
10913
|
+
error,
|
|
10914
|
+
attempt.stage,
|
|
10915
|
+
attempt.attempted,
|
|
10916
|
+
attempt.principal,
|
|
10917
|
+
attempt.operationId,
|
|
10918
|
+
operation
|
|
10919
|
+
);
|
|
10939
10920
|
} finally {
|
|
10940
10921
|
this.enrolmentProgress.delete(controller.id);
|
|
10941
|
-
clearTimeout(timer);
|
|
10922
|
+
clearTimeout(attempt.timer);
|
|
10942
10923
|
operation.abort();
|
|
10943
10924
|
await this.operations.release(bound.fingerprint, owner);
|
|
10944
10925
|
}
|
|
10945
10926
|
}
|
|
10927
|
+
/** Bound, unretired access whose commissioning session still matches this controller. */
|
|
10928
|
+
async enrolmentCandidate(controller) {
|
|
10929
|
+
if (!controller.mqttServerId) return null;
|
|
10930
|
+
const bound = await this.access.findOne({ where: { controllerId: controller.id }, order: { sessionId: "DESC" } });
|
|
10931
|
+
if (!bound) return null;
|
|
10932
|
+
const session = await this.sessions.findOneBy({ id: bound.sessionId });
|
|
10933
|
+
if (!session || !["awaiting_verification", "completed"].includes(session.state)) return null;
|
|
10934
|
+
if (session.hardwareId !== controller.hardwareId || session.mqttServerId !== controller.mqttServerId) return null;
|
|
10935
|
+
const access = await this.loadSession(session.id);
|
|
10936
|
+
if (!access || ["retiring", "retired", "pending"].includes(access.state)) return null;
|
|
10937
|
+
return { bound, session, access };
|
|
10938
|
+
}
|
|
10939
|
+
/** Probes the pinned bootstrap root login with the encrypted recovery password. completeEnrolment
|
|
10940
|
+
* only starts an attempt once a probe is registered; probes are never unregistered. */
|
|
10941
|
+
async rootLoginAccepted(access) {
|
|
10942
|
+
const probe = this.rootProbe;
|
|
10943
|
+
if (!probe) throw new Error("Root recovery probe unavailable");
|
|
10944
|
+
return probe(access.host, access.fingerprint, this.credentials(access).recoveryPassword);
|
|
10945
|
+
}
|
|
10946
|
+
/** Replaces the attempt's operation deadline; called whenever a new bounded phase starts. */
|
|
10947
|
+
restartEnrolmentTimer(attempt, ms) {
|
|
10948
|
+
clearTimeout(attempt.timer);
|
|
10949
|
+
attempt.timer = setTimeout(() => attempt.operation.abort(), ms).unref();
|
|
10950
|
+
}
|
|
10951
|
+
/** Runs the leased enrolment attempt; `attempt` records the stage and audit state for failure handling. */
|
|
10952
|
+
async runEnrolment(attempt, fingerprint2) {
|
|
10953
|
+
const { controller, session, owner, operation, operationId } = attempt;
|
|
10954
|
+
await this.operations.assertOwned(fingerprint2, owner);
|
|
10955
|
+
const access = await this.loadSession(session.id);
|
|
10956
|
+
if (!access || ["retiring", "retired", "pending"].includes(access.state)) return false;
|
|
10957
|
+
const remoteStatus = await this.connection(access, `access-status ${access.token}`, operation.signal);
|
|
10958
|
+
if (remoteStatus === "cutover\n" && access.state !== "recovery_required") return false;
|
|
10959
|
+
if (!["open\n", "committed\n", "cutover\n"].includes(remoteStatus)) throw new Error("Unknown management receipt");
|
|
10960
|
+
const committed = remoteStatus === "committed\n";
|
|
10961
|
+
if (committed) this.restartEnrolmentTimer(attempt, 18e4);
|
|
10962
|
+
attempt.stage = "audit";
|
|
10963
|
+
const principal = JSON.parse(session.initiatingPrincipal ?? "null");
|
|
10964
|
+
attempt.principal = principal;
|
|
10965
|
+
if (!principal) throw new Error("Management audit initiator unavailable");
|
|
10966
|
+
await this.securityAudit(session.id, "security_apply", principal, operationId, "attempted");
|
|
10967
|
+
attempt.attempted = true;
|
|
10968
|
+
await this.sessions.update(session.id, { updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
10969
|
+
await this.access.update(access.sessionId, { controllerId: controller.id });
|
|
10970
|
+
if (remoteStatus === "cutover\n") {
|
|
10971
|
+
attempt.stage = "rollback";
|
|
10972
|
+
this.restartEnrolmentTimer(attempt, 5e4);
|
|
10973
|
+
await this.connection(access, `access-restore ${access.token}`, operation.signal);
|
|
10974
|
+
await this.securityAudit(session.id, "security_apply", principal, operationId, "failed");
|
|
10975
|
+
return false;
|
|
10976
|
+
}
|
|
10977
|
+
if (!committed) await this.cutOverEnrolment(attempt, access);
|
|
10978
|
+
attempt.stage = "policy";
|
|
10979
|
+
await this.prove(access, operation.signal);
|
|
10980
|
+
if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
|
|
10981
|
+
throw new Error("Managed SSH policy is unverified");
|
|
10982
|
+
attempt.stage = "root_login";
|
|
10983
|
+
if (await this.rootLoginAccepted(access)) throw new Error("Root SSH remains enabled");
|
|
10984
|
+
await this.prove(access, operation.signal);
|
|
10985
|
+
if (!committed) await this.verifyEnrolmentReboot(attempt, access);
|
|
10986
|
+
await this.operations.assertOwned(access.fingerprint, owner);
|
|
10987
|
+
attempt.stage = "confirmation";
|
|
10988
|
+
this.enrolmentProgress.set(controller.id, { state: "running", reason: "confirmation" });
|
|
10989
|
+
if (!committed) await this.connection(access, `access-commit ${access.token}`, operation.signal);
|
|
10990
|
+
await this.setActiveState(access.sessionId, "managed");
|
|
10991
|
+
await this.finishSession(session.id);
|
|
10992
|
+
await this.securityAudit(session.id, "security_apply", principal, operationId, "succeeded");
|
|
10993
|
+
return true;
|
|
10994
|
+
}
|
|
10995
|
+
/** First attempt: prove recovery, commit the key, accept commissioning and switch SSH policy. */
|
|
10996
|
+
async cutOverEnrolment(attempt, access) {
|
|
10997
|
+
const { controller, session, owner, operation } = attempt;
|
|
10998
|
+
attempt.stage = "ownership";
|
|
10999
|
+
if (!session.deliveryToken || !session.dockerProvisionToken) throw new Error("Commissioning ownership unavailable");
|
|
11000
|
+
attempt.stage = "proof";
|
|
11001
|
+
await this.prove(access, operation.signal);
|
|
11002
|
+
attempt.stage = "recovery_login";
|
|
11003
|
+
if (!await this.rootLoginAccepted(access)) throw new Error("Root recovery path is unverified");
|
|
11004
|
+
await this.setActiveState(access.sessionId, "verified");
|
|
11005
|
+
attempt.stage = "key_commit";
|
|
11006
|
+
await this.connection(access, `access-key-commit ${access.token}`, operation.signal);
|
|
11007
|
+
attempt.stage = "proof";
|
|
11008
|
+
await this.prove(access, operation.signal);
|
|
11009
|
+
attempt.stage = "acceptance";
|
|
11010
|
+
if (this.rootAcceptance) {
|
|
11011
|
+
await this.rootAcceptance(
|
|
11012
|
+
access.host,
|
|
11013
|
+
access.fingerprint,
|
|
11014
|
+
this.credentials(access).recoveryPassword,
|
|
11015
|
+
session.deliveryToken,
|
|
11016
|
+
{
|
|
11017
|
+
assertOwned: () => this.operations.assertOwned(access.fingerprint, owner),
|
|
11018
|
+
signal: operation.signal,
|
|
11019
|
+
deadline: Date.now() + 12 * 6e4
|
|
11020
|
+
},
|
|
11021
|
+
{ token: access.token, helper: managedHostHelper(await this.desired()), watchdog: managedWatchdogScript() }
|
|
11022
|
+
);
|
|
11023
|
+
} else await this.connection(access, `commissioning-accept ${session.deliveryToken}`, operation.signal);
|
|
11024
|
+
attempt.stage = "cutover";
|
|
11025
|
+
this.enrolmentProgress.set(controller.id, { state: "running", reason: "ssh_cutover" });
|
|
11026
|
+
await this.connection(access, `access-cutover ${access.token}`, operation.signal);
|
|
11027
|
+
this.restartEnrolmentTimer(attempt, 18e4);
|
|
11028
|
+
await new Promise((resolve2) => setTimeout(resolve2, 3e3));
|
|
11029
|
+
}
|
|
11030
|
+
/** First attempt: reboot and require the new boot to keep the hardened policy. */
|
|
11031
|
+
async verifyEnrolmentReboot(attempt, access) {
|
|
11032
|
+
const { controller, operation } = attempt;
|
|
11033
|
+
this.enrolmentProgress.set(controller.id, { state: "running", reason: "reboot" });
|
|
11034
|
+
attempt.stage = "boot";
|
|
11035
|
+
const boot = await this.connection(access, `access-boot ${access.token}`, operation.signal);
|
|
11036
|
+
if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(boot))
|
|
11037
|
+
throw new Error("Controller boot identity unavailable");
|
|
11038
|
+
attempt.stage = "reboot";
|
|
11039
|
+
await this.connection(access, `access-reboot ${access.token}`, operation.signal).catch(() => void 0);
|
|
11040
|
+
let rebootVerified = false;
|
|
11041
|
+
for (let poll = 0; poll < 60; poll++) {
|
|
11042
|
+
operation.signal.throwIfAborted();
|
|
11043
|
+
await new Promise((resolve2) => setTimeout(resolve2, 1e3));
|
|
11044
|
+
try {
|
|
11045
|
+
const next = await this.connection(access, `access-boot ${access.token}`, operation.signal);
|
|
11046
|
+
if (next !== boot && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\n$/.test(next)) {
|
|
11047
|
+
await this.prove(access, operation.signal);
|
|
11048
|
+
if (await this.connection(access, `access-policy ${access.token}`, operation.signal) !== "OK\n")
|
|
11049
|
+
throw new Error("Post-reboot SSH policy unverified");
|
|
11050
|
+
if (await this.rootLoginAccepted(access)) throw new Error("Root SSH enabled after reboot");
|
|
11051
|
+
await this.prove(access, operation.signal);
|
|
11052
|
+
rebootVerified = true;
|
|
11053
|
+
break;
|
|
11054
|
+
}
|
|
11055
|
+
} catch {
|
|
11056
|
+
}
|
|
11057
|
+
}
|
|
11058
|
+
if (!rebootVerified) throw new Error("Managed reboot verification failed");
|
|
11059
|
+
}
|
|
10946
11060
|
async failEnrolment(session, error, stage, attempted, principal, operationId, operation) {
|
|
10947
11061
|
if (attempted && principal)
|
|
10948
11062
|
await this.securityAudit(session.id, "security_apply", principal, operationId, "failed").catch(() => void 0);
|
|
@@ -15623,6 +15737,14 @@ function networkChangeInput(body) {
|
|
|
15623
15737
|
throw new import_common35.BadRequestException("Select a configured MQTT server or update only the address.");
|
|
15624
15738
|
return { targetHost: input.targetHost, mqttServerId: input.mqttServerId };
|
|
15625
15739
|
}
|
|
15740
|
+
function assertNoUnfinishedChange(row) {
|
|
15741
|
+
if (row && row.phase !== "completed" && !(row.phase === "connecting" && row.failure))
|
|
15742
|
+
throw new import_common35.ConflictException("Retry the pending MQTT/address change before starting another.");
|
|
15743
|
+
}
|
|
15744
|
+
function assertSameManagedIdentity(row, access) {
|
|
15745
|
+
if (!row || row.sessionId !== access.sessionId || row.fingerprint !== access.fingerprint)
|
|
15746
|
+
throw new import_common35.ConflictException("Pending change belongs to another managed identity.");
|
|
15747
|
+
}
|
|
15626
15748
|
|
|
15627
15749
|
// backend/network/service.ts
|
|
15628
15750
|
var import_promises10 = require("node:dns/promises");
|
|
@@ -15659,31 +15781,10 @@ var WagoNetworkChangeService = class {
|
|
|
15659
15781
|
throw new import_common36.ConflictException("Managed enrolled controller required.");
|
|
15660
15782
|
row = await this.repository.createQueryBuilder("change").addSelect("change.encryptedPayload").where("change.controllerId = :controllerId", { controllerId }).getOne();
|
|
15661
15783
|
if (retry && (!row || row.phase === "completed")) return this.status(controllerId);
|
|
15662
|
-
if (!retry
|
|
15663
|
-
|
|
15664
|
-
|
|
15665
|
-
|
|
15666
|
-
if (rotation && rotation.phase !== "completed" || update?.metadata && JSON.parse(update.metadata).token)
|
|
15667
|
-
throw new import_common36.ConflictException(
|
|
15668
|
-
"Finish credential rotation or runtime update recovery before changing MQTT/address settings."
|
|
15669
|
-
);
|
|
15670
|
-
if (!retry) {
|
|
15671
|
-
if (!input) throw new import_common36.BadRequestException("Invalid MQTT/address change");
|
|
15672
|
-
row = this.repository.create({
|
|
15673
|
-
controllerId,
|
|
15674
|
-
sessionId: access.sessionId,
|
|
15675
|
-
fingerprint: access.fingerprint,
|
|
15676
|
-
...input,
|
|
15677
|
-
phase: "connecting",
|
|
15678
|
-
failure: null,
|
|
15679
|
-
encryptedPayload: null,
|
|
15680
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15681
|
-
});
|
|
15682
|
-
await assertOwned();
|
|
15683
|
-
await this.repository.save(row);
|
|
15684
|
-
}
|
|
15685
|
-
if (!row || row.sessionId !== access.sessionId || row.fingerprint !== access.fingerprint)
|
|
15686
|
-
throw new import_common36.ConflictException("Pending change belongs to another managed identity.");
|
|
15784
|
+
if (!retry) assertNoUnfinishedChange(row);
|
|
15785
|
+
await this.assertNoPendingRecovery(controllerId);
|
|
15786
|
+
if (!retry) row = await this.savePendingChange(controllerId, access, input, assertOwned);
|
|
15787
|
+
assertSameManagedIdentity(row, access);
|
|
15687
15788
|
await lifecycle.attempt();
|
|
15688
15789
|
audited = true;
|
|
15689
15790
|
await assertOwned();
|
|
@@ -15695,10 +15796,7 @@ var WagoNetworkChangeService = class {
|
|
|
15695
15796
|
if (row.mqttServerId !== null) {
|
|
15696
15797
|
await this.applyBrokerChange(controllerId, row, controller, host, abort, assertOwned, retry);
|
|
15697
15798
|
} else {
|
|
15698
|
-
|
|
15699
|
-
throw new RuntimeUpdateError("recovery");
|
|
15700
|
-
await this.phase(row, "saving", assertOwned);
|
|
15701
|
-
await this.saveBindings(row, host.encryptedCredentials, null, assertOwned);
|
|
15799
|
+
await this.applyAddressChange(row, host, assertOwned);
|
|
15702
15800
|
}
|
|
15703
15801
|
row.encryptedPayload = null;
|
|
15704
15802
|
await this.phase(row, "completed", assertOwned);
|
|
@@ -15707,10 +15805,7 @@ var WagoNetworkChangeService = class {
|
|
|
15707
15805
|
audited = false;
|
|
15708
15806
|
return this.status(controllerId);
|
|
15709
15807
|
} catch (error) {
|
|
15710
|
-
if (row && row.phase !== "completed")
|
|
15711
|
-
row.failure = error instanceof RuntimeUpdateError || error instanceof NetworkChangeError ? error.failure : "interrupted";
|
|
15712
|
-
await assertOwned().then(() => this.repository.save(row)).catch(() => void 0);
|
|
15713
|
-
}
|
|
15808
|
+
if (row && row.phase !== "completed") await this.recordFailure(row, error, assertOwned);
|
|
15714
15809
|
if (audited) await lifecycle.finish("failed");
|
|
15715
15810
|
throw new import_common36.ConflictException("MQTT/address change is incomplete. Check its status and retry the saved operation.");
|
|
15716
15811
|
} finally {
|
|
@@ -15720,6 +15815,44 @@ var WagoNetworkChangeService = class {
|
|
|
15720
15815
|
await operations.release(access.fingerprint, owner);
|
|
15721
15816
|
}
|
|
15722
15817
|
}
|
|
15818
|
+
/** Network changes must not race credential rotation or runtime update recovery. */
|
|
15819
|
+
async assertNoPendingRecovery(controllerId) {
|
|
15820
|
+
const rotation = await this.context.getRepository(WagoCredentialRotationEntity).findOneBy({ controllerId });
|
|
15821
|
+
const update = await this.context.getRepository(WagoRuntimeUpdateEntity).findOneBy({ controllerId });
|
|
15822
|
+
if (rotation && rotation.phase !== "completed" || update?.metadata && JSON.parse(update.metadata).token)
|
|
15823
|
+
throw new import_common36.ConflictException(
|
|
15824
|
+
"Finish credential rotation or runtime update recovery before changing MQTT/address settings."
|
|
15825
|
+
);
|
|
15826
|
+
}
|
|
15827
|
+
/** Durably record a new change intent before contacting the controller. */
|
|
15828
|
+
async savePendingChange(controllerId, access, input, assertOwned) {
|
|
15829
|
+
if (!input) throw new import_common36.BadRequestException("Invalid MQTT/address change");
|
|
15830
|
+
const row = this.repository.create({
|
|
15831
|
+
controllerId,
|
|
15832
|
+
sessionId: access.sessionId,
|
|
15833
|
+
fingerprint: access.fingerprint,
|
|
15834
|
+
...input,
|
|
15835
|
+
phase: "connecting",
|
|
15836
|
+
failure: null,
|
|
15837
|
+
encryptedPayload: null,
|
|
15838
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15839
|
+
});
|
|
15840
|
+
await assertOwned();
|
|
15841
|
+
await this.repository.save(row);
|
|
15842
|
+
return row;
|
|
15843
|
+
}
|
|
15844
|
+
/** Apply an address-only change; the broker binding stays unchanged. */
|
|
15845
|
+
async applyAddressChange(row, host, assertOwned) {
|
|
15846
|
+
if (await host.command(`mqtt-address ${host.managementToken}`) !== "OK\n")
|
|
15847
|
+
throw new RuntimeUpdateError("recovery");
|
|
15848
|
+
await this.phase(row, "saving", assertOwned);
|
|
15849
|
+
await this.saveBindings(row, host.encryptedCredentials, null, assertOwned);
|
|
15850
|
+
}
|
|
15851
|
+
/** Keep the durable intent for an explicit retry, recording only a safe failure code. */
|
|
15852
|
+
async recordFailure(row, error, assertOwned) {
|
|
15853
|
+
row.failure = error instanceof RuntimeUpdateError || error instanceof NetworkChangeError ? error.failure : "interrupted";
|
|
15854
|
+
await assertOwned().then(() => this.repository.save(row)).catch(() => void 0);
|
|
15855
|
+
}
|
|
15723
15856
|
async applyBrokerChange(controllerId, row, controller, host, abort, assertOwned, retry) {
|
|
15724
15857
|
let superseded;
|
|
15725
15858
|
let connection;
|