@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
@@ -0,0 +1,67 @@
1
+ import {AUTH_USER, requireUserContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ type AuthInterModuleClient,
4
+ authInterModuleContract,
5
+ } from '@shipfox/api-auth-dto/inter-module';
6
+ import {
7
+ listRunnerAdministratorInstancesQuerySchema,
8
+ listRunnerAdministratorInstancesResponseSchema,
9
+ } from '@shipfox/api-runners-dto';
10
+ import {isInterModuleKnownError} from '@shipfox/inter-module';
11
+ import {decodeTimestampIdCursor, encodeTimestampIdCursor} from '@shipfox/node-drizzle';
12
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
13
+ import {listRunnerAdministratorInstances} from '#core/admin-runner-instances.js';
14
+ import {toRunnerAdministratorInstanceDto} from '#presentation/dto/admin-runner-instances.js';
15
+
16
+ function translateAdminRunnerInstancesRouteError(error: unknown): never {
17
+ if (
18
+ isInterModuleKnownError(authInterModuleContract.methods.requireAdminRole, error) &&
19
+ error.code === 'admin-role-required'
20
+ ) {
21
+ throw new ClientError('Administrator observer role required', 'forbidden', {
22
+ status: 403,
23
+ details: {required_role: error.details.requiredRole},
24
+ });
25
+ }
26
+
27
+ throw error;
28
+ }
29
+
30
+ export function createAdminRunnerInstancesRoute(
31
+ auth: Pick<AuthInterModuleClient, 'requireAdminRole'>,
32
+ ) {
33
+ return defineRoute({
34
+ method: 'GET',
35
+ path: '/',
36
+ auth: AUTH_USER,
37
+ description: 'List bounded safe installation-managed runner instances for administrators.',
38
+ schema: {
39
+ querystring: listRunnerAdministratorInstancesQuerySchema,
40
+ response: {200: listRunnerAdministratorInstancesResponseSchema},
41
+ },
42
+ errorHandler: translateAdminRunnerInstancesRouteError,
43
+ handler: async (request) => {
44
+ const actor = requireUserContext(request);
45
+ await auth.requireAdminRole({userId: actor.userId, minimumRole: 'admin-observer'});
46
+
47
+ const {state, assignment, label, limit, cursor} = request.query;
48
+ const decodedCursor = decodeTimestampIdCursor(cursor);
49
+ if (cursor !== undefined && !decodedCursor) {
50
+ throw new ClientError('Invalid cursor', 'invalid-cursor', {status: 400});
51
+ }
52
+
53
+ const result = await listRunnerAdministratorInstances({
54
+ state,
55
+ assignment,
56
+ label,
57
+ limit,
58
+ ...(decodedCursor ? {cursor: decodedCursor} : {}),
59
+ });
60
+
61
+ return {
62
+ runners: result.runners.map(toRunnerAdministratorInstanceDto),
63
+ next_cursor: result.nextCursor ? encodeTimestampIdCursor(result.nextCursor) : null,
64
+ };
65
+ },
66
+ });
67
+ }
@@ -8,6 +8,8 @@ import {
8
8
  import type {AuthInterModuleClient} from '@shipfox/api-auth-dto/inter-module';
9
9
  import type {RouteGroup} from '@shipfox/node-fastify';
10
10
  import type {CreateRunnersModuleOptions} from '#installation-provisioning.js';
11
+ import {createAdminProvisionerTokenRoutes} from './admin-provisioner-tokens.js';
12
+ import {createAdminRunnerInstancesRoute} from './admin-runner-instances.js';
11
13
  import {assignRunnerInstancesRoute} from './assign-runner-instances.js';
12
14
  import {createManualRegistrationTokenRoute} from './create-manual-registration-token.js';
13
15
  import {createProvisionerTokenRoute} from './create-provisioner-token.js';
@@ -77,6 +79,12 @@ function createRunnerOnlyRoutes(auth: AuthInterModuleClient): RouteGroup[] {
77
79
  reconcileRunnerInstancesRoute,
78
80
  ],
79
81
  },
82
+ {
83
+ prefix: '/admin/runners/instances',
84
+ auth: AUTH_USER,
85
+ routes: [createAdminRunnerInstancesRoute(auth)],
86
+ },
87
+ createAdminProvisionerTokenRoutes(auth),
80
88
  ];
81
89
  }
82
90
 
@@ -23,7 +23,10 @@ import {
23
23
  runnersTestAuthClient,
24
24
  } from '#test/index.js';
25
25
  import {createRunnerRoutes} from './index.js';
26
- import {pollForRunnerActivationToken} from './runner-enrollment.js';
26
+ import {
27
+ pollForRunnerActivationToken,
28
+ resolveRunnerAssignmentPollWaitSeconds,
29
+ } from './runner-enrollment.js';
27
30
 
28
31
  const token = 'provisioner-test-token';
29
32
  const fakeUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
@@ -65,6 +68,14 @@ describe('runner enrollment control plane', () => {
65
68
  provisionerId = crypto.randomUUID();
66
69
  });
67
70
 
71
+ it('uses the requested assignment wait below the server cap and caps larger requests', () => {
72
+ expect(resolveRunnerAssignmentPollWaitSeconds(5)).toBe(5);
73
+ expect(resolveRunnerAssignmentPollWaitSeconds(0)).toBe(1);
74
+ expect(resolveRunnerAssignmentPollWaitSeconds(30)).toBe(30);
75
+ expect(resolveRunnerAssignmentPollWaitSeconds(31)).toBe(30);
76
+ expect(resolveRunnerAssignmentPollWaitSeconds(undefined)).toBe(30);
77
+ });
78
+
68
79
  it('creates a hashed one-use bootstrap token and restricts its control session to its instance', async () => {
69
80
  const created = await app.inject({
70
81
  method: 'POST',
@@ -370,7 +381,7 @@ describe('runner enrollment control plane', () => {
370
381
 
371
382
  const assignment = await app.inject({
372
383
  method: 'GET',
373
- url: '/runner-control/assignment',
384
+ url: '/runner-control/assignment?wait_seconds=5',
374
385
  headers: {authorization: `Bearer ${controlToken}`},
375
386
  });
376
387
  expect(assignment.statusCode).toBe(200);
@@ -394,6 +405,29 @@ describe('runner enrollment control plane', () => {
394
405
  expect(closed.statusCode).toBe(401);
395
406
  });
396
407
 
408
+ it('rejects a negative assignment wait before entering the control-plane poll', async () => {
409
+ const created = await app.inject({
410
+ method: 'POST',
411
+ url: '/provisioners/runner-instances/batch',
412
+ headers: {authorization: `Bearer ${token}`},
413
+ payload: {runner_instances: [{}]},
414
+ });
415
+ const runner = created.json().runner_instances[0];
416
+ const exchanged = await app.inject({
417
+ method: 'POST',
418
+ url: '/runner-enrollment/exchange',
419
+ payload: {bootstrap_token: runner.bootstrap_token},
420
+ });
421
+
422
+ const assignment = await app.inject({
423
+ method: 'GET',
424
+ url: '/runner-control/assignment?wait_seconds=-1',
425
+ headers: {authorization: `Bearer ${exchanged.json().control_session_token}`},
426
+ });
427
+
428
+ expect(assignment.statusCode).toBe(400);
429
+ });
430
+
397
431
  it('keeps polling when an assignment cannot yet mint an activation token', async () => {
398
432
  vi.useFakeTimers();
399
433
  const abortController = new AbortController();
@@ -419,6 +453,30 @@ describe('runner enrollment control plane', () => {
419
453
  vi.useRealTimers();
420
454
  }
421
455
  });
456
+
457
+ it('does not read or sleep past the assignment poll deadline', async () => {
458
+ vi.useFakeTimers();
459
+ const abortController = new AbortController();
460
+ const getAssignment = vi.fn().mockResolvedValue(null);
461
+ const issueActivationToken = vi.fn();
462
+ try {
463
+ const activationToken = pollForRunnerActivationToken({
464
+ deadline: Date.now() + 10,
465
+ intervalMs: 100,
466
+ signal: abortController.signal,
467
+ getAssignment,
468
+ issueActivationToken,
469
+ });
470
+
471
+ await vi.advanceTimersByTimeAsync(10);
472
+
473
+ await expect(activationToken).resolves.toBeNull();
474
+ expect(getAssignment).toHaveBeenCalledTimes(1);
475
+ expect(issueActivationToken).not.toHaveBeenCalled();
476
+ } finally {
477
+ vi.useRealTimers();
478
+ }
479
+ });
422
480
  it('rejects registration with an expired activation token', async () => {
423
481
  const created = await app.inject({
424
482
  method: 'POST',
@@ -6,6 +6,7 @@ import {
6
6
  attachRunnerControlProviderIdBodySchema,
7
7
  createRunnerInstancesBodySchema,
8
8
  createRunnerInstancesResponseSchema,
9
+ runnerAssignmentPollQuerySchema,
9
10
  runnerAssignmentPollResponseSchema,
10
11
  runnerBootstrapExchangeBodySchema,
11
12
  runnerBootstrapExchangeResponseSchema,
@@ -175,17 +176,32 @@ type RunnerAssignmentPollParams = {
175
176
  getAssignment: () => Promise<unknown | null>;
176
177
  issueActivationToken: () => Promise<string | null>;
177
178
  };
179
+
180
+ export function resolveRunnerAssignmentPollWaitSeconds(
181
+ requestedWaitSeconds: number | undefined,
182
+ ): number {
183
+ return Math.max(
184
+ 1,
185
+ Math.min(
186
+ requestedWaitSeconds ?? config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS,
187
+ config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS,
188
+ ),
189
+ );
190
+ }
191
+
178
192
  export async function pollForRunnerActivationToken(
179
193
  params: RunnerAssignmentPollParams,
180
194
  ): Promise<string | null> {
181
- while (Date.now() <= params.deadline) {
195
+ while (Date.now() < params.deadline) {
182
196
  if (params.signal.aborted) return null;
183
197
  const assignment = await params.getAssignment();
184
198
  if (assignment) {
185
199
  const activationToken = await params.issueActivationToken();
186
200
  if (activationToken) return activationToken;
187
201
  }
188
- await new Promise((resolve) => setTimeout(resolve, params.intervalMs));
202
+ const remainingMs = params.deadline - Date.now();
203
+ if (remainingMs <= 0 || params.signal.aborted) return null;
204
+ await new Promise((resolve) => setTimeout(resolve, Math.min(params.intervalMs, remainingMs)));
189
205
  }
190
206
  return null;
191
207
  }
@@ -193,11 +209,15 @@ export const runnerAssignmentPollRoute = defineRoute({
193
209
  method: 'GET',
194
210
  path: '/assignment',
195
211
  description: 'Wait for only the authenticated runner instance assignment',
196
- schema: {response: {200: runnerAssignmentPollResponseSchema}},
212
+ schema: {
213
+ querystring: runnerAssignmentPollQuerySchema,
214
+ response: {200: runnerAssignmentPollResponseSchema},
215
+ },
197
216
  preHandler: authenticateRunnerControlSession,
198
217
  handler: async (request, reply) => {
199
218
  const session = requireRunnerControlSessionContext(request);
200
- const deadline = Date.now() + config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS * 1000;
219
+ const waitSeconds = resolveRunnerAssignmentPollWaitSeconds(request.query.wait_seconds);
220
+ const deadline = Date.now() + waitSeconds * 1000;
201
221
  const abortController = new AbortController();
202
222
  reply.raw.on('close', () => abortController.abort());
203
223
  const activationToken = await pollForRunnerActivationToken({
@@ -20,6 +20,7 @@ export async function setup() {
20
20
  await db().execute(sql`TRUNCATE runners_pending_jobs CASCADE`);
21
21
  await db().execute(sql`TRUNCATE runners_running_jobs CASCADE`);
22
22
  await db().execute(sql`TRUNCATE runners_outbox CASCADE`);
23
+ await db().execute(sql`TRUNCATE runners_admin_command_results CASCADE`);
23
24
 
24
25
  closeDb();
25
26
  await closePostgresClient();