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
@@ -5,6 +5,7 @@ import {
createInstanceApiDataSource,
passwordUpdate,
tagsFromInput,
BindingRefusalError,
} from './instance-api-data-source.js';
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 fetch = vi
.fn<typeof globalThis.fetch>()
.mockResolvedValueOnce(response(instance, 200, '"rev-3"'))
.mockResolvedValueOnce(response({ id: 'prep-1', confirmationToken: token }))
.mockResolvedValueOnce(response({ id: 'job-1' }, 202));
vi.stubGlobal('fetch', fetch);
const source = createInstanceApiDataSource();
await createInstanceApiDataSource().delete('owner', 3);
expect(JSON.parse(fetch.mock.calls[0]?.[1]?.body as string)).toEqual({
await source.get('owner/id');
await source.delete('owner/id', 3);
expect(JSON.parse(fetch.mock.calls[1]?.[1]?.body as string)).toEqual({
operationId: 'deleteInstance',
targets: [{ instanceId: 'owner', revision: 3 }],
targets: [{ instanceId: 'owner/id', revision: 3 }],
parameters: { parameterSchemaId: 'deleteInstance.parameters.v1', fields: [] },
});
expect(fetch.mock.calls[1]?.[0]).toBe('/api/v1/operations/execute');
expect(fetch.mock.calls[1]?.[1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ preparationId: 'prep-1', confirmationToken: token }),
expect(fetch.mock.calls[2]?.[0]).toBe('/api/v1/instances/owner%2Fid');
expect(fetch.mock.calls[2]?.[1]).toMatchObject({
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'If-Match': '"rev-3"',
'X-Preparation-Id': 'prep-1',
'X-Confirmation-Token': token,
},
});
expect(
fetch.mock.calls.some(
([url, init]) => init?.method === 'DELETE' || String(url).includes(token),
),
).toBe(false);
expect(fetch.mock.calls[2]?.[1]?.body).toBeUndefined();
// The confirmation is a bearer secret: it belongs in a header, never in a URL or a body.
expect(fetch.mock.calls.every(([url]) => !String(url).includes(token))).toBe(true);
});
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 () => {
@@ -39,6 +39,18 @@ interface Preparation {
const DELETE_SCHEMA = 'deleteInstance.parameters.v1';
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 {
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) });
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 {
async get(instanceId) {
return instanceResponse(ownerPath(instanceId), instanceId);
@@ -157,13 +184,30 @@ export function createInstanceApiDataSource(): InstanceDataSource {
)
throw new Error('The server returned an invalid deletion confirmation.');
const confirmation = prepared as unknown as Preparation;
await requestJson('/api/v1/operations/execute', {
method: 'POST',
body: JSON.stringify({
preparationId: confirmation.id,
confirmationToken: confirmation.confirmationToken,
}),
// The generic execute endpoint only accepts the operations the safe transport can replay,
// and deletion is not one of them: it goes to the owner route with the one-time token in a
// header, where the token is consumed against the revision the preparation was bound to.
const match = await etagFor(instanceId, revision);
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);
},
};
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InstanceEditor, type InstanceDataSource, type ManagedInstance } from './instance-crud.js';
import { BindingRefusalError } from './instance-api-data-source.js';
const owner: ManagedInstance = {
id: 'owner',
@@ -111,4 +112,20 @@ describe('Instance CRUD form', () => {
await user.click(screen.getByRole('button', { name: '确认删除' }));
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('设备仍持有回连绑定');
});
});
+5 -2
View File
@@ -3,6 +3,7 @@ import { Button, Card, Title } from 'animal-island-ui';
import { DeviceDiscoveryPanel, type DeviceSelection } from './device-discovery-panel.js';
import {
BindingRefusalError,
createInstanceApiDataSource,
passwordUpdate,
tagsFromInput,
@@ -137,8 +138,10 @@ export function InstanceEditor({
setStatus('实例删除请求已接受。');
setError(undefined);
setConfirming(false);
} catch {
setError(message());
} catch (cause) {
// 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 {
setBusy(false);
}