@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.
Files changed (118) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +28 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +8 -13
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/admin-runner-instances.d.ts +34 -0
  7. package/dist/core/admin-runner-instances.d.ts.map +1 -0
  8. package/dist/core/admin-runner-instances.js +58 -0
  9. package/dist/core/admin-runner-instances.js.map +1 -0
  10. package/dist/core/errors.d.ts +6 -0
  11. package/dist/core/errors.d.ts.map +1 -1
  12. package/dist/core/errors.js +12 -0
  13. package/dist/core/errors.js.map +1 -1
  14. package/dist/core/index.d.ts +3 -2
  15. package/dist/core/index.d.ts.map +1 -1
  16. package/dist/core/index.js +3 -2
  17. package/dist/core/index.js.map +1 -1
  18. package/dist/core/provisioner-tokens.d.ts +33 -0
  19. package/dist/core/provisioner-tokens.d.ts.map +1 -1
  20. package/dist/core/provisioner-tokens.js +107 -2
  21. package/dist/core/provisioner-tokens.js.map +1 -1
  22. package/dist/db/admin-runner-instances.d.ts +34 -0
  23. package/dist/db/admin-runner-instances.d.ts.map +1 -0
  24. package/dist/db/admin-runner-instances.js +62 -0
  25. package/dist/db/admin-runner-instances.js.map +1 -0
  26. package/dist/db/db.d.ts +290 -0
  27. package/dist/db/db.d.ts.map +1 -1
  28. package/dist/db/db.js +2 -0
  29. package/dist/db/db.js.map +1 -1
  30. package/dist/db/index.d.ts +3 -1
  31. package/dist/db/index.d.ts.map +1 -1
  32. package/dist/db/index.js +2 -1
  33. package/dist/db/index.js.map +1 -1
  34. package/dist/db/job-executions.d.ts.map +1 -1
  35. package/dist/db/job-executions.js +11 -0
  36. package/dist/db/job-executions.js.map +1 -1
  37. package/dist/db/provisioner-tokens.d.ts +38 -0
  38. package/dist/db/provisioner-tokens.d.ts.map +1 -1
  39. package/dist/db/provisioner-tokens.js +157 -3
  40. package/dist/db/provisioner-tokens.js.map +1 -1
  41. package/dist/db/reservations.d.ts.map +1 -1
  42. package/dist/db/reservations.js +60 -8
  43. package/dist/db/reservations.js.map +1 -1
  44. package/dist/db/schema/admin-command-results.d.ts +134 -0
  45. package/dist/db/schema/admin-command-results.d.ts.map +1 -0
  46. package/dist/db/schema/admin-command-results.js +18 -0
  47. package/dist/db/schema/admin-command-results.js.map +1 -0
  48. package/dist/db/schema/runner-instances.d.ts +17 -0
  49. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  50. package/dist/db/schema/runner-instances.js +3 -0
  51. package/dist/db/schema/runner-instances.js.map +1 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/presentation/dto/admin-provisioner-token.d.ts +16 -0
  56. package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -0
  57. package/dist/presentation/dto/admin-provisioner-token.js +22 -0
  58. package/dist/presentation/dto/admin-provisioner-token.js.map +1 -0
  59. package/dist/presentation/dto/admin-runner-instances.d.ts +23 -0
  60. package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -0
  61. package/dist/presentation/dto/admin-runner-instances.js +19 -0
  62. package/dist/presentation/dto/admin-runner-instances.js.map +1 -0
  63. package/dist/presentation/dto/index.d.ts +2 -0
  64. package/dist/presentation/dto/index.d.ts.map +1 -1
  65. package/dist/presentation/dto/index.js +2 -0
  66. package/dist/presentation/dto/index.js.map +1 -1
  67. package/dist/presentation/routes/admin-provisioner-tokens.d.ts +4 -0
  68. package/dist/presentation/routes/admin-provisioner-tokens.d.ts.map +1 -0
  69. package/dist/presentation/routes/admin-provisioner-tokens.js +167 -0
  70. package/dist/presentation/routes/admin-provisioner-tokens.js.map +1 -0
  71. package/dist/presentation/routes/admin-runner-instances.d.ts +3 -0
  72. package/dist/presentation/routes/admin-runner-instances.d.ts.map +1 -0
  73. package/dist/presentation/routes/admin-runner-instances.js +63 -0
  74. package/dist/presentation/routes/admin-runner-instances.js.map +1 -0
  75. package/dist/presentation/routes/index.d.ts.map +1 -1
  76. package/dist/presentation/routes/index.js +11 -1
  77. package/dist/presentation/routes/index.js.map +1 -1
  78. package/dist/presentation/routes/runner-enrollment.d.ts +1 -0
  79. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  80. package/dist/presentation/routes/runner-enrollment.js +11 -4
  81. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  82. package/dist/tsconfig.test.tsbuildinfo +1 -1
  83. package/drizzle/0002_special_shadowcat.sql +1 -0
  84. package/drizzle/0003_flaky_sally_floyd.sql +11 -0
  85. package/drizzle/meta/0002_snapshot.json +2099 -0
  86. package/drizzle/meta/0003_snapshot.json +2178 -0
  87. package/drizzle/meta/_journal.json +14 -0
  88. package/package.json +10 -9
  89. package/src/config.test.ts +11 -0
  90. package/src/config.ts +19 -8
  91. package/src/core/admin-runner-instances.ts +125 -0
  92. package/src/core/errors.ts +14 -0
  93. package/src/core/index.ts +12 -0
  94. package/src/core/provisioner-tokens.ts +157 -1
  95. package/src/db/admin-runner-instances.ts +121 -0
  96. package/src/db/db.ts +2 -0
  97. package/src/db/index.ts +10 -0
  98. package/src/db/job-executions.test.ts +17 -0
  99. package/src/db/job-executions.ts +22 -0
  100. package/src/db/provisioner-tokens.ts +263 -9
  101. package/src/db/reservations.test.ts +253 -0
  102. package/src/db/reservations.ts +170 -15
  103. package/src/db/schema/admin-command-results.ts +29 -0
  104. package/src/db/schema/runner-instances.ts +1 -0
  105. package/src/index.test.ts +1 -1
  106. package/src/index.ts +6 -1
  107. package/src/presentation/dto/admin-provisioner-token.ts +23 -0
  108. package/src/presentation/dto/admin-runner-instances.ts +19 -0
  109. package/src/presentation/dto/index.ts +2 -0
  110. package/src/presentation/routes/admin-provisioner-tokens.test.ts +227 -0
  111. package/src/presentation/routes/admin-provisioner-tokens.ts +177 -0
  112. package/src/presentation/routes/admin-runner-instances.test.ts +671 -0
  113. package/src/presentation/routes/admin-runner-instances.ts +67 -0
  114. package/src/presentation/routes/index.ts +8 -0
  115. package/src/presentation/routes/runner-enrollment.test.ts +60 -2
  116. package/src/presentation/routes/runner-enrollment.ts +24 -4
  117. package/test/globalSetup.ts +1 -0
  118. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,11 +1,26 @@
1
1
  import {canonicalizeLabels} from '@shipfox/runner-labels';
2
- import {and, asc, eq, gt, inArray, lt, sql} from 'drizzle-orm';
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
- const expiredIds = db()
319
- .select({id: reservations.id})
320
- .from(reservations)
321
- .where(lt(reservations.expiresAt, sql`now()`))
322
- .orderBy(asc(reservations.expiresAt))
323
- .limit(params?.limit ?? 1000);
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
- const deleted = await db()
326
- .delete(reservations)
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 deleted.length;
423
+ return await db().transaction(async (tx) => {
424
+ return await deleteReservationsWithCleanupTx(tx, ids);
425
+ });
331
426
  }
332
427
 
333
- export async function deleteReservationsByIds(ids: string[]): Promise<number> {
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 deleted = await db()
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, ids))
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
@@ -9,7 +9,7 @@ describe('createRunnersModule', () => {
9
9
 
10
10
  expect(module.name).toBe('runners');
11
11
  expect(module.auth).toHaveLength(3);
12
- expect(module.routes).toHaveLength(11);
12
+ expect(module.routes).toHaveLength(13);
13
13
  expect(module.workers).toHaveLength(1);
14
14
  });
15
15
 
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: [{name: 'runners', table: runnersOutbox, db, eventSchemas: runnersEventSchemas}],
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
+ });