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