feat(api): complete phase 2.4 control plane

This commit is contained in:
chick
2026-07-17 00:37:11 +08:00
parent c2702b5fc6
commit 7b91dbbad1
40 changed files with 4526 additions and 74 deletions
@@ -0,0 +1,327 @@
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { afterEach, describe, expect, it } from 'vitest';
import { InstanceService } from '../instances/instance-service.js';
import { migrateDatabase } from '../../infrastructure/database/migrations.js';
import type { SecretStore } from '../../infrastructure/secrets/secret-store.js';
import {
DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
DeleteInstanceOperation,
DeleteInstanceOperationError,
} from './delete-instance-operation.js';
class Store implements SecretStore {
async set() {
return '';
}
async get() {
return undefined;
}
async delete() {
return false;
}
}
const databases: Database.Database[] = [];
afterEach(() => {
for (const database of databases.splice(0)) database.close();
});
function fixture() {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
databases.push(db);
let sequence = 0;
const now = () => new Date('2026-07-16T12:00:00.000Z');
const instances = new InstanceService({
db,
store: new Store(),
idFactory: () => `instance-${++sequence}`,
now,
});
const operation = new DeleteInstanceOperation({
db,
instances,
now,
idFactory: () => `operation-${++sequence}`,
tokenFactory: () => Buffer.alloc(32, 7).toString('base64url'),
});
return { db, instances, operation };
}
describe('DeleteInstanceOperation', () => {
it('prepares only the fixed delete shape and persists only a token digest with TTL', async () => {
const { db, instances, operation } = fixture();
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: [] },
});
expect(prepared).toMatchObject({
status: 'prepared',
operationId: 'deleteInstance',
risk: 'R3',
targetCount: 1,
});
expect(Buffer.from(prepared.confirmationToken, 'base64url')).toHaveLength(32);
expect(prepared.expiresAt).toBe('2026-07-16T12:05:00.000Z');
const row = db
.prepare('SELECT token_digest, expires_at FROM operation_preparations WHERE id=?')
.get(prepared.id) as { token_digest: string; expires_at: string };
expect(row.token_digest).toBe(
createHash('sha256').update(prepared.confirmationToken).digest('hex'),
);
expect(JSON.stringify(db.prepare('SELECT * FROM operation_preparations').all())).not.toContain(
prepared.confirmationToken,
);
for (const input of [
{
operationId: 'other',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
{
operationId: 'deleteInstance',
targets: [],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 0 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: 'wrong', fields: [] },
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: {
parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID,
fields: [{ fieldId: 'x', kind: 'string' as const, value: 'x' }],
},
},
{
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: 1 }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
extra: true,
},
null,
])
await expect(operation.prepare(input as never)).rejects.toBeInstanceOf(
DeleteInstanceOperationError,
);
});
it('consumes once, creates immutable history, deletes safely, and returns a frozen Job shape', async () => {
const { db, instances, operation } = fixture();
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-1',
});
expect(job).toEqual({
id: expect.any(String),
operationId: 'deleteInstance',
status: 'succeeded',
rootJobId: expect.any(String),
items: [{ id: expect.any(String), targetId: instance.id, state: 'succeeded' }],
attempts: [
{
id: expect.any(String),
state: 'succeeded',
startedAt: expect.any(String),
finishedAt: expect.any(String),
},
],
createdAt: expect.any(String),
});
expect(job.rootJobId).toBe(job.id);
expect(await instances.get(instance.id)).toBeUndefined();
expect(db.prepare('SELECT instance_id FROM job_items').get()).toEqual({
instance_id: instance.id,
});
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-2',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 1 });
});
it('does not consume a preparation when the token is wrong', async () => {
const { db, instances, operation } = fixture();
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: [] },
});
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: 'x'.repeat(20),
actor: 'loopback-control-plane',
requestId: 'request-1',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'prepared' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
});
it('returns typed target errors after consuming a correctly bound confirmation', async () => {
const { db, instances, operation } = fixture();
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: [] },
});
await instances.update(instance.id, 1, { name: 'B' });
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'stale',
}),
).rejects.toMatchObject({ code: 'REVISION_CONFLICT' });
expect(
db.prepare('SELECT status FROM operation_preparations WHERE id=?').get(prepared.id),
).toEqual({ status: 'consumed' });
const current = await instances.get(instance.id);
const missing = await operation.prepare({
operationId: 'deleteInstance',
targets: [{ instanceId: instance.id, revision: current!.revision }],
parameters: { parameterSchemaId: DELETE_INSTANCE_PARAMETER_SCHEMA_ID, fields: [] },
});
await instances.delete(instance.id, current!.revision);
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: current!.revision,
preparationId: missing.id,
confirmationToken: missing.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'missing',
}),
).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('reconciles interrupted running delete jobs to unknown-result', async () => {
const { db, instances, operation } = fixture();
const instance = await instances.create({ name: 'A', origin: 'http://192.168.1.10' });
const now = '2026-07-16T12:00:00.000Z';
db.prepare(
"INSERT INTO jobs (id,root_job_id,operation_id,risk_level,status,requested_by,request_id,parameters_digest,created_at,started_at,updated_at) VALUES ('j','j','deleteInstance','R3','running','a','r','d',?,?,?)",
).run(now, now, now);
db.prepare(
"INSERT INTO job_attempts (id,job_id,status,started_at,created_at) VALUES ('a','j','running',?,?)",
).run(now, now);
db.prepare(
"INSERT INTO job_items (id,job_id,instance_id,attempt_number,status,created_at,started_at,updated_at) VALUES ('i','j',?,1,'running',?,?,?)",
).run(instance.id, now, now, now);
expect(operation.reconcileInterruptedJobs()).toBe(1);
expect(db.prepare("SELECT status FROM jobs WHERE id='j'").get()).toEqual({
status: 'unknown-result',
});
expect(db.prepare("SELECT status FROM job_items WHERE id='i'").get()).toEqual({
status: 'unknown-result',
});
expect(db.prepare("SELECT status FROM job_attempts WHERE id='a'").get()).toEqual({
status: 'unknown-result',
});
});
it('does not delete when durable Job history cannot be created at execution entry', async () => {
const { db, instances, operation } = fixture();
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: [] },
});
db.exec(
"CREATE TRIGGER reject_delete_job_item BEFORE INSERT ON job_items BEGIN SELECT RAISE(ABORT, 'blocked'); END",
);
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-1',
}),
).rejects.toThrow();
expect(await instances.get(instance.id)).toBeDefined();
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
});
it('rejects an expired preparation once and never creates a Job', async () => {
const { db, instances } = fixture();
let current = new Date('2026-07-16T12:00:00.000Z');
let sequence = 100;
const operation = new DeleteInstanceOperation({
db,
instances,
now: () => current,
idFactory: () => `expiry-${++sequence}`,
tokenFactory: () => Buffer.alloc(32, 9).toString('base64url'),
});
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: [] },
});
current = new Date('2026-07-16T12:05:00.001Z');
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-expired',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
await expect(
operation.executeDelete({
instanceId: instance.id,
revision: 1,
preparationId: prepared.id,
confirmationToken: prepared.confirmationToken,
actor: 'loopback-control-plane',
requestId: 'request-replay',
}),
).rejects.toMatchObject({ code: 'CONFIRMATION_INVALID' });
expect(db.prepare('SELECT COUNT(*) count FROM jobs').get()).toEqual({ count: 0 });
});
});