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
@@ -6,6 +6,7 @@ import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import {
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
type BindingRelease,
DeleteInstanceOperation,
DeleteInstanceOperationError,
} from './delete-instance-operation.js';
@@ -26,7 +27,9 @@ afterEach(() => {
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:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
@@ -43,6 +46,7 @@ function fixture() {
db,
instances,
now,
...(releaseBinding === undefined ? {} : { releaseBinding }),
idFactory: () => `operation-${++sequence}`,
tokenFactory: () => Buffer.alloc(32, 7).toString('base64url'),
});
@@ -396,4 +400,90 @@ describe('DeleteInstanceOperation', () => {
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
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();
});
});