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:
@@ -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('设备仍持有回连绑定');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user