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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user