feat(api): complete phase 2.4 control plane
This commit is contained in:
@@ -64,9 +64,12 @@ describe('database migrations', () => {
|
||||
'capabilities',
|
||||
'instance_tags',
|
||||
'instances',
|
||||
'job_attempts',
|
||||
'job_items',
|
||||
'jobs',
|
||||
'operation_preparations',
|
||||
'schema_migrations',
|
||||
'secret_cleanup_tasks',
|
||||
'secret_references',
|
||||
'status_snapshots',
|
||||
]);
|
||||
@@ -91,7 +94,18 @@ describe('database migrations', () => {
|
||||
expect(foreignKeys).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: 'job_id', on_delete: 'CASCADE', table: 'jobs' }),
|
||||
expect.objectContaining({ from: 'instance_id', on_delete: 'RESTRICT', table: 'instances' }),
|
||||
]),
|
||||
);
|
||||
expect(foreignKeys).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ from: 'instance_id', table: 'instances' }),
|
||||
]),
|
||||
);
|
||||
const jobColumns = database.pragma('table_info(jobs)') as Array<{ name: string }>;
|
||||
expect(jobColumns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'root_job_id' }),
|
||||
expect.objectContaining({ name: 'retry_of_job_id' }),
|
||||
]),
|
||||
);
|
||||
const indexes = database
|
||||
@@ -107,6 +121,103 @@ describe('database migrations', () => {
|
||||
'idx_status_snapshots_instance_observed_at',
|
||||
]),
|
||||
);
|
||||
const now = '2026-07-16T00:00:00.000Z';
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run('cleanup-owner', 'Cleanup owner', 'http://10.0.0.9', 'password', 1, 1, now, now);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
'ref-one',
|
||||
'cleanup-owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
'opaque-one',
|
||||
now,
|
||||
now,
|
||||
);
|
||||
expect(() =>
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run(
|
||||
'ref-two',
|
||||
'cleanup-owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
'opaque-two',
|
||||
now,
|
||||
now,
|
||||
),
|
||||
).toThrow(/unique/i);
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,queued_at,updated_at) VALUES (?,?,?,?,?,?)',
|
||||
)
|
||||
.run('opaque-one', 'cleanup-owner', 'instance-password', 'macos-keychain', now, now);
|
||||
database.prepare('DELETE FROM instances WHERE id=?').run('cleanup-owner');
|
||||
expect(database.prepare('SELECT reference FROM secret_cleanup_tasks').all()).toEqual([
|
||||
{ reference: 'opaque-one' },
|
||||
]);
|
||||
database.close();
|
||||
});
|
||||
|
||||
it('upgrades duplicate v1 secret references by retaining one canonical row and queuing the rest', async () => {
|
||||
const directory = await temporaryDirectory();
|
||||
const database = openDatabase(join(directory, 'app.sqlite'));
|
||||
migrateDatabase(database, [MIGRATIONS[0]!]);
|
||||
const older = '2026-07-15T00:00:00.000Z';
|
||||
const newer = '2026-07-16T00:00:00.000Z';
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO instances (id,name,base_url,auth_mode,enabled,config_revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
.run('owner', 'Owner', 'http://10.0.0.8', 'password', 1, 1, older, newer);
|
||||
const reference = (slot: string) =>
|
||||
`keychain://multi-simadmin/${Buffer.from(JSON.stringify(['owner', 'instance-password', slot]), 'utf8').toString('base64url')}`;
|
||||
const insert = database.prepare(
|
||||
'INSERT INTO secret_references (id,instance_id,purpose,provider,external_reference,created_at,updated_at) VALUES (?,?,?,?,?,?,?)',
|
||||
);
|
||||
insert.run(
|
||||
'old-ref',
|
||||
'owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
reference('old-slot'),
|
||||
older,
|
||||
older,
|
||||
);
|
||||
insert.run(
|
||||
'new-ref',
|
||||
'owner',
|
||||
'instance-password',
|
||||
'macos-keychain',
|
||||
reference('new-slot'),
|
||||
newer,
|
||||
newer,
|
||||
);
|
||||
|
||||
migrateDatabase(database);
|
||||
|
||||
expect(database.prepare('SELECT id FROM secret_references').all()).toEqual([{ id: 'new-ref' }]);
|
||||
expect(
|
||||
database
|
||||
.prepare('SELECT reference,instance_id,purpose,provider FROM secret_cleanup_tasks')
|
||||
.all(),
|
||||
).toEqual([
|
||||
{
|
||||
reference: reference('old-slot'),
|
||||
instance_id: 'owner',
|
||||
purpose: 'instance-password',
|
||||
provider: 'macos-keychain',
|
||||
},
|
||||
]);
|
||||
expect(() => migrateDatabase(database)).not.toThrow();
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -113,6 +113,121 @@ export const MIGRATIONS: readonly Migration[] = [
|
||||
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'secret-cleanup-task-outbox',
|
||||
statements: [
|
||||
`CREATE TABLE secret_cleanup_tasks (
|
||||
reference TEXT PRIMARY KEY,
|
||||
instance_id TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0),
|
||||
queued_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`INSERT INTO secret_cleanup_tasks (reference,instance_id,purpose,provider,generation,queued_at,updated_at)
|
||||
SELECT external_reference,instance_id,purpose,provider,1,updated_at,updated_at
|
||||
FROM (
|
||||
SELECT external_reference,instance_id,purpose,provider,updated_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY instance_id,purpose
|
||||
ORDER BY updated_at DESC,created_at DESC,id DESC
|
||||
) AS row_number
|
||||
FROM secret_references
|
||||
WHERE instance_id IS NOT NULL
|
||||
) ranked
|
||||
WHERE row_number > 1`,
|
||||
`DELETE FROM secret_references
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY instance_id,purpose
|
||||
ORDER BY updated_at DESC,created_at DESC,id DESC
|
||||
) AS row_number
|
||||
FROM secret_references
|
||||
WHERE instance_id IS NOT NULL
|
||||
) ranked
|
||||
WHERE row_number > 1
|
||||
)`,
|
||||
'CREATE UNIQUE INDEX uq_secret_references_instance_purpose ON secret_references(instance_id, purpose) WHERE instance_id IS NOT NULL',
|
||||
'CREATE INDEX idx_secret_cleanup_tasks_queued_at ON secret_cleanup_tasks(queued_at)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'current-status-snapshot-lifecycle',
|
||||
statements: [
|
||||
`DELETE FROM status_snapshots
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY instance_id, category
|
||||
ORDER BY observed_at DESC, created_at DESC, id DESC
|
||||
) AS row_number
|
||||
FROM status_snapshots
|
||||
) ranked
|
||||
WHERE row_number > 1
|
||||
)`,
|
||||
'CREATE UNIQUE INDEX uq_status_snapshots_instance_category ON status_snapshots(instance_id, category)',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'r3-preparations-and-durable-job-lineage',
|
||||
statements: [
|
||||
'ALTER TABLE jobs ADD COLUMN root_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
|
||||
'ALTER TABLE jobs ADD COLUMN retry_of_job_id TEXT REFERENCES jobs(id) ON DELETE RESTRICT',
|
||||
'UPDATE jobs SET root_job_id=id WHERE root_job_id IS NULL',
|
||||
`CREATE TABLE job_items_v4 (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
instance_id TEXT NOT NULL,
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number > 0),
|
||||
status TEXT NOT NULL,
|
||||
result_code TEXT,
|
||||
error_json TEXT,
|
||||
source_job_item_id TEXT REFERENCES job_items_v4(id) ON DELETE RESTRICT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (job_id, instance_id, attempt_number)
|
||||
)`,
|
||||
`INSERT INTO job_items_v4 (id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at)
|
||||
SELECT id,job_id,instance_id,attempt_number,status,result_code,created_at,started_at,finished_at,updated_at FROM job_items`,
|
||||
'DROP TABLE job_items',
|
||||
'ALTER TABLE job_items_v4 RENAME TO job_items',
|
||||
'CREATE INDEX idx_job_items_job_status ON job_items(job_id, status)',
|
||||
`CREATE TABLE job_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (status IN ('running','succeeded','failed','cancelled','unknown-result')),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
'CREATE INDEX idx_job_attempts_job_started_at ON job_attempts(job_id, started_at)',
|
||||
`CREATE TABLE operation_preparations (
|
||||
id TEXT PRIMARY KEY,
|
||||
operation_id TEXT NOT NULL CHECK (operation_id = 'deleteInstance'),
|
||||
risk_level TEXT NOT NULL CHECK (risk_level = 'R3'),
|
||||
status TEXT NOT NULL CHECK (status IN ('prepared','consumed','expired','invalidated')),
|
||||
target_instance_id TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL CHECK (target_revision > 0),
|
||||
parameter_schema_id TEXT NOT NULL,
|
||||
parameters_digest TEXT NOT NULL,
|
||||
token_digest TEXT NOT NULL CHECK (length(token_digest) = 64),
|
||||
requested_by TEXT NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
'CREATE INDEX idx_operation_preparations_status_expires_at ON operation_preparations(status, expires_at)',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const createMigrationsTable = `CREATE TABLE schema_migrations (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sqliteTable,
|
||||
text,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
|
||||
const timestamps = {
|
||||
@@ -88,6 +89,7 @@ export const statusSnapshots = sqliteTable(
|
||||
sql`${table.state} in ('fresh', 'stale', 'expired', 'unknown')`,
|
||||
),
|
||||
index('idx_status_snapshots_instance_observed_at').on(table.instanceId, desc(table.observedAt)),
|
||||
uniqueIndex('uq_status_snapshots_instance_category').on(table.instanceId, table.category),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -98,6 +100,12 @@ export const jobs = sqliteTable(
|
||||
parentJobId: text('parent_job_id').references((): AnySQLiteColumn => jobs.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
rootJobId: text('root_job_id').references((): AnySQLiteColumn => jobs.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
retryOfJobId: text('retry_of_job_id').references((): AnySQLiteColumn => jobs.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
operationId: text('operation_id').notNull(),
|
||||
riskLevel: text('risk_level').notNull(),
|
||||
status: text('status').notNull(),
|
||||
@@ -122,12 +130,14 @@ export const jobItems = sqliteTable(
|
||||
jobId: text('job_id')
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: 'cascade' }),
|
||||
instanceId: text('instance_id')
|
||||
.notNull()
|
||||
.references(() => instances.id, { onDelete: 'restrict' }),
|
||||
instanceId: text('instance_id').notNull(),
|
||||
attemptNumber: integer('attempt_number').notNull().default(1),
|
||||
status: text('status').notNull(),
|
||||
resultCode: text('result_code'),
|
||||
errorJson: text('error_json'),
|
||||
sourceJobItemId: text('source_job_item_id').references((): AnySQLiteColumn => jobItems.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
createdAt: text('created_at').notNull(),
|
||||
startedAt: text('started_at'),
|
||||
finishedAt: text('finished_at'),
|
||||
@@ -144,6 +154,44 @@ export const jobItems = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const jobAttempts = sqliteTable(
|
||||
'job_attempts',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
jobId: text('job_id')
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: 'cascade' }),
|
||||
status: text('status').notNull(),
|
||||
startedAt: text('started_at').notNull(),
|
||||
finishedAt: text('finished_at'),
|
||||
createdAt: text('created_at').notNull(),
|
||||
},
|
||||
(table) => [index('idx_job_attempts_job_started_at').on(table.jobId, table.startedAt)],
|
||||
);
|
||||
|
||||
export const operationPreparations = sqliteTable(
|
||||
'operation_preparations',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
operationId: text('operation_id').notNull(),
|
||||
riskLevel: text('risk_level').notNull(),
|
||||
status: text('status').notNull(),
|
||||
targetInstanceId: text('target_instance_id').notNull(),
|
||||
targetRevision: integer('target_revision').notNull(),
|
||||
parameterSchemaId: text('parameter_schema_id').notNull(),
|
||||
parametersDigest: text('parameters_digest').notNull(),
|
||||
tokenDigest: text('token_digest').notNull(),
|
||||
requestedBy: text('requested_by').notNull(),
|
||||
requestId: text('request_id').notNull(),
|
||||
expiresAt: text('expires_at').notNull(),
|
||||
consumedAt: text('consumed_at'),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [
|
||||
index('idx_operation_preparations_status_expires_at').on(table.status, table.expiresAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const auditEvents = sqliteTable(
|
||||
'audit_events',
|
||||
{
|
||||
@@ -188,5 +236,22 @@ export const secretReferences = sqliteTable(
|
||||
table.provider,
|
||||
table.externalReference,
|
||||
),
|
||||
uniqueIndex('uq_secret_references_instance_purpose')
|
||||
.on(table.instanceId, table.purpose)
|
||||
.where(sql`${table.instanceId} is not null`),
|
||||
],
|
||||
);
|
||||
|
||||
export const secretCleanupTasks = sqliteTable(
|
||||
'secret_cleanup_tasks',
|
||||
{
|
||||
reference: text('reference').primaryKey(),
|
||||
instanceId: text('instance_id').notNull(),
|
||||
purpose: text('purpose').notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
generation: integer('generation').notNull().default(1),
|
||||
queuedAt: text('queued_at').notNull(),
|
||||
updatedAt: text('updated_at').notNull(),
|
||||
},
|
||||
(table) => [index('idx_secret_cleanup_tasks_queued_at').on(table.queuedAt)],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user