fix(instances): release the hub binding before forgetting a node
Deletion now follows the published contract: prepare returns the one-time token and the console spends it on DELETE /api/v1/instances/:id with If-Match, where the generic execute endpoint had been rejecting it as an operation the safe transport cannot replay. An online device is told to release its own binding first. A refusal keeps the record and reports UNBIND_FAILED on the job item so the console can say why the node is still there; a node that never answers a heartbeat is forgotten locally.
This commit is contained in:
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import type { Instance, InstancePage } from '@multi-simadmin/contracts';
|
import type { Instance, InstancePage } from '@multi-simadmin/contracts';
|
||||||
|
|
||||||
import type { InstanceService } from '../instances/instance-service.js';
|
import type { InstanceService } from '../instances/instance-service.js';
|
||||||
import type { ConnectionProbe } from './connection-probe.js';
|
import type { ConnectionState, ConnectionProbe } from './connection-probe.js';
|
||||||
import type { ConnectionSettingsService } from './connection-settings-service.js';
|
import type { ConnectionSettingsService } from './connection-settings-service.js';
|
||||||
import { FleetHeartbeatCoordinator } from './fleet-heartbeat.js';
|
import { FleetHeartbeatCoordinator, type FleetStateTransition } from './fleet-heartbeat.js';
|
||||||
|
|
||||||
function instance(id: string): Instance {
|
function instance(id: string): Instance {
|
||||||
return {
|
return {
|
||||||
@@ -28,6 +28,11 @@ function coordinator(options: {
|
|||||||
readonly probe?: (id: string) => Promise<void>;
|
readonly probe?: (id: string) => Promise<void>;
|
||||||
readonly heartbeatMs?: number;
|
readonly heartbeatMs?: number;
|
||||||
readonly concurrency?: number;
|
readonly concurrency?: number;
|
||||||
|
/** Reachability the snapshot journal already holds when the first beat starts. */
|
||||||
|
readonly before?: ReadonlyMap<string, ConnectionState>;
|
||||||
|
/** Lets a test flip a node to reachable-but-unauthenticated after it answers. */
|
||||||
|
readonly authenticated?: (id: string) => boolean;
|
||||||
|
readonly onTransition?: (transitions: readonly FleetStateTransition[]) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const listed: string[] = [];
|
const listed: string[] = [];
|
||||||
const instances = {
|
const instances = {
|
||||||
@@ -39,12 +44,30 @@ function coordinator(options: {
|
|||||||
},
|
},
|
||||||
} as unknown as InstanceService;
|
} as unknown as InstanceService;
|
||||||
const probed: string[] = [];
|
const probed: string[] = [];
|
||||||
|
const journal = new Map<string, ConnectionState>(options.before ?? []);
|
||||||
const probe = {
|
const probe = {
|
||||||
async test(id: string) {
|
async test(id: string) {
|
||||||
probed.push(id);
|
probed.push(id);
|
||||||
await options.probe?.(id);
|
try {
|
||||||
return { instanceId: id, authenticated: true, checkedAt: '2026-09-05T00:00:00.000Z' };
|
await options.probe?.(id);
|
||||||
|
} catch {
|
||||||
|
// A failed probe leaves the previous snapshot in place and lets it expire on its own,
|
||||||
|
// which is exactly what the offline window is for.
|
||||||
|
throw new Error('probe failed');
|
||||||
|
}
|
||||||
|
const authenticated = options.authenticated?.(id) ?? true;
|
||||||
|
journal.set(id, {
|
||||||
|
reachable: true,
|
||||||
|
authenticated,
|
||||||
|
checkedAt: '2026-09-05T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
instanceId: id,
|
||||||
|
authenticated,
|
||||||
|
checkedAt: '2026-09-05T00:00:00.000Z',
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
reachability: () => journal,
|
||||||
} as unknown as ConnectionProbe;
|
} as unknown as ConnectionProbe;
|
||||||
const settings = {
|
const settings = {
|
||||||
heartbeatMs: options.heartbeatMs ?? 30_000,
|
heartbeatMs: options.heartbeatMs ?? 30_000,
|
||||||
@@ -56,8 +79,9 @@ function coordinator(options: {
|
|||||||
pageSize: options.pageSize,
|
pageSize: options.pageSize,
|
||||||
now: () => new Date('2026-09-05T00:00:00.000Z'),
|
now: () => new Date('2026-09-05T00:00:00.000Z'),
|
||||||
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
|
...(options.concurrency === undefined ? {} : { concurrency: options.concurrency }),
|
||||||
|
...(options.onTransition === undefined ? {} : { onTransition: options.onTransition }),
|
||||||
});
|
});
|
||||||
return { beat, listed, probed };
|
return { beat, listed, probed, journal };
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -76,6 +100,7 @@ describe('FleetHeartbeatCoordinator', () => {
|
|||||||
await expect(beat.runOnce()).resolves.toEqual({
|
await expect(beat.runOnce()).resolves.toEqual({
|
||||||
probed: 3,
|
probed: 3,
|
||||||
failed: 0,
|
failed: 0,
|
||||||
|
transitions: [],
|
||||||
startedAt: '2026-09-05T00:00:00.000Z',
|
startedAt: '2026-09-05T00:00:00.000Z',
|
||||||
finishedAt: '2026-09-05T00:00:00.000Z',
|
finishedAt: '2026-09-05T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
@@ -83,6 +108,86 @@ describe('FleetHeartbeatCoordinator', () => {
|
|||||||
expect([...probed].sort()).toEqual(['a', 'b', 'c']);
|
expect([...probed].sort()).toEqual(['a', 'b', 'c']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records a baseline on the first beat instead of announcing every node as recovered', async () => {
|
||||||
|
const announced: (readonly FleetStateTransition[])[] = [];
|
||||||
|
const { beat } = coordinator({
|
||||||
|
pageSize: 10,
|
||||||
|
pages: new Map([[1, [instance('a'), instance('b')]]]),
|
||||||
|
onTransition: async (transitions) => {
|
||||||
|
announced.push(transitions);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(beat.runOnce()).resolves.toMatchObject({ transitions: [] });
|
||||||
|
await expect(beat.runOnce()).resolves.toMatchObject({ transitions: [] });
|
||||||
|
expect(announced).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a node only once its snapshot has actually expired', async () => {
|
||||||
|
const down: ConnectionState = {
|
||||||
|
reachable: false,
|
||||||
|
authenticated: false,
|
||||||
|
checkedAt: '2026-09-04T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const up: ConnectionState = {
|
||||||
|
reachable: true,
|
||||||
|
authenticated: true,
|
||||||
|
checkedAt: '2026-09-04T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const announced: (readonly FleetStateTransition[])[] = [];
|
||||||
|
const { beat, journal } = coordinator({
|
||||||
|
pageSize: 10,
|
||||||
|
concurrency: 1,
|
||||||
|
pages: new Map([[1, [instance('a'), instance('b')]]]),
|
||||||
|
before: new Map([
|
||||||
|
['a', up],
|
||||||
|
['b', down],
|
||||||
|
]),
|
||||||
|
probe: async (id) => {
|
||||||
|
// b never answers; a answers on the first beat and goes quiet afterwards.
|
||||||
|
if (id === 'b' || !journal.get('a')?.reachable) throw new Error('ECONNREFUSED');
|
||||||
|
},
|
||||||
|
onTransition: async (transitions) => {
|
||||||
|
announced.push(transitions);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// a is reachable and b is still down, so nothing moved against the baseline.
|
||||||
|
await expect(beat.runOnce()).resolves.toMatchObject({
|
||||||
|
probed: 1,
|
||||||
|
failed: 1,
|
||||||
|
transitions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// a's snapshot ages out. A single lost probe would not be enough on its own; the beat
|
||||||
|
// reports offline only once the offline window the operator configured has passed.
|
||||||
|
journal.set('a', down);
|
||||||
|
await expect(beat.runOnce()).resolves.toMatchObject({
|
||||||
|
transitions: [{ instanceId: 'a', from: 'online', to: 'offline' }],
|
||||||
|
});
|
||||||
|
expect(announced).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces an expired device session as a state change', async () => {
|
||||||
|
const announced: (readonly FleetStateTransition[])[] = [];
|
||||||
|
let sessionValid = true;
|
||||||
|
const { beat } = coordinator({
|
||||||
|
pageSize: 10,
|
||||||
|
concurrency: 1,
|
||||||
|
pages: new Map([[1, [instance('a')]]]),
|
||||||
|
authenticated: () => sessionValid,
|
||||||
|
onTransition: async (transitions) => {
|
||||||
|
announced.push(transitions);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// The first beat only records that the node is healthy.
|
||||||
|
await expect(beat.runOnce()).resolves.toMatchObject({ transitions: [] });
|
||||||
|
sessionValid = false;
|
||||||
|
await expect(beat.runOnce()).resolves.toMatchObject({
|
||||||
|
transitions: [{ instanceId: 'a', from: 'online', to: 'auth-required' }],
|
||||||
|
});
|
||||||
|
expect(announced).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('stops paging on a short page and probes a duplicated id once', async () => {
|
it('stops paging on a short page and probes a duplicated id once', async () => {
|
||||||
const { beat, listed, probed } = coordinator({
|
const { beat, listed, probed } = coordinator({
|
||||||
pageSize: 2,
|
pageSize: 2,
|
||||||
|
|||||||
@@ -1,10 +1,42 @@
|
|||||||
import type { ConnectionProbe } from './connection-probe.js';
|
import type { ConnectionProbe, ConnectionState } from './connection-probe.js';
|
||||||
import type { ConnectionSettingsService } from './connection-settings-service.js';
|
import type { ConnectionSettingsService } from './connection-settings-service.js';
|
||||||
import type { InstanceService } from '../instances/instance-service.js';
|
import type { InstanceService } from '../instances/instance-service.js';
|
||||||
|
|
||||||
|
/** Reachability class the console and the notification rules both speak in. */
|
||||||
|
export type FleetObservedState = 'online' | 'auth-required' | 'offline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-facing copy shared by the notification templates so every channel reads alike.
|
||||||
|
* `FLEET_STATE_NAMES` describes a steady state, `FLEET_STATE_EVENTS` describes arriving at it.
|
||||||
|
*/
|
||||||
|
export const FLEET_STATE_NAMES: Readonly<Record<FleetObservedState, string>> = Object.freeze({
|
||||||
|
online: '在线',
|
||||||
|
'auth-required': '需要认证',
|
||||||
|
offline: '离线',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FLEET_STATE_EVENTS: Readonly<Record<FleetObservedState, string>> = Object.freeze({
|
||||||
|
online: '恢复在线',
|
||||||
|
'auth-required': '设备会话已失效',
|
||||||
|
offline: '已离线',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FLEET_STATE_CODES: Readonly<Record<FleetObservedState, string>> = Object.freeze({
|
||||||
|
online: 'online',
|
||||||
|
'auth-required': 'auth_required',
|
||||||
|
offline: 'offline',
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface FleetStateTransition {
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly from: FleetObservedState;
|
||||||
|
readonly to: FleetObservedState;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FleetHeartbeatSummary {
|
export interface FleetHeartbeatSummary {
|
||||||
readonly probed: number;
|
readonly probed: number;
|
||||||
readonly failed: number;
|
readonly failed: number;
|
||||||
|
readonly transitions: readonly FleetStateTransition[];
|
||||||
readonly startedAt: string;
|
readonly startedAt: string;
|
||||||
readonly finishedAt: string;
|
readonly finishedAt: string;
|
||||||
}
|
}
|
||||||
@@ -17,12 +49,23 @@ export interface FleetHeartbeatOptions {
|
|||||||
/** Devices touched at the same time; the control plane talks to LAN hosts, not the internet. */
|
/** Devices touched at the same time; the control plane talks to LAN hosts, not the internet. */
|
||||||
readonly concurrency?: number;
|
readonly concurrency?: number;
|
||||||
readonly pageSize?: number;
|
readonly pageSize?: number;
|
||||||
|
/**
|
||||||
|
* Called once per beat with every state change the beat revealed. Reachability is derived
|
||||||
|
* from snapshot expiry, so a single lost probe never fires anything; a node has to stay
|
||||||
|
* down for the whole offline window before it counts as a transition.
|
||||||
|
*/
|
||||||
|
readonly onTransition?: (transitions: readonly FleetStateTransition[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONCURRENCY = 6;
|
const DEFAULT_CONCURRENCY = 6;
|
||||||
const DEFAULT_PAGE_SIZE = 100;
|
const DEFAULT_PAGE_SIZE = 100;
|
||||||
const MAX_PAGES = 100;
|
const MAX_PAGES = 100;
|
||||||
|
|
||||||
|
function observedState(state: ConnectionState | undefined): FleetObservedState {
|
||||||
|
if (!state?.reachable) return 'offline';
|
||||||
|
return state.authenticated ? 'online' : 'auth-required';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps device online state honest without waiting for an operator to open a page: every beat
|
* Keeps device online state honest without waiting for an operator to open a page: every beat
|
||||||
* probes each instance once and lets the snapshot journal carry the result until it expires.
|
* probes each instance once and lets the snapshot journal carry the result until it expires.
|
||||||
@@ -34,9 +77,14 @@ export class FleetHeartbeatCoordinator {
|
|||||||
readonly #now: () => Date;
|
readonly #now: () => Date;
|
||||||
readonly #concurrency: number;
|
readonly #concurrency: number;
|
||||||
readonly #pageSize: number;
|
readonly #pageSize: number;
|
||||||
|
readonly #onTransition:
|
||||||
|
| ((transitions: readonly FleetStateTransition[]) => Promise<void>)
|
||||||
|
| undefined;
|
||||||
#timer: ReturnType<typeof setTimeout> | undefined;
|
#timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
#running: Promise<FleetHeartbeatSummary> | undefined;
|
#running: Promise<FleetHeartbeatSummary> | undefined;
|
||||||
#stopped = false;
|
#stopped = false;
|
||||||
|
/** States seen by the previous beat; undefined until the first beat establishes a baseline. */
|
||||||
|
#previous: ReadonlyMap<string, FleetObservedState> | undefined;
|
||||||
|
|
||||||
constructor(options: FleetHeartbeatOptions) {
|
constructor(options: FleetHeartbeatOptions) {
|
||||||
this.#instances = options.instances;
|
this.#instances = options.instances;
|
||||||
@@ -51,6 +99,7 @@ export class FleetHeartbeatCoordinator {
|
|||||||
throw new RangeError('pageSize must be between 1 and 100');
|
throw new RangeError('pageSize must be between 1 and 100');
|
||||||
this.#concurrency = concurrency;
|
this.#concurrency = concurrency;
|
||||||
this.#pageSize = pageSize;
|
this.#pageSize = pageSize;
|
||||||
|
this.#onTransition = options.onTransition;
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -95,6 +144,7 @@ export class FleetHeartbeatCoordinator {
|
|||||||
for (const instance of current.items) if (!ids.includes(instance.id)) ids.push(instance.id);
|
for (const instance of current.items) if (!ids.includes(instance.id)) ids.push(instance.id);
|
||||||
if (current.items.length < this.#pageSize) break;
|
if (current.items.length < this.#pageSize) break;
|
||||||
}
|
}
|
||||||
|
const before = this.#previous;
|
||||||
let probed = 0;
|
let probed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
@@ -115,6 +165,39 @@ export class FleetHeartbeatCoordinator {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await Promise.all(workers);
|
await Promise.all(workers);
|
||||||
return { probed, failed, startedAt, finishedAt: this.#now().toISOString() };
|
const transitions = this.#diff(before, ids);
|
||||||
|
if (transitions.length > 0) await this.#onTransition?.(transitions);
|
||||||
|
return { probed, failed, transitions, startedAt, finishedAt: this.#now().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the given instances, including ones that have never been probed. */
|
||||||
|
#states(ids: readonly string[]): ReadonlyMap<string, FleetObservedState> {
|
||||||
|
const reachability = this.#probe.reachability();
|
||||||
|
const states = new Map<string, FleetObservedState>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const state = reachability.get(id);
|
||||||
|
states.set(id, observedState(state));
|
||||||
|
}
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first beat only records a baseline. Without one, every instance would start as
|
||||||
|
* "offline" and then announce itself as recovered, so a restart would flood the queue.
|
||||||
|
*/
|
||||||
|
#diff(
|
||||||
|
before: ReadonlyMap<string, FleetObservedState> | undefined,
|
||||||
|
ids: readonly string[],
|
||||||
|
): readonly FleetStateTransition[] {
|
||||||
|
const after = this.#states(ids);
|
||||||
|
this.#previous = after;
|
||||||
|
if (before === undefined) return [];
|
||||||
|
const transitions: FleetStateTransition[] = [];
|
||||||
|
for (const [id, to] of after) {
|
||||||
|
const from = before.get(id);
|
||||||
|
if (from === undefined || from === to) continue;
|
||||||
|
transitions.push({ instanceId: id, from, to });
|
||||||
|
}
|
||||||
|
return transitions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,11 +178,20 @@ function expect(reply: { code: number; text: string }, codes: readonly number[])
|
|||||||
function plainSocket(host: string, port: number, timeoutMs: number): Promise<Socket> {
|
function plainSocket(host: string, port: number, timeoutMs: number): Promise<Socket> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = createConnection({ host, port });
|
const socket = createConnection({ host, port });
|
||||||
socket.setTimeout(timeoutMs);
|
const onTimeout = (): void => {
|
||||||
socket.once('connect', () => resolve(socket));
|
|
||||||
socket.once('timeout', () => {
|
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
reject(new Error('SMTP 连接超时'));
|
reject(new Error('SMTP 连接超时'));
|
||||||
|
};
|
||||||
|
socket.setTimeout(timeoutMs);
|
||||||
|
socket.once('timeout', onTimeout);
|
||||||
|
socket.once('connect', () => {
|
||||||
|
// The timer only guards the handshake. Every command afterwards carries its own reply
|
||||||
|
// deadline, and leaving an idle timer armed would destroy the session at the same
|
||||||
|
// instant that deadline expires, making the reported detail a coin flip between
|
||||||
|
// "connection closed" and "timed out".
|
||||||
|
socket.removeListener('timeout', onTimeout);
|
||||||
|
socket.setTimeout(0);
|
||||||
|
resolve(socket);
|
||||||
});
|
});
|
||||||
socket.once('error', reject);
|
socket.once('error', reject);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { migrateDatabase } from '../../infrastructure/database/migrations.js';
|
|||||||
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
|
||||||
import {
|
import {
|
||||||
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
|
||||||
|
type BindingRelease,
|
||||||
DeleteInstanceOperation,
|
DeleteInstanceOperation,
|
||||||
DeleteInstanceOperationError,
|
DeleteInstanceOperationError,
|
||||||
} from './delete-instance-operation.js';
|
} from './delete-instance-operation.js';
|
||||||
@@ -26,7 +27,9 @@ afterEach(() => {
|
|||||||
for (const database of databases.splice(0)) database.close();
|
for (const database of databases.splice(0)) database.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
function fixture() {
|
function fixture(
|
||||||
|
releaseBinding?: (instanceId: string, requestId: string) => Promise<BindingRelease>,
|
||||||
|
) {
|
||||||
const db = new Database(':memory:');
|
const db = new Database(':memory:');
|
||||||
db.pragma('foreign_keys=ON');
|
db.pragma('foreign_keys=ON');
|
||||||
migrateDatabase(db);
|
migrateDatabase(db);
|
||||||
@@ -43,6 +46,7 @@ function fixture() {
|
|||||||
db,
|
db,
|
||||||
instances,
|
instances,
|
||||||
now,
|
now,
|
||||||
|
...(releaseBinding === undefined ? {} : { releaseBinding }),
|
||||||
idFactory: () => `operation-${++sequence}`,
|
idFactory: () => `operation-${++sequence}`,
|
||||||
tokenFactory: () => Buffer.alloc(32, 7).toString('base64url'),
|
tokenFactory: () => Buffer.alloc(32, 7).toString('base64url'),
|
||||||
});
|
});
|
||||||
@@ -396,4 +400,90 @@ describe('DeleteInstanceOperation', () => {
|
|||||||
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
|
||||||
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the node when the device refuses to release its binding', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const { instances, operation } = fixture(async (instanceId, requestId) => {
|
||||||
|
calls.push(`${instanceId}:${requestId}`);
|
||||||
|
return { status: 'failed' };
|
||||||
|
});
|
||||||
|
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||||
|
const prepared = await operation.prepare({
|
||||||
|
operationId: 'deleteInstance',
|
||||||
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||||
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||||
|
});
|
||||||
|
const job = await operation.executeDelete({
|
||||||
|
instanceId: instance.id,
|
||||||
|
revision: 1,
|
||||||
|
preparationId: prepared.id,
|
||||||
|
confirmationToken: prepared.confirmationToken,
|
||||||
|
actor: 'loopback-control-plane',
|
||||||
|
requestId: 'request-refused',
|
||||||
|
});
|
||||||
|
expect(calls).toEqual([`${instance.id}:request-refused`]);
|
||||||
|
expect(job.status).toBe('failed');
|
||||||
|
expect(job.items).toEqual([
|
||||||
|
{
|
||||||
|
id: expect.any(String),
|
||||||
|
targetId: instance.id,
|
||||||
|
state: 'failed',
|
||||||
|
error: {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Job item failed',
|
||||||
|
status: 502,
|
||||||
|
detail: 'The job item did not complete successfully.',
|
||||||
|
code: 'UNBIND_FAILED',
|
||||||
|
requestId: 'request-refused',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(await instances.get(instance.id)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets a node that never answered, exactly as an offline device is dropped', async () => {
|
||||||
|
const { instances, operation } = fixture(async () => ({ status: 'skipped' }));
|
||||||
|
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||||
|
const prepared = await operation.prepare({
|
||||||
|
operationId: 'deleteInstance',
|
||||||
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||||
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||||
|
});
|
||||||
|
const job = await operation.executeDelete({
|
||||||
|
instanceId: instance.id,
|
||||||
|
revision: 1,
|
||||||
|
preparationId: prepared.id,
|
||||||
|
confirmationToken: prepared.confirmationToken,
|
||||||
|
actor: 'loopback-control-plane',
|
||||||
|
requestId: 'request-offline',
|
||||||
|
});
|
||||||
|
expect(job.status).toBe('succeeded');
|
||||||
|
expect(job.items[0]).not.toHaveProperty('error');
|
||||||
|
expect(await instances.get(instance.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a transport that throws while the node was online as a refusal', async () => {
|
||||||
|
const { db, instances, operation } = fixture(async () => {
|
||||||
|
throw new Error('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
|
||||||
|
const prepared = await operation.prepare({
|
||||||
|
operationId: 'deleteInstance',
|
||||||
|
targets: [{ instanceId: instance.id, revision: 1 }],
|
||||||
|
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
|
||||||
|
});
|
||||||
|
const job = await operation.executeDelete({
|
||||||
|
instanceId: instance.id,
|
||||||
|
revision: 1,
|
||||||
|
preparationId: prepared.id,
|
||||||
|
confirmationToken: prepared.confirmationToken,
|
||||||
|
actor: 'loopback-control-plane',
|
||||||
|
requestId: 'request-thrown',
|
||||||
|
});
|
||||||
|
expect(job.items[0]).toMatchObject({ state: 'failed', error: { code: 'UNBIND_FAILED' } });
|
||||||
|
expect(db.prepare('SELECT result_code FROM job_items').get()).toEqual({
|
||||||
|
result_code: 'UNBIND_FAILED',
|
||||||
|
});
|
||||||
|
expect(await instances.get(instance.id)).toBeDefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { InstanceService, InstanceServiceError } from '../instances/instance-ser
|
|||||||
export const DELETE_INSTANCE_PARAMETER_SCHEMA_ID = 'deleteInstance.parameters.v1' as const;
|
export const DELETE_INSTANCE_PARAMETER_SCHEMA_ID = 'deleteInstance.parameters.v1' as const;
|
||||||
const OPERATION_ID = 'deleteInstance';
|
const OPERATION_ID = 'deleteInstance';
|
||||||
const ACTOR = 'loopback-control-plane';
|
const ACTOR = 'loopback-control-plane';
|
||||||
|
/** Job item code for "the device still holds its central binding", surfaced to the console. */
|
||||||
|
export const BINDING_REFUSAL_CODE = 'UNBIND_FAILED' as const;
|
||||||
const TTL_MS = 5 * 60 * 1000;
|
const TTL_MS = 5 * 60 * 1000;
|
||||||
const PARAMETERS_DIGEST = createHash('sha256')
|
const PARAMETERS_DIGEST = createHash('sha256')
|
||||||
.update(JSON.stringify({ parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] }))
|
.update(JSON.stringify({ parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] }))
|
||||||
@@ -53,9 +55,23 @@ interface ExecuteInput {
|
|||||||
readonly actor: string;
|
readonly actor: string;
|
||||||
readonly requestId: string;
|
readonly requestId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer to "release the central binding on this device before we forget it".
|
||||||
|
* `skipped` means the node never answered a heartbeat, so there is nobody to notify and the
|
||||||
|
* console deletes its own record; the device re-binds if it ever comes back. `failed` keeps the
|
||||||
|
* record, because a device that refused or timed out must not be silently orphaned.
|
||||||
|
*/
|
||||||
|
export type BindingRelease =
|
||||||
|
| { readonly status: 'released' }
|
||||||
|
| { readonly status: 'skipped' }
|
||||||
|
| { readonly status: 'failed' };
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
readonly db: Database.Database;
|
readonly db: Database.Database;
|
||||||
readonly instances: InstanceService;
|
readonly instances: InstanceService;
|
||||||
|
/** Optional so a control plane without a device transport can still delete. */
|
||||||
|
readonly releaseBinding?: (instanceId: string, requestId: string) => Promise<BindingRelease>;
|
||||||
readonly now?: () => Date;
|
readonly now?: () => Date;
|
||||||
readonly idFactory?: () => string;
|
readonly idFactory?: () => string;
|
||||||
readonly tokenFactory?: () => string;
|
readonly tokenFactory?: () => string;
|
||||||
@@ -73,6 +89,7 @@ const validRevision = (value: unknown): value is number =>
|
|||||||
export class DeleteInstanceOperation {
|
export class DeleteInstanceOperation {
|
||||||
private readonly db: Database.Database;
|
private readonly db: Database.Database;
|
||||||
private readonly instances: InstanceService;
|
private readonly instances: InstanceService;
|
||||||
|
private readonly releaseBinding: Options['releaseBinding'];
|
||||||
private readonly clock: () => Date;
|
private readonly clock: () => Date;
|
||||||
private readonly id: () => string;
|
private readonly id: () => string;
|
||||||
private readonly token: () => string;
|
private readonly token: () => string;
|
||||||
@@ -80,6 +97,7 @@ export class DeleteInstanceOperation {
|
|||||||
constructor(options: Options) {
|
constructor(options: Options) {
|
||||||
this.db = options.db;
|
this.db = options.db;
|
||||||
this.instances = options.instances;
|
this.instances = options.instances;
|
||||||
|
this.releaseBinding = options.releaseBinding;
|
||||||
this.clock = options.now ?? (() => new Date());
|
this.clock = options.now ?? (() => new Date());
|
||||||
this.id = options.idFactory ?? randomUUID;
|
this.id = options.idFactory ?? randomUUID;
|
||||||
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
|
this.token = options.tokenFactory ?? (() => randomBytes(32).toString('base64url'));
|
||||||
@@ -294,6 +312,14 @@ export class DeleteInstanceOperation {
|
|||||||
'Confirmation could not be accepted',
|
'Confirmation could not be accepted',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// An online device is told to release its central binding first, and a refusal keeps the
|
||||||
|
// record so the operator can retry instead of losing sight of a device that is still
|
||||||
|
// reporting to a hub. Only a node that cannot be reached at all is forgotten locally.
|
||||||
|
const release = await this.releaseBindingPort(input.instanceId, input.requestId);
|
||||||
|
if (release?.status === 'failed') {
|
||||||
|
this.closeAsFailed(ids, input.requestId, BINDING_REFUSAL_CODE);
|
||||||
|
return this.job(ids.job);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true });
|
await this.instances.delete(input.instanceId, input.revision, { allowJobHistory: true });
|
||||||
const finished = this.clock().toISOString();
|
const finished = this.clock().toISOString();
|
||||||
@@ -339,6 +365,53 @@ export class DeleteInstanceOperation {
|
|||||||
return this.job(ids.job);
|
return this.job(ids.job);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A transport that throws while the node was reachable counts as a refusal, never a pass. */
|
||||||
|
private async releaseBindingPort(
|
||||||
|
instanceId: string,
|
||||||
|
requestId: string,
|
||||||
|
): Promise<BindingRelease | undefined> {
|
||||||
|
if (!this.releaseBinding) return undefined;
|
||||||
|
try {
|
||||||
|
return await this.releaseBinding(instanceId, requestId);
|
||||||
|
} catch {
|
||||||
|
return { status: 'failed' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the job without touching the instance row, so the device stays listed and retryable. */
|
||||||
|
private closeAsFailed(
|
||||||
|
ids: { job: string; item: string; attempt: string },
|
||||||
|
requestId: string,
|
||||||
|
resultCode: string,
|
||||||
|
): void {
|
||||||
|
const finished = this.clock().toISOString();
|
||||||
|
this.db.transaction(() => {
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE job_items
|
||||||
|
SET status='failed',result_code=?,error_json=?,finished_at=?,updated_at=?
|
||||||
|
WHERE id=? AND status='running'`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
resultCode,
|
||||||
|
JSON.stringify({ status: 502, code: resultCode, requestId }),
|
||||||
|
finished,
|
||||||
|
finished,
|
||||||
|
ids.item,
|
||||||
|
);
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE job_attempts SET status='failed',finished_at=? WHERE id=? AND status='running'",
|
||||||
|
)
|
||||||
|
.run(finished, ids.attempt);
|
||||||
|
this.db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE jobs SET status='failed',finished_at=?,updated_at=? WHERE id=? AND status='running'",
|
||||||
|
)
|
||||||
|
.run(finished, finished, ids.job);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
reconcileInterruptedJobs(): number {
|
reconcileInterruptedJobs(): number {
|
||||||
const finished = this.clock().toISOString();
|
const finished = this.clock().toISOString();
|
||||||
return this.db.transaction(() => {
|
return this.db.transaction(() => {
|
||||||
@@ -369,7 +442,7 @@ export class DeleteInstanceOperation {
|
|||||||
private job(id: string): Job {
|
private job(id: string): Job {
|
||||||
const row = this.db
|
const row = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at FROM jobs WHERE id=?',
|
'SELECT operation_id,status,root_job_id,retry_of_job_id,created_at,request_id FROM jobs WHERE id=?',
|
||||||
)
|
)
|
||||||
.get(id) as {
|
.get(id) as {
|
||||||
operation_id: string;
|
operation_id: string;
|
||||||
@@ -377,13 +450,17 @@ export class DeleteInstanceOperation {
|
|||||||
root_job_id: string;
|
root_job_id: string;
|
||||||
retry_of_job_id: string | null;
|
retry_of_job_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
request_id: string;
|
||||||
};
|
};
|
||||||
const items = this.db
|
const items = this.db
|
||||||
.prepare('SELECT id,instance_id,status FROM job_items WHERE job_id=? ORDER BY created_at,id')
|
.prepare(
|
||||||
|
'SELECT id,instance_id,status,result_code FROM job_items WHERE job_id=? ORDER BY created_at,id',
|
||||||
|
)
|
||||||
.all(id) as Array<{
|
.all(id) as Array<{
|
||||||
id: string;
|
id: string;
|
||||||
instance_id: string;
|
instance_id: string;
|
||||||
status: 'succeeded' | 'failed' | 'unknown-result';
|
status: 'succeeded' | 'failed' | 'unknown-result';
|
||||||
|
result_code: string | null;
|
||||||
}>;
|
}>;
|
||||||
const attempts = this.db
|
const attempts = this.db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -401,7 +478,25 @@ export class DeleteInstanceOperation {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
...(row.retry_of_job_id ? { retryOfJobId: row.retry_of_job_id } : {}),
|
...(row.retry_of_job_id ? { retryOfJobId: row.retry_of_job_id } : {}),
|
||||||
rootJobId: row.root_job_id,
|
rootJobId: row.root_job_id,
|
||||||
items: items.map((item) => ({ id: item.id, targetId: item.instance_id, state: item.status })),
|
items: items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
targetId: item.instance_id,
|
||||||
|
state: item.status,
|
||||||
|
// The console reads this code straight out of the execute response to explain why the
|
||||||
|
// node it just confirmed is still in the list.
|
||||||
|
...(item.result_code === BINDING_REFUSAL_CODE
|
||||||
|
? {
|
||||||
|
error: {
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Job item failed',
|
||||||
|
status: 502,
|
||||||
|
detail: 'The job item did not complete successfully.',
|
||||||
|
code: BINDING_REFUSAL_CODE,
|
||||||
|
requestId: row.request_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
attempts: attempts.map((attempt) => ({
|
attempts: attempts.map((attempt) => ({
|
||||||
id: attempt.id,
|
id: attempt.id,
|
||||||
state: attempt.status,
|
state: attempt.status,
|
||||||
|
|||||||
@@ -1216,4 +1216,84 @@ describe('buildControlPlaneApp', () => {
|
|||||||
expect(body.total).toBe(1);
|
expect(body.total).toBe(1);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a node that refuses to release its hub binding and drops one that is unreachable', async () => {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.pragma('foreign_keys=ON');
|
||||||
|
migrateDatabase(db);
|
||||||
|
dbs.push(db);
|
||||||
|
const unbinds: string[] = [];
|
||||||
|
const app = buildControlPlaneApp({
|
||||||
|
db,
|
||||||
|
store: new Store(),
|
||||||
|
upstream: {
|
||||||
|
get: async (url) => {
|
||||||
|
if (url.includes('192.168.1.21')) throw new Error('ECONNREFUSED');
|
||||||
|
return { status: 200, headers: {}, body: '' };
|
||||||
|
},
|
||||||
|
request: async ({ url, method }) => {
|
||||||
|
if (method === 'POST' && url.endsWith('/api/hub/unbind')) {
|
||||||
|
unbinds.push(url);
|
||||||
|
return { status: 409, headers: {}, body: '{"status":"error","message":"busy"}' };
|
||||||
|
}
|
||||||
|
return { status: 200, headers: {}, body: '{"status":"success"}' };
|
||||||
|
},
|
||||||
|
postNetworkRegisterAuto: async () => ({ status: 200 }),
|
||||||
|
postServiceRestart: async () => ({ status: 200 }),
|
||||||
|
postBasebandRestart: async () => ({ status: 200 }),
|
||||||
|
postSystemReboot: async () => ({ status: 200 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
async function remove(instanceId: string): Promise<Record<string, unknown>> {
|
||||||
|
const prepared = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/operations/prepare',
|
||||||
|
payload: {
|
||||||
|
operationId: 'deleteInstance',
|
||||||
|
targets: [{ instanceId, revision: 1 }],
|
||||||
|
parameters: { parameterSchemaId: 'deleteInstance.parameters.v1', fields: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(prepared.statusCode).toBe(200);
|
||||||
|
const executed = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/instances/${encodeURIComponent(instanceId)}`,
|
||||||
|
headers: {
|
||||||
|
'if-match': '"rev-1"',
|
||||||
|
'x-preparation-id': prepared.json().id as string,
|
||||||
|
'x-confirmation-token': prepared.json().confirmationToken as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(executed.statusCode).toBe(202);
|
||||||
|
return executed.json() as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
for (const [name, host] of [
|
||||||
|
['Refusing', '192.168.1.20'],
|
||||||
|
['Unreachable', '192.168.1.21'],
|
||||||
|
]) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/instances',
|
||||||
|
payload: { name, origin: `http://${host}:8080` },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
created.push(response.json().id as string);
|
||||||
|
}
|
||||||
|
const [refused, gone] = created;
|
||||||
|
|
||||||
|
expect(await remove(refused!)).toMatchObject({
|
||||||
|
status: 'failed',
|
||||||
|
items: [{ state: 'failed', error: { code: 'UNBIND_FAILED' } }],
|
||||||
|
});
|
||||||
|
expect(await remove(gone!)).toMatchObject({ status: 'succeeded' });
|
||||||
|
|
||||||
|
const listed = await app.inject('/api/v1/instances');
|
||||||
|
expect((listed.json().items as Array<{ id: string }>).map((item) => item.id)).toEqual([
|
||||||
|
refused,
|
||||||
|
]);
|
||||||
|
expect(unbinds).toEqual([`http://192.168.1.20:8080/api/hub/unbind`]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ import {
|
|||||||
type ConnectionTransport,
|
type ConnectionTransport,
|
||||||
} from './application/connections/connection-probe.js';
|
} from './application/connections/connection-probe.js';
|
||||||
import { ConnectionSettingsService } from './application/connections/connection-settings-service.js';
|
import { ConnectionSettingsService } from './application/connections/connection-settings-service.js';
|
||||||
import { FleetHeartbeatCoordinator } from './application/connections/fleet-heartbeat.js';
|
import {
|
||||||
|
FLEET_STATE_CODES,
|
||||||
|
FLEET_STATE_EVENTS,
|
||||||
|
FLEET_STATE_NAMES,
|
||||||
|
FleetHeartbeatCoordinator,
|
||||||
|
} from './application/connections/fleet-heartbeat.js';
|
||||||
import { InstanceCredentialResolver } from './application/connections/instance-credential-resolver.js';
|
import { InstanceCredentialResolver } from './application/connections/instance-credential-resolver.js';
|
||||||
import { InstanceLoginService } from './application/connections/instance-login-service.js';
|
import { InstanceLoginService } from './application/connections/instance-login-service.js';
|
||||||
import {
|
import {
|
||||||
@@ -23,7 +28,10 @@ import {
|
|||||||
} from './application/connections/upstream-session-client.js';
|
} from './application/connections/upstream-session-client.js';
|
||||||
import { InstanceService } from './application/instances/instance-service.js';
|
import { InstanceService } from './application/instances/instance-service.js';
|
||||||
import { DeviceDiscoveryService } from './application/instances/device-discovery-service.js';
|
import { DeviceDiscoveryService } from './application/instances/device-discovery-service.js';
|
||||||
import { DeleteInstanceOperation } from './application/operations/delete-instance-operation.js';
|
import {
|
||||||
|
type BindingRelease,
|
||||||
|
DeleteInstanceOperation,
|
||||||
|
} from './application/operations/delete-instance-operation.js';
|
||||||
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
import type { SecretStore } from './infrastructure/secrets/secret-store.js';
|
||||||
import { registerInstanceRoutes } from './interface/http/instance-routes.js';
|
import { registerInstanceRoutes } from './interface/http/instance-routes.js';
|
||||||
import { registerEventRoutes } from './interface/http/event-routes.js';
|
import { registerEventRoutes } from './interface/http/event-routes.js';
|
||||||
@@ -181,12 +189,6 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
connectionLogs,
|
connectionLogs,
|
||||||
snapshotTtlMs: () => connectionSettings.snapshotTtlMs,
|
snapshotTtlMs: () => connectionSettings.snapshotTtlMs,
|
||||||
});
|
});
|
||||||
const heartbeat = new FleetHeartbeatCoordinator({
|
|
||||||
instances,
|
|
||||||
probe: connections,
|
|
||||||
settings: connectionSettings,
|
|
||||||
...(options.now ? { now: options.now } : {}),
|
|
||||||
});
|
|
||||||
const sessions = new InstanceSessionStore();
|
const sessions = new InstanceSessionStore();
|
||||||
const discovery = options.now
|
const discovery = options.now
|
||||||
? new DeviceDiscoveryService({ transport: options.upstream, instances, now: options.now })
|
? new DeviceDiscoveryService({ transport: options.upstream, instances, now: options.now })
|
||||||
@@ -238,6 +240,31 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
store: options.store,
|
store: options.store,
|
||||||
...(options.now ? { now: options.now } : {}),
|
...(options.now ? { now: options.now } : {}),
|
||||||
});
|
});
|
||||||
|
// The heartbeat owns the reachability loop, so it also owns telling the rule engine when a
|
||||||
|
// node went offline, came back, or lost its device session.
|
||||||
|
const heartbeat = new FleetHeartbeatCoordinator({
|
||||||
|
instances,
|
||||||
|
probe: connections,
|
||||||
|
settings: connectionSettings,
|
||||||
|
...(options.now ? { now: options.now } : {}),
|
||||||
|
onTransition: async (transitions) => {
|
||||||
|
for (const transition of transitions) {
|
||||||
|
const instance = await instances.get(transition.instanceId);
|
||||||
|
const label = instance?.name ?? transition.instanceId;
|
||||||
|
await centralNotifications.enqueueEvent('system', {
|
||||||
|
instanceId: transition.instanceId,
|
||||||
|
instanceTags: instance?.tags ?? [],
|
||||||
|
fields: {
|
||||||
|
title: `${label} ${FLEET_STATE_EVENTS[transition.to]}`,
|
||||||
|
status: FLEET_STATE_CODES[transition.to],
|
||||||
|
content: `节点 ${label} 从「${FLEET_STATE_NAMES[transition.from]}」变为「${
|
||||||
|
FLEET_STATE_NAMES[transition.to]
|
||||||
|
}」(${FLEET_STATE_EVENTS[transition.to]})。`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
const hubMessages = new HubMessageService(options.db, {
|
const hubMessages = new HubMessageService(options.db, {
|
||||||
instances,
|
instances,
|
||||||
messages,
|
messages,
|
||||||
@@ -269,9 +296,34 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
|
|||||||
...(options.now ? { now: options.now } : {}),
|
...(options.now ? { now: options.now } : {}),
|
||||||
});
|
});
|
||||||
smsOutbox.reconcileInterrupted();
|
smsOutbox.reconcileInterrupted();
|
||||||
|
// Removal follows the hub rule the operator already knows: an online device is told to release
|
||||||
|
// its own binding first and a refusal keeps the record, while a device that cannot be reached
|
||||||
|
// at all is forgotten locally. The closure is late-bound because the device action service is
|
||||||
|
// built further down, next to the session store it needs.
|
||||||
|
const releaseBinding = async (instanceId: string, requestId: string): Promise<BindingRelease> => {
|
||||||
|
try {
|
||||||
|
await connections.test(instanceId);
|
||||||
|
} catch {
|
||||||
|
return { status: 'skipped' };
|
||||||
|
}
|
||||||
|
const result = await deviceActions.execute(
|
||||||
|
instanceId,
|
||||||
|
'hub.unbind',
|
||||||
|
{},
|
||||||
|
{ actor: 'loopback-control-plane', requestId, confirm: true },
|
||||||
|
);
|
||||||
|
// A device too old to expose the endpoint never had a binding to release.
|
||||||
|
if (result.status === 404 || result.status === 405) return { status: 'released' };
|
||||||
|
return result.ok ? { status: 'released' } : { status: 'failed' };
|
||||||
|
};
|
||||||
const deletion = options.now
|
const deletion = options.now
|
||||||
? new DeleteInstanceOperation({ db: options.db, instances, now: options.now })
|
? new DeleteInstanceOperation({
|
||||||
: new DeleteInstanceOperation({ db: options.db, instances });
|
db: options.db,
|
||||||
|
instances,
|
||||||
|
releaseBinding,
|
||||||
|
now: options.now,
|
||||||
|
})
|
||||||
|
: new DeleteInstanceOperation({ db: options.db, instances, releaseBinding });
|
||||||
deletion.reconcileInterruptedJobs();
|
deletion.reconcileInterruptedJobs();
|
||||||
const listHubDevices = createHubDeviceReader(options.db, instances);
|
const listHubDevices = createHubDeviceReader(options.db, instances);
|
||||||
const resolveOperationCookie = async (
|
const resolveOperationCookie = async (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createInstanceApiDataSource,
|
createInstanceApiDataSource,
|
||||||
passwordUpdate,
|
passwordUpdate,
|
||||||
tagsFromInput,
|
tagsFromInput,
|
||||||
|
BindingRefusalError,
|
||||||
} from './instance-api-data-source.js';
|
} from './instance-api-data-source.js';
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
@@ -86,30 +87,97 @@ describe('instance API data source', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prepares and executes R3 deletion through the durable operation endpoint', async () => {
|
it('prepares R3 deletion and executes it on the owner route with the one-time token', async () => {
|
||||||
const token = 'a'.repeat(32);
|
const token = 'a'.repeat(32);
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn<typeof globalThis.fetch>()
|
.fn<typeof globalThis.fetch>()
|
||||||
|
.mockResolvedValueOnce(response(instance, 200, '"rev-3"'))
|
||||||
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
|
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
|
||||||
.mockResolvedValueOnce(response({ id: 'job-1' }, 202));
|
.mockResolvedValueOnce(response({ id: 'job-1' }, 202));
|
||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const source = createInstanceApiDataSource();
|
||||||
|
|
||||||
await createInstanceApiDataSource().delete('owner', 3);
|
await source.get('owner/id');
|
||||||
expect(JSON.parse(fetch.mock.calls[0]?.[1]?.body as string)).toEqual({
|
await source.delete('owner/id', 3);
|
||||||
|
expect(JSON.parse(fetch.mock.calls[1]?.[1]?.body as string)).toEqual({
|
||||||
operationId: 'deleteInstance',
|
operationId: 'deleteInstance',
|
||||||
targets: [{ instanceId: 'owner', revision: 3 }],
|
targets: [{ instanceId: 'owner/id', revision: 3 }],
|
||||||
parameters: { parameterSchemaId: 'deleteInstance.parameters.v1', fields: [] },
|
parameters: { parameterSchemaId: 'deleteInstance.parameters.v1', fields: [] },
|
||||||
});
|
});
|
||||||
expect(fetch.mock.calls[1]?.[0]).toBe('/api/v1/operations/execute');
|
expect(fetch.mock.calls[2]?.[0]).toBe('/api/v1/instances/owner%2Fid');
|
||||||
expect(fetch.mock.calls[1]?.[1]).toMatchObject({
|
expect(fetch.mock.calls[2]?.[1]).toMatchObject({
|
||||||
method: 'POST',
|
method: 'DELETE',
|
||||||
body: JSON.stringify({ preparationId: 'prep-1', confirmationToken: token }),
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'If-Match': '"rev-3"',
|
||||||
|
'X-Preparation-Id': 'prep-1',
|
||||||
|
'X-Confirmation-Token': token,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(
|
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
|
||||||
fetch.mock.calls.some(
|
// The confirmation is a bearer secret: it belongs in a header, never in a URL or a body.
|
||||||
([url, init]) => init?.method === 'DELETE' || String(url).includes(token),
|
expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true);
|
||||||
),
|
});
|
||||||
).toBe(false);
|
|
||||||
|
it('refetches the ETag when deletion is confirmed without a warm cache', async () => {
|
||||||
|
const token = 'c'.repeat(32);
|
||||||
|
const fetch = vi
|
||||||
|
.fn<typeof globalThis.fetch>()
|
||||||
|
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
|
||||||
|
.mockResolvedValueOnce(response(instance, 200, '"rev-3"'))
|
||||||
|
.mockResolvedValueOnce(response({ id: 'job-1' }, 202));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
|
||||||
|
await createInstanceApiDataSource().delete('owner/id', 3);
|
||||||
|
|
||||||
|
expect(fetch.mock.calls[1]?.[1]?.method).toBeUndefined();
|
||||||
|
expect(fetch.mock.calls[2]?.[1]).toMatchObject({
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: expect.objectContaining({ 'If-Match': '"rev-3"' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a deletion whose confirmation no longer matches the recorded revision', async () => {
|
||||||
|
const token = 'd'.repeat(32);
|
||||||
|
const fetch = vi
|
||||||
|
.fn<typeof globalThis.fetch>()
|
||||||
|
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
|
||||||
|
.mockResolvedValueOnce(response({ ...instance, revision: 4 }, 200, '"rev-4"'));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
|
||||||
|
await expect(createInstanceApiDataSource().delete('owner/id', 3)).rejects.toThrow(/dropped/i);
|
||||||
|
expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a device that kept its binding instead of pretending the node is gone', async () => {
|
||||||
|
const token = 'b'.repeat(32);
|
||||||
|
const fetch = vi
|
||||||
|
.fn<typeof globalThis.fetch>()
|
||||||
|
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
|
||||||
|
.mockResolvedValueOnce(response(instance, 200, '"rev-3"'))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
response(
|
||||||
|
{
|
||||||
|
id: 'job-1',
|
||||||
|
status: 'failed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-1',
|
||||||
|
targetId: 'owner/id',
|
||||||
|
state: 'failed',
|
||||||
|
error: { code: 'UNBIND_FAILED', status: 502 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
202,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
|
||||||
|
await expect(createInstanceApiDataSource().delete('owner/id', 3)).rejects.toBeInstanceOf(
|
||||||
|
BindingRefusalError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects mismatched owners and does not render arbitrary response text as an error', async () => {
|
it('rejects mismatched owners and does not render arbitrary response text as an error', async () => {
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ interface Preparation {
|
|||||||
const DELETE_SCHEMA = 'deleteInstance.parameters.v1';
|
const DELETE_SCHEMA = 'deleteInstance.parameters.v1';
|
||||||
const SAFE_FALLBACK = 'The instance operation could not be completed.';
|
const SAFE_FALLBACK = 'The instance operation could not be completed.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device kept its central binding, so the control plane refused to forget it. This is the
|
||||||
|
* one deletion failure the operator can act on, and the row stays in the list, so the console
|
||||||
|
* shows the reason instead of the generic fallback.
|
||||||
|
*/
|
||||||
|
export class BindingRefusalError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('设备仍持有回连绑定,节点已保留。请先在设备面板处理绑定,或在节点离线后重试删除。');
|
||||||
|
this.name = 'BindingRefusalError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ownerPath(instanceId: string, suffix = ''): string {
|
function ownerPath(instanceId: string, suffix = ''): string {
|
||||||
return `/api/v1/instances/${encodeURIComponent(instanceId)}${suffix}`;
|
return `/api/v1/instances/${encodeURIComponent(instanceId)}${suffix}`;
|
||||||
}
|
}
|
||||||
@@ -109,6 +121,21 @@ export function createInstanceApiDataSource(): InstanceDataSource {
|
|||||||
etags.set(value.id, { revision: value.revision, value: strongEtag(result.response) });
|
etags.set(value.id, { revision: value.revision, value: strongEtag(result.response) });
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Deletion is an If-Match route, so the console needs the strong ETag for the exact revision
|
||||||
|
* the operator confirmed. A warm cache is preferred; a cold or stale one costs one GET rather
|
||||||
|
* than letting the browser guess at a header the server is about to check.
|
||||||
|
*/
|
||||||
|
const etagFor = async (instanceId: string, revision: number): Promise<string> => {
|
||||||
|
const cached = etags.get(instanceId);
|
||||||
|
if (cached !== undefined && cached.revision === revision) return cached.value;
|
||||||
|
const fresh = await instanceResponse(ownerPath(instanceId), instanceId);
|
||||||
|
const stored = etags.get(fresh.id);
|
||||||
|
if (stored === undefined) throw new Error('The server did not return a valid strong ETag.');
|
||||||
|
if (fresh.revision !== revision)
|
||||||
|
throw new Error('The instance changed before deletion, so the confirmation was dropped.');
|
||||||
|
return stored.value;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
async get(instanceId) {
|
async get(instanceId) {
|
||||||
return instanceResponse(ownerPath(instanceId), instanceId);
|
return instanceResponse(ownerPath(instanceId), instanceId);
|
||||||
@@ -157,13 +184,30 @@ export function createInstanceApiDataSource(): InstanceDataSource {
|
|||||||
)
|
)
|
||||||
throw new Error('The server returned an invalid deletion confirmation.');
|
throw new Error('The server returned an invalid deletion confirmation.');
|
||||||
const confirmation = prepared as unknown as Preparation;
|
const confirmation = prepared as unknown as Preparation;
|
||||||
await requestJson('/api/v1/operations/execute', {
|
// The generic execute endpoint only accepts the operations the safe transport can replay,
|
||||||
method: 'POST',
|
// and deletion is not one of them: it goes to the owner route with the one-time token in a
|
||||||
body: JSON.stringify({
|
// header, where the token is consumed against the revision the preparation was bound to.
|
||||||
preparationId: confirmation.id,
|
const match = await etagFor(instanceId, revision);
|
||||||
confirmationToken: confirmation.confirmationToken,
|
const executed = await requestJson(ownerPath(instanceId), {
|
||||||
}),
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'If-Match': match,
|
||||||
|
'X-Preparation-Id': confirmation.id,
|
||||||
|
'X-Confirmation-Token': confirmation.confirmationToken,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
// Deletion is accepted as a job, and the job is where a device that kept its binding
|
||||||
|
// reports back. The row survives a refresh in that case, so the console has to say why.
|
||||||
|
if (isRecord(executed) && Array.isArray(executed.items)) {
|
||||||
|
const refused = executed.items.some(
|
||||||
|
(item) =>
|
||||||
|
isRecord(item) &&
|
||||||
|
item.state === 'failed' &&
|
||||||
|
isRecord(item.error) &&
|
||||||
|
item.error.code === 'UNBIND_FAILED',
|
||||||
|
);
|
||||||
|
if (refused) throw new BindingRefusalError();
|
||||||
|
}
|
||||||
etags.delete(instanceId);
|
etags.delete(instanceId);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { InstanceEditor, type InstanceDataSource, type ManagedInstance } from './instance-crud.js';
|
import { InstanceEditor, type InstanceDataSource, type ManagedInstance } from './instance-crud.js';
|
||||||
|
import { BindingRefusalError } from './instance-api-data-source.js';
|
||||||
|
|
||||||
const owner: ManagedInstance = {
|
const owner: ManagedInstance = {
|
||||||
id: 'owner',
|
id: 'owner',
|
||||||
@@ -111,4 +112,20 @@ describe('Instance CRUD form', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: '确认删除' }));
|
await user.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
expect(source.delete).toHaveBeenCalledWith('owner', 3);
|
expect(source.delete).toHaveBeenCalledWith('owner', 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('explains why a device that kept its binding is still listed', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const source = dataSource({
|
||||||
|
delete: vi.fn(async () => {
|
||||||
|
throw new BindingRefusalError();
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
render(<InstanceEditor mode="edit" instanceId="owner" dataSource={source} />);
|
||||||
|
await screen.findByDisplayValue('Owner modem');
|
||||||
|
await user.click(screen.getByRole('button', { name: '删除实例' }));
|
||||||
|
await user.type(screen.getByLabelText('输入 owner 以确认'), 'owner');
|
||||||
|
await user.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
|
|
||||||
|
expect((await screen.findByRole('alert')).textContent).toContain('设备仍持有回连绑定');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button, Card, Title } from 'animal-island-ui';
|
|||||||
|
|
||||||
import { DeviceDiscoveryPanel, type DeviceSelection } from './device-discovery-panel.js';
|
import { DeviceDiscoveryPanel, type DeviceSelection } from './device-discovery-panel.js';
|
||||||
import {
|
import {
|
||||||
|
BindingRefusalError,
|
||||||
createInstanceApiDataSource,
|
createInstanceApiDataSource,
|
||||||
passwordUpdate,
|
passwordUpdate,
|
||||||
tagsFromInput,
|
tagsFromInput,
|
||||||
@@ -137,8 +138,10 @@ export function InstanceEditor({
|
|||||||
setStatus('实例删除请求已接受。');
|
setStatus('实例删除请求已接受。');
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setConfirming(false);
|
setConfirming(false);
|
||||||
} catch {
|
} catch (cause) {
|
||||||
setError(message());
|
// A device that kept its binding stays in the list, so the operator needs the real reason.
|
||||||
|
if (cause instanceof BindingRefusalError) setError(cause.message);
|
||||||
|
else setError(message());
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user