@shipfox/api-runners 10.0.0 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +28 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -13
- package/dist/config.js.map +1 -1
- package/dist/core/admin-runner-instances.d.ts +34 -0
- package/dist/core/admin-runner-instances.d.ts.map +1 -0
- package/dist/core/admin-runner-instances.js +58 -0
- package/dist/core/admin-runner-instances.js.map +1 -0
- package/dist/core/errors.d.ts +6 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +12 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +3 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/provisioner-tokens.d.ts +33 -0
- package/dist/core/provisioner-tokens.d.ts.map +1 -1
- package/dist/core/provisioner-tokens.js +107 -2
- package/dist/core/provisioner-tokens.js.map +1 -1
- package/dist/db/admin-runner-instances.d.ts +34 -0
- package/dist/db/admin-runner-instances.d.ts.map +1 -0
- package/dist/db/admin-runner-instances.js +62 -0
- package/dist/db/admin-runner-instances.js.map +1 -0
- package/dist/db/db.d.ts +290 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +2 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/index.d.ts +3 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +2 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +11 -0
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/provisioner-tokens.d.ts +38 -0
- package/dist/db/provisioner-tokens.d.ts.map +1 -1
- package/dist/db/provisioner-tokens.js +157 -3
- package/dist/db/provisioner-tokens.js.map +1 -1
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +60 -8
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +134 -0
- package/dist/db/schema/admin-command-results.d.ts.map +1 -0
- package/dist/db/schema/admin-command-results.js +18 -0
- package/dist/db/schema/admin-command-results.js.map +1 -0
- package/dist/db/schema/runner-instances.d.ts +17 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +3 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/admin-provisioner-token.d.ts +16 -0
- package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -0
- package/dist/presentation/dto/admin-provisioner-token.js +22 -0
- package/dist/presentation/dto/admin-provisioner-token.js.map +1 -0
- package/dist/presentation/dto/admin-runner-instances.d.ts +23 -0
- package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -0
- package/dist/presentation/dto/admin-runner-instances.js +19 -0
- package/dist/presentation/dto/admin-runner-instances.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -1
- package/dist/presentation/dto/index.js +2 -0
- package/dist/presentation/dto/index.js.map +1 -1
- package/dist/presentation/routes/admin-provisioner-tokens.d.ts +4 -0
- package/dist/presentation/routes/admin-provisioner-tokens.d.ts.map +1 -0
- package/dist/presentation/routes/admin-provisioner-tokens.js +167 -0
- package/dist/presentation/routes/admin-provisioner-tokens.js.map +1 -0
- package/dist/presentation/routes/admin-runner-instances.d.ts +3 -0
- package/dist/presentation/routes/admin-runner-instances.d.ts.map +1 -0
- package/dist/presentation/routes/admin-runner-instances.js +63 -0
- package/dist/presentation/routes/admin-runner-instances.js.map +1 -0
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +11 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +1 -0
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +11 -4
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0002_special_shadowcat.sql +1 -0
- package/drizzle/0003_flaky_sally_floyd.sql +11 -0
- package/drizzle/meta/0002_snapshot.json +2099 -0
- package/drizzle/meta/0003_snapshot.json +2178 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +10 -9
- package/src/config.test.ts +11 -0
- package/src/config.ts +19 -8
- package/src/core/admin-runner-instances.ts +125 -0
- package/src/core/errors.ts +14 -0
- package/src/core/index.ts +12 -0
- package/src/core/provisioner-tokens.ts +157 -1
- package/src/db/admin-runner-instances.ts +121 -0
- package/src/db/db.ts +2 -0
- package/src/db/index.ts +10 -0
- package/src/db/job-executions.test.ts +17 -0
- package/src/db/job-executions.ts +22 -0
- package/src/db/provisioner-tokens.ts +263 -9
- package/src/db/reservations.test.ts +253 -0
- package/src/db/reservations.ts +170 -15
- package/src/db/schema/admin-command-results.ts +29 -0
- package/src/db/schema/runner-instances.ts +1 -0
- package/src/index.test.ts +1 -1
- package/src/index.ts +6 -1
- package/src/presentation/dto/admin-provisioner-token.ts +23 -0
- package/src/presentation/dto/admin-runner-instances.ts +19 -0
- package/src/presentation/dto/index.ts +2 -0
- package/src/presentation/routes/admin-provisioner-tokens.test.ts +227 -0
- package/src/presentation/routes/admin-provisioner-tokens.ts +177 -0
- package/src/presentation/routes/admin-runner-instances.test.ts +671 -0
- package/src/presentation/routes/admin-runner-instances.ts +67 -0
- package/src/presentation/routes/index.ts +8 -0
- package/src/presentation/routes/runner-enrollment.test.ts +60 -2
- package/src/presentation/routes/runner-enrollment.ts +24 -4
- package/test/globalSetup.ts +1 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/db/reservations.ts
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
1
|
import {canonicalizeLabels} from '@shipfox/runner-labels';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
and,
|
|
4
|
+
arrayContains,
|
|
5
|
+
asc,
|
|
6
|
+
eq,
|
|
7
|
+
exists,
|
|
8
|
+
gt,
|
|
9
|
+
inArray,
|
|
10
|
+
isNotNull,
|
|
11
|
+
isNull,
|
|
12
|
+
lt,
|
|
13
|
+
sql,
|
|
14
|
+
} from 'drizzle-orm';
|
|
3
15
|
import type {Tx} from './db.js';
|
|
4
16
|
import {db} from './db.js';
|
|
5
17
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
6
18
|
import {provisionerCapabilitySnapshots} from './schema/provisioner-capability-snapshots.js';
|
|
7
19
|
import {provisionerTokens} from './schema/provisioner-tokens.js';
|
|
8
20
|
import {reservations} from './schema/reservations.js';
|
|
21
|
+
import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
|
|
22
|
+
import {runnerControlSessions} from './schema/runner-control-sessions.js';
|
|
23
|
+
import {providerRunners} from './schema/runner-instances.js';
|
|
9
24
|
|
|
10
25
|
export interface ReservationTemplate {
|
|
11
26
|
templateKey: string;
|
|
@@ -243,6 +258,13 @@ async function pollDemandAndReserveLockedTx(
|
|
|
243
258
|
|
|
244
259
|
remainingMaxReservations -= grant;
|
|
245
260
|
drawSlots(satisfyingTemplates, grant);
|
|
261
|
+
await bindIdleRunnerInstancesTx(tx, {
|
|
262
|
+
provisionerId: params.provisionerId,
|
|
263
|
+
reservationId: inserted.id,
|
|
264
|
+
requiredLabels: demand.requiredLabels,
|
|
265
|
+
count: grant,
|
|
266
|
+
workspaceId: params.workspaceId,
|
|
267
|
+
});
|
|
246
268
|
reservedAfterGrant += grant;
|
|
247
269
|
grants.push({
|
|
248
270
|
reservationId: inserted.id,
|
|
@@ -265,6 +287,71 @@ async function pollDemandAndReserveLockedTx(
|
|
|
265
287
|
return {stats, reservations: grants};
|
|
266
288
|
}
|
|
267
289
|
|
|
290
|
+
async function bindIdleRunnerInstancesTx(
|
|
291
|
+
tx: Tx,
|
|
292
|
+
params: {
|
|
293
|
+
provisionerId: string;
|
|
294
|
+
reservationId: string;
|
|
295
|
+
workspaceId: string;
|
|
296
|
+
requiredLabels: string[];
|
|
297
|
+
count: number;
|
|
298
|
+
},
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
const idleRunners = await tx
|
|
301
|
+
.select({id: providerRunners.id})
|
|
302
|
+
.from(providerRunners)
|
|
303
|
+
.where(
|
|
304
|
+
and(
|
|
305
|
+
eq(providerRunners.provisionerId, params.provisionerId),
|
|
306
|
+
isNull(providerRunners.workspaceId),
|
|
307
|
+
isNull(providerRunners.reservationId),
|
|
308
|
+
isNull(providerRunners.intendedReservationId),
|
|
309
|
+
isNull(providerRunners.runnerSessionId),
|
|
310
|
+
isNotNull(providerRunners.providerRunnerId),
|
|
311
|
+
eq(providerRunners.state, 'running'),
|
|
312
|
+
arrayContains(providerRunners.labels, params.requiredLabels),
|
|
313
|
+
exists(
|
|
314
|
+
tx
|
|
315
|
+
.select({id: runnerControlSessions.id})
|
|
316
|
+
.from(runnerControlSessions)
|
|
317
|
+
.where(
|
|
318
|
+
and(
|
|
319
|
+
eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
|
|
320
|
+
eq(runnerControlSessions.provisionerId, params.provisionerId),
|
|
321
|
+
isNull(runnerControlSessions.closedAt),
|
|
322
|
+
gt(runnerControlSessions.expiresAt, sql`now()`),
|
|
323
|
+
),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
.orderBy(asc(providerRunners.createdAt), asc(providerRunners.id))
|
|
329
|
+
.limit(params.count)
|
|
330
|
+
.for('update');
|
|
331
|
+
|
|
332
|
+
if (idleRunners.length === 0) return;
|
|
333
|
+
|
|
334
|
+
await tx
|
|
335
|
+
.update(providerRunners)
|
|
336
|
+
.set({
|
|
337
|
+
workspaceId: params.workspaceId,
|
|
338
|
+
reservationId: params.reservationId,
|
|
339
|
+
assignedAt: sql`now()`,
|
|
340
|
+
updatedAt: sql`now()`,
|
|
341
|
+
})
|
|
342
|
+
.where(
|
|
343
|
+
and(
|
|
344
|
+
inArray(
|
|
345
|
+
providerRunners.id,
|
|
346
|
+
idleRunners.map((runner) => runner.id),
|
|
347
|
+
),
|
|
348
|
+
isNull(providerRunners.workspaceId),
|
|
349
|
+
isNull(providerRunners.reservationId),
|
|
350
|
+
isNull(providerRunners.runnerSessionId),
|
|
351
|
+
),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
268
355
|
async function listInstallationDemandWorkspaceIds(eligibleWorkspaceIds: ReadonlySet<string>) {
|
|
269
356
|
if (eligibleWorkspaceIds.size === 0) return [];
|
|
270
357
|
const rows = await db()
|
|
@@ -315,27 +402,95 @@ async function listActiveWorkspaceCapabilityLabelsTx(
|
|
|
315
402
|
}
|
|
316
403
|
|
|
317
404
|
export async function deleteExpiredReservations(params?: {limit?: number}): Promise<number> {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
405
|
+
return await db().transaction(async (tx) => {
|
|
406
|
+
const expiredRows = await tx
|
|
407
|
+
.select({id: reservations.id})
|
|
408
|
+
.from(reservations)
|
|
409
|
+
.where(lt(reservations.expiresAt, sql`now()`))
|
|
410
|
+
.orderBy(asc(reservations.expiresAt))
|
|
411
|
+
.limit(params?.limit ?? 1000);
|
|
412
|
+
return await deleteReservationsWithCleanupTx(
|
|
413
|
+
tx,
|
|
414
|
+
expiredRows.map((reservation) => reservation.id),
|
|
415
|
+
{expiredOnly: true},
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
324
419
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
.where(inArray(reservations.id, expiredIds))
|
|
328
|
-
.returning({id: reservations.id});
|
|
420
|
+
export async function deleteReservationsByIds(ids: string[]): Promise<number> {
|
|
421
|
+
if (ids.length === 0) return 0;
|
|
329
422
|
|
|
330
|
-
return
|
|
423
|
+
return await db().transaction(async (tx) => {
|
|
424
|
+
return await deleteReservationsWithCleanupTx(tx, ids);
|
|
425
|
+
});
|
|
331
426
|
}
|
|
332
427
|
|
|
333
|
-
|
|
428
|
+
async function deleteReservationsWithCleanupTx(
|
|
429
|
+
tx: Tx,
|
|
430
|
+
ids: string[],
|
|
431
|
+
params: {expiredOnly?: boolean} = {},
|
|
432
|
+
): Promise<number> {
|
|
334
433
|
if (ids.length === 0) return 0;
|
|
335
434
|
|
|
336
|
-
const
|
|
435
|
+
const assignedRunners = await tx
|
|
436
|
+
.select({id: providerRunners.id, reservationId: providerRunners.reservationId})
|
|
437
|
+
.from(providerRunners)
|
|
438
|
+
.where(
|
|
439
|
+
and(
|
|
440
|
+
inArray(providerRunners.reservationId, ids),
|
|
441
|
+
isNull(providerRunners.runnerSessionId),
|
|
442
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
.for('update');
|
|
446
|
+
const reservationRows = await tx
|
|
447
|
+
.select({id: reservations.id})
|
|
448
|
+
.from(reservations)
|
|
449
|
+
.where(
|
|
450
|
+
and(
|
|
451
|
+
inArray(reservations.id, ids),
|
|
452
|
+
params.expiredOnly ? lt(reservations.expiresAt, sql`now()`) : undefined,
|
|
453
|
+
),
|
|
454
|
+
)
|
|
455
|
+
.for('update');
|
|
456
|
+
const reservationIds = reservationRows.map((reservation) => reservation.id);
|
|
457
|
+
|
|
458
|
+
if (reservationIds.length === 0) return 0;
|
|
459
|
+
|
|
460
|
+
const assignedRunnerIds = assignedRunners
|
|
461
|
+
.filter((runner) => runner.reservationId && reservationIds.includes(runner.reservationId))
|
|
462
|
+
.map((runner) => runner.id);
|
|
463
|
+
if (assignedRunnerIds.length > 0) {
|
|
464
|
+
await tx
|
|
465
|
+
.update(runnerActivationTokens)
|
|
466
|
+
.set({revokedAt: sql`now()`})
|
|
467
|
+
.where(
|
|
468
|
+
and(
|
|
469
|
+
inArray(runnerActivationTokens.runnerInstanceId, assignedRunnerIds),
|
|
470
|
+
isNull(runnerActivationTokens.consumedAt),
|
|
471
|
+
isNull(runnerActivationTokens.revokedAt),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
await tx
|
|
475
|
+
.update(providerRunners)
|
|
476
|
+
.set({
|
|
477
|
+
workspaceId: null,
|
|
478
|
+
reservationId: null,
|
|
479
|
+
assignedAt: null,
|
|
480
|
+
updatedAt: sql`now()`,
|
|
481
|
+
})
|
|
482
|
+
.where(
|
|
483
|
+
and(
|
|
484
|
+
inArray(providerRunners.id, assignedRunnerIds),
|
|
485
|
+
isNull(providerRunners.runnerSessionId),
|
|
486
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
487
|
+
),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const deleted = await tx
|
|
337
492
|
.delete(reservations)
|
|
338
|
-
.where(inArray(reservations.id,
|
|
493
|
+
.where(inArray(reservations.id, reservationIds))
|
|
339
494
|
.returning({id: reservations.id});
|
|
340
495
|
|
|
341
496
|
return deleted.length;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import {pgTable} from './common.js';
|
|
4
|
+
|
|
5
|
+
export interface StoredProvisionerTokenAdministrationResult {
|
|
6
|
+
provisionerTokenId: string;
|
|
7
|
+
correlationId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const runnersAdminCommandResults = pgTable(
|
|
11
|
+
'admin_command_results',
|
|
12
|
+
{
|
|
13
|
+
id: uuidv7PrimaryKey(),
|
|
14
|
+
actorId: uuid('actor_id').notNull(),
|
|
15
|
+
idempotencyKeyFingerprint: text('idempotency_key_fingerprint').notNull(),
|
|
16
|
+
command: text('command').notNull(),
|
|
17
|
+
requestFingerprint: text('request_fingerprint').notNull(),
|
|
18
|
+
result: jsonb('result').$type<StoredProvisionerTokenAdministrationResult>().notNull(),
|
|
19
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
20
|
+
},
|
|
21
|
+
(table) => [
|
|
22
|
+
uniqueIndex('runners_admin_command_results_actor_key_unique').on(
|
|
23
|
+
table.actorId,
|
|
24
|
+
table.idempotencyKeyFingerprint,
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export type RunnersAdminCommandResultDb = typeof runnersAdminCommandResults.$inferSelect;
|
|
@@ -31,6 +31,7 @@ export const providerRunners = pgTable(
|
|
|
31
31
|
state: providerRunnerStateEnum('state').notNull(),
|
|
32
32
|
reason: text('reason'),
|
|
33
33
|
runnerSessionId: uuid('runner_session_id'),
|
|
34
|
+
firstClaimedAt: timestamp('first_claimed_at', {withTimezone: true}),
|
|
34
35
|
providerKind: text('provider_kind'),
|
|
35
36
|
protocolVersion: text('protocol_version'),
|
|
36
37
|
capabilities: jsonb('capabilities').$type<RunnerToolCapabilitiesDto | null>(),
|
package/src/index.test.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {dirname, resolve} from 'node:path';
|
|
2
2
|
import {fileURLToPath} from 'node:url';
|
|
3
3
|
import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
|
|
4
|
+
import {administrationActionEventSchemas} from '@shipfox/api-common-dto';
|
|
4
5
|
import {runnersEventSchemas} from '@shipfox/api-runners-dto';
|
|
5
6
|
import {
|
|
6
7
|
WORKFLOWS_JOB_EXECUTION_TIMED_OUT,
|
|
@@ -21,6 +22,8 @@ import {createRunnersInterModulePresentation} from '#presentation/inter-module.j
|
|
|
21
22
|
import {createRunnersMaintenanceActivities} from '#temporal/activities/index.js';
|
|
22
23
|
import {RUNNERS_MAINTENANCE_TASK_QUEUE} from '#temporal/constants.js';
|
|
23
24
|
|
|
25
|
+
const runnersPublisherEventSchemas = {...runnersEventSchemas, ...administrationActionEventSchemas};
|
|
26
|
+
|
|
24
27
|
export {
|
|
25
28
|
type EffectiveRunnerToolCapabilitiesResult,
|
|
26
29
|
getEffectiveRunnerToolCapabilities,
|
|
@@ -58,7 +61,9 @@ export function createRunnersModule({
|
|
|
58
61
|
],
|
|
59
62
|
routes: createRunnerRoutes(auth, options),
|
|
60
63
|
metrics: registerRunnersServiceMetrics,
|
|
61
|
-
publishers: [
|
|
64
|
+
publishers: [
|
|
65
|
+
{name: 'runners', table: runnersOutbox, db, eventSchemas: runnersPublisherEventSchemas},
|
|
66
|
+
],
|
|
62
67
|
subscribers: [subscriber(WORKFLOWS_JOB_EXECUTION_TIMED_OUT, onWorkflowsJobExecutionTimedOut)],
|
|
63
68
|
workers: [
|
|
64
69
|
{
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {ProvisionerToken} from '#core/entities/provisioner-token.js';
|
|
2
|
+
import {installationProvisionerTokenStatus} from '#core/provisioner-tokens.js';
|
|
3
|
+
|
|
4
|
+
export function toAdministratorProvisionerTokenDto(token: ProvisionerToken) {
|
|
5
|
+
if (token.scope !== 'installation') {
|
|
6
|
+
throw new Error('Workspace provisioner tokens cannot be returned from administrator routes');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
id: token.id,
|
|
11
|
+
scope: 'installation' as const,
|
|
12
|
+
prefix: token.prefix,
|
|
13
|
+
name: token.name,
|
|
14
|
+
status: installationProvisionerTokenStatus(token),
|
|
15
|
+
created_by_user_id: token.createdByUserId,
|
|
16
|
+
revoked_by_user_id: token.revokedByUserId,
|
|
17
|
+
expires_at: token.expiresAt?.toISOString() ?? null,
|
|
18
|
+
revoked_at: token.revokedAt?.toISOString() ?? null,
|
|
19
|
+
last_seen_at: token.lastSeenAt?.toISOString() ?? null,
|
|
20
|
+
created_at: token.createdAt.toISOString(),
|
|
21
|
+
updated_at: token.updatedAt.toISOString(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type {RunnerAdministratorInstance} from '#core/admin-runner-instances.js';
|
|
2
|
+
|
|
3
|
+
export function toRunnerAdministratorInstanceDto(runner: RunnerAdministratorInstance) {
|
|
4
|
+
return {
|
|
5
|
+
id: runner.id,
|
|
6
|
+
lifecycle_state: runner.lifecycleState,
|
|
7
|
+
compute_state: runner.computeState,
|
|
8
|
+
enrollment_state: runner.enrollmentState,
|
|
9
|
+
assignment_presence: runner.assignmentPresence,
|
|
10
|
+
assigned_workspace: runner.assignedWorkspace,
|
|
11
|
+
labels: runner.labels,
|
|
12
|
+
created_at: runner.createdAt.toISOString(),
|
|
13
|
+
last_heartbeat_at: runner.lastHeartbeatAt.toISOString(),
|
|
14
|
+
closure_reason: runner.closureReason,
|
|
15
|
+
closed_at: runner.closedAt?.toISOString() ?? null,
|
|
16
|
+
provisioner: runner.provisioner,
|
|
17
|
+
reconciliation_status: runner.reconciliationStatus,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export {toActiveProvisionerDto} from './active-provisioner.js';
|
|
2
|
+
export {toAdministratorProvisionerTokenDto} from './admin-provisioner-token.js';
|
|
3
|
+
export {toRunnerAdministratorInstanceDto} from './admin-runner-instances.js';
|
|
2
4
|
export {toManualRegistrationTokenDto} from './manual-registration-token.js';
|
|
3
5
|
export {toPollDemandResponseDto} from './poll-demand.js';
|
|
4
6
|
export {toProvisionerTokenDto} from './provisioner-token.js';
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_LEASED_JOB,
|
|
3
|
+
AUTH_PROVISIONER_TOKEN,
|
|
4
|
+
AUTH_RUNNER_REGISTRATION_TOKEN,
|
|
5
|
+
AUTH_RUNNER_SESSION,
|
|
6
|
+
AUTH_USER,
|
|
7
|
+
buildUserContext,
|
|
8
|
+
setUserContext,
|
|
9
|
+
} from '@shipfox/api-auth-context';
|
|
10
|
+
import {
|
|
11
|
+
type AuthInterModuleClient,
|
|
12
|
+
authInterModuleContract,
|
|
13
|
+
} from '@shipfox/api-auth-dto/inter-module';
|
|
14
|
+
import {createInterModuleKnownError} from '@shipfox/inter-module';
|
|
15
|
+
import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
|
|
16
|
+
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
17
|
+
import {vi} from '@shipfox/vitest/vi';
|
|
18
|
+
import {eq} from 'drizzle-orm';
|
|
19
|
+
import type {FastifyInstance, FastifyRequest} from 'fastify';
|
|
20
|
+
import {db} from '#db/db.js';
|
|
21
|
+
import {runnersAdminCommandResults} from '#db/schema/admin-command-results.js';
|
|
22
|
+
import {runnersOutbox} from '#db/schema/outbox.js';
|
|
23
|
+
import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
|
|
24
|
+
import {provisionerTokenFactory, runnersTestAuthClient} from '#test/index.js';
|
|
25
|
+
import {createRunnerRoutes} from './index.js';
|
|
26
|
+
|
|
27
|
+
const USER_ID = '00000000-0000-4000-8000-000000000001';
|
|
28
|
+
|
|
29
|
+
const fakeUserAuth: AuthMethod = {
|
|
30
|
+
name: AUTH_USER,
|
|
31
|
+
authenticate: (request: FastifyRequest) => {
|
|
32
|
+
if (request.headers.authorization !== 'Bearer user') {
|
|
33
|
+
throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
|
|
34
|
+
}
|
|
35
|
+
setUserContext(
|
|
36
|
+
request,
|
|
37
|
+
buildUserContext({
|
|
38
|
+
userId: USER_ID,
|
|
39
|
+
email: 'admin@example.com',
|
|
40
|
+
name: 'Administrator',
|
|
41
|
+
memberships: [],
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
return Promise.resolve();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const passthroughAuth = (name: string): AuthMethod => ({
|
|
49
|
+
name,
|
|
50
|
+
authenticate: () => Promise.resolve(),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('administrator installation provisioner tokens', () => {
|
|
54
|
+
let app: FastifyInstance;
|
|
55
|
+
let auth: AuthInterModuleClient;
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
await closeApp();
|
|
59
|
+
auth = {
|
|
60
|
+
...runnersTestAuthClient,
|
|
61
|
+
requireAdminRole: vi.fn().mockResolvedValue({role: 'admin-owner'}),
|
|
62
|
+
};
|
|
63
|
+
app = await createApp({
|
|
64
|
+
auth: [
|
|
65
|
+
fakeUserAuth,
|
|
66
|
+
passthroughAuth(AUTH_RUNNER_REGISTRATION_TOKEN),
|
|
67
|
+
passthroughAuth(AUTH_RUNNER_SESSION),
|
|
68
|
+
passthroughAuth(AUTH_LEASED_JOB),
|
|
69
|
+
passthroughAuth(AUTH_PROVISIONER_TOKEN),
|
|
70
|
+
],
|
|
71
|
+
routes: createRunnerRoutes(auth),
|
|
72
|
+
swagger: false,
|
|
73
|
+
});
|
|
74
|
+
await app.ready();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
await db()
|
|
79
|
+
.delete(runnersAdminCommandResults)
|
|
80
|
+
.where(eq(runnersAdminCommandResults.actorId, USER_ID));
|
|
81
|
+
await db().delete(provisionerTokens).where(eq(provisionerTokens.createdByUserId, USER_ID));
|
|
82
|
+
await db()
|
|
83
|
+
.delete(runnersOutbox)
|
|
84
|
+
.where(eq(runnersOutbox.eventType, 'administration.action.performed'));
|
|
85
|
+
await closeApp();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('creates, lists, and revokes only installation metadata with idempotent retries', async () => {
|
|
89
|
+
const createKey = `create-${crypto.randomUUID()}`;
|
|
90
|
+
const createRequest = {
|
|
91
|
+
method: 'POST' as const,
|
|
92
|
+
url: '/admin/runners/provisioner-tokens',
|
|
93
|
+
headers: {authorization: 'Bearer user', 'idempotency-key': createKey},
|
|
94
|
+
payload: {name: 'Cloud managed runners', reason: 'Rotate the installation credential'},
|
|
95
|
+
};
|
|
96
|
+
const created = await app.inject(createRequest);
|
|
97
|
+
expect(created.statusCode).toBe(201);
|
|
98
|
+
const createdBody = created.json();
|
|
99
|
+
expect(createdBody).toMatchObject({
|
|
100
|
+
scope: 'installation',
|
|
101
|
+
name: 'Cloud managed runners',
|
|
102
|
+
status: 'active',
|
|
103
|
+
raw_token: expect.any(String),
|
|
104
|
+
correlation_id: expect.any(String),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const retry = await app.inject(createRequest);
|
|
108
|
+
expect(retry.statusCode).toBe(201);
|
|
109
|
+
expect(retry.json()).toMatchObject({
|
|
110
|
+
id: createdBody.id,
|
|
111
|
+
raw_token: createdBody.raw_token,
|
|
112
|
+
correlation_id: createdBody.correlation_id,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const rows = await db().select().from(runnersAdminCommandResults);
|
|
116
|
+
expect(rows).toHaveLength(1);
|
|
117
|
+
expect(JSON.stringify(rows[0]?.result)).not.toContain(createdBody.raw_token);
|
|
118
|
+
expect(JSON.stringify(rows[0]?.result)).not.toContain(hashOpaqueToken(createdBody.raw_token));
|
|
119
|
+
|
|
120
|
+
const workspaceToken = await provisionerTokenFactory.create({
|
|
121
|
+
workspaceId: crypto.randomUUID(),
|
|
122
|
+
createdByUserId: USER_ID,
|
|
123
|
+
});
|
|
124
|
+
const list = await app.inject({
|
|
125
|
+
method: 'GET',
|
|
126
|
+
url: '/admin/runners/provisioner-tokens?status=active',
|
|
127
|
+
headers: {authorization: 'Bearer user'},
|
|
128
|
+
});
|
|
129
|
+
expect(list.statusCode).toBe(200);
|
|
130
|
+
const listedToken = list
|
|
131
|
+
.json()
|
|
132
|
+
.tokens.find((token: {id: string}) => token.id === createdBody.id);
|
|
133
|
+
expect(listedToken).toMatchObject({id: createdBody.id, scope: 'installation'});
|
|
134
|
+
expect(listedToken).not.toHaveProperty('raw_token');
|
|
135
|
+
expect(listedToken).not.toHaveProperty('hashed_token');
|
|
136
|
+
expect(list.json().tokens.map((token: {id: string}) => token.id)).not.toContain(
|
|
137
|
+
workspaceToken.id,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
vi.mocked(auth.requireAdminRole).mockResolvedValue({role: 'admin-operator'});
|
|
141
|
+
const revokeKey = `revoke-${crypto.randomUUID()}`;
|
|
142
|
+
const revokeRequest = {
|
|
143
|
+
method: 'POST' as const,
|
|
144
|
+
url: `/admin/runners/provisioner-tokens/${createdBody.id}/revoke`,
|
|
145
|
+
headers: {authorization: 'Bearer user', 'idempotency-key': revokeKey},
|
|
146
|
+
payload: {reason: 'Replace the installation credential'},
|
|
147
|
+
};
|
|
148
|
+
const revoked = await app.inject(revokeRequest);
|
|
149
|
+
expect(revoked.statusCode).toBe(200);
|
|
150
|
+
expect(revoked.json()).toMatchObject({id: createdBody.id, status: 'revoked'});
|
|
151
|
+
expect(revoked.json()).not.toHaveProperty('raw_token');
|
|
152
|
+
|
|
153
|
+
const revokeRetry = await app.inject(revokeRequest);
|
|
154
|
+
expect(revokeRetry.statusCode).toBe(200);
|
|
155
|
+
expect(revokeRetry.json()).toMatchObject({
|
|
156
|
+
id: createdBody.id,
|
|
157
|
+
correlation_id: revoked.json().correlation_id,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const noOpRevoke = await app.inject({
|
|
161
|
+
...revokeRequest,
|
|
162
|
+
headers: {...revokeRequest.headers, 'idempotency-key': `revoke-no-op-${crypto.randomUUID()}`},
|
|
163
|
+
});
|
|
164
|
+
expect(noOpRevoke.statusCode).toBe(200);
|
|
165
|
+
expect(noOpRevoke.json()).toMatchObject({
|
|
166
|
+
id: createdBody.id,
|
|
167
|
+
status: 'revoked',
|
|
168
|
+
revoked_by_user_id: USER_ID,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const events = (await db().select().from(runnersOutbox)).filter(
|
|
172
|
+
(event) => event.eventType === 'administration.action.performed',
|
|
173
|
+
);
|
|
174
|
+
expect(events).toHaveLength(2);
|
|
175
|
+
expect(events.map((event) => event.eventType)).toEqual([
|
|
176
|
+
'administration.action.performed',
|
|
177
|
+
'administration.action.performed',
|
|
178
|
+
]);
|
|
179
|
+
expect(JSON.stringify(events)).not.toContain(createdBody.raw_token);
|
|
180
|
+
expect(JSON.stringify(events)).not.toContain(hashOpaqueToken(createdBody.raw_token));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('fails loudly when an idempotent create replay cannot reproduce its raw token', async () => {
|
|
184
|
+
const createKey = `create-${crypto.randomUUID()}`;
|
|
185
|
+
const createRequest = {
|
|
186
|
+
method: 'POST' as const,
|
|
187
|
+
url: '/admin/runners/provisioner-tokens',
|
|
188
|
+
headers: {authorization: 'Bearer user', 'idempotency-key': createKey},
|
|
189
|
+
payload: {reason: 'Create the installation credential'},
|
|
190
|
+
};
|
|
191
|
+
const created = await app.inject(createRequest);
|
|
192
|
+
expect(created.statusCode).toBe(201);
|
|
193
|
+
const createdBody = created.json();
|
|
194
|
+
|
|
195
|
+
await db()
|
|
196
|
+
.update(provisionerTokens)
|
|
197
|
+
.set({hashedToken: hashOpaqueToken('a different token')})
|
|
198
|
+
.where(eq(provisionerTokens.id, createdBody.id));
|
|
199
|
+
|
|
200
|
+
const replay = await app.inject(createRequest);
|
|
201
|
+
expect(replay.statusCode).toBe(409);
|
|
202
|
+
expect(replay.json()).toMatchObject({code: 'idempotency-replay-unavailable'});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('rejects administrators without the required role', async () => {
|
|
206
|
+
vi.mocked(auth.requireAdminRole).mockRejectedValueOnce(
|
|
207
|
+
createInterModuleKnownError(
|
|
208
|
+
authInterModuleContract.methods.requireAdminRole,
|
|
209
|
+
'admin-role-required',
|
|
210
|
+
{requiredRole: 'admin-owner'},
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const response = await app.inject({
|
|
215
|
+
method: 'POST',
|
|
216
|
+
url: '/admin/runners/provisioner-tokens',
|
|
217
|
+
headers: {authorization: 'Bearer user', 'idempotency-key': 'owner-required'},
|
|
218
|
+
payload: {reason: 'Create the installation credential'},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(response.statusCode).toBe(403);
|
|
222
|
+
expect(response.json()).toMatchObject({
|
|
223
|
+
code: 'forbidden',
|
|
224
|
+
details: {required_role: 'admin-owner'},
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|