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:
chick
2026-09-05 19:52:27 +08:00
parent 5016586922
commit 8b7df8cb65
11 changed files with 691 additions and 45 deletions
+80
View File
@@ -1216,4 +1216,84 @@ describe('buildControlPlaneApp', () => {
expect(body.total).toBe(1);
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();
});
});