@shipfox/api-runners 9.2.0 → 10.0.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 (66) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +53 -0
  3. package/dist/config.js +1 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/runner-activation.d.ts +6 -0
  6. package/dist/core/runner-activation.d.ts.map +1 -1
  7. package/dist/core/runner-activation.js +20 -19
  8. package/dist/core/runner-activation.js.map +1 -1
  9. package/dist/core/runner-control-sessions.d.ts +2 -1
  10. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  11. package/dist/core/runner-control-sessions.js +65 -19
  12. package/dist/core/runner-control-sessions.js.map +1 -1
  13. package/dist/db/db.d.ts +34 -0
  14. package/dist/db/db.d.ts.map +1 -1
  15. package/dist/db/index.d.ts +1 -1
  16. package/dist/db/index.d.ts.map +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/index.js.map +1 -1
  19. package/dist/db/job-executions.d.ts +7 -0
  20. package/dist/db/job-executions.d.ts.map +1 -1
  21. package/dist/db/job-executions.js +25 -0
  22. package/dist/db/job-executions.js.map +1 -1
  23. package/dist/db/runner-assignments.d.ts +6 -0
  24. package/dist/db/runner-assignments.d.ts.map +1 -1
  25. package/dist/db/runner-assignments.js +57 -42
  26. package/dist/db/runner-assignments.js.map +1 -1
  27. package/dist/db/runner-instances.d.ts.map +1 -1
  28. package/dist/db/runner-instances.js +64 -19
  29. package/dist/db/runner-instances.js.map +1 -1
  30. package/dist/db/schema/runner-instances.d.ts +17 -0
  31. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  32. package/dist/db/schema/runner-instances.js +3 -0
  33. package/dist/db/schema/runner-instances.js.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/presentation/inter-module.d.ts.map +1 -1
  39. package/dist/presentation/inter-module.js +7 -2
  40. package/dist/presentation/inter-module.js.map +1 -1
  41. package/dist/presentation/routes/runner-enrollment.d.ts +9 -0
  42. package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
  43. package/dist/presentation/routes/runner-enrollment.js +36 -23
  44. package/dist/presentation/routes/runner-enrollment.js.map +1 -1
  45. package/dist/tsconfig.test.tsbuildinfo +1 -1
  46. package/drizzle/0001_intended_reservation.sql +3 -0
  47. package/drizzle/meta/0001_snapshot.json +2093 -0
  48. package/drizzle/meta/_journal.json +7 -0
  49. package/package.json +12 -12
  50. package/src/config.test.ts +11 -0
  51. package/src/config.ts +1 -1
  52. package/src/core/runner-activation.ts +48 -38
  53. package/src/core/runner-control-sessions.ts +115 -33
  54. package/src/db/index.ts +1 -0
  55. package/src/db/job-executions.ts +28 -0
  56. package/src/db/runner-assignments.test.ts +81 -0
  57. package/src/db/runner-assignments.ts +134 -86
  58. package/src/db/runner-instances.test.ts +182 -0
  59. package/src/db/runner-instances.ts +123 -28
  60. package/src/db/schema/runner-instances.ts +9 -0
  61. package/src/index.ts +1 -0
  62. package/src/presentation/inter-module.ts +4 -0
  63. package/src/presentation/routes/poll-demand.test.ts +2 -2
  64. package/src/presentation/routes/runner-enrollment.test.ts +144 -1
  65. package/src/presentation/routes/runner-enrollment.ts +40 -20
  66. package/tsconfig.build.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export {
30
30
  cancelRunnerJobs,
31
31
  type EnqueueJobExecutionParams,
32
32
  enqueueJobExecution,
33
+ getWorkspaceJobCounts,
33
34
  isJobLeaseActive,
34
35
  releaseJobExecution,
35
36
  } from '#db/index.js';
@@ -9,6 +9,7 @@ import {getEffectiveRunnerToolCapabilities} from '#core/runner-tool-capabilities
9
9
  import {
10
10
  cancelRunnerJobs,
11
11
  enqueueJobExecution,
12
+ getWorkspaceJobCounts,
12
13
  isJobLeaseActive,
13
14
  releaseJobExecution,
14
15
  } from '#db/job-executions.js';
@@ -38,6 +39,9 @@ export function createRunnersInterModulePresentation(): InterModulePresentation<
38
39
  const result = await getEffectiveRunnerToolCapabilities(input);
39
40
  return {capabilities: result.capabilities, reportFresh: result.reportFresh};
40
41
  },
42
+ getWorkspaceJobCounts: async ({workspaceIds}) => ({
43
+ counts: await getWorkspaceJobCounts({workspaceIds}),
44
+ }),
41
45
  });
42
46
  }
43
47
 
@@ -290,8 +290,8 @@ describe('POST /provisioners/demand/poll', () => {
290
290
  expect(res.statusCode).toBe(400);
291
291
  });
292
292
 
293
- it('returns 400 for too many templates', async () => {
294
- const templates = Array.from({length: 101}, (_, index) => ({
293
+ it('returns 400 for more than 1000 templates', async () => {
294
+ const templates = Array.from({length: 1001}, (_, index) => ({
295
295
  template_key: `linux-${index}`,
296
296
  labels: ['linux'],
297
297
  available_slots: 1,
@@ -23,6 +23,7 @@ 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
27
 
27
28
  const token = 'provisioner-test-token';
28
29
  const fakeUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
@@ -102,7 +103,8 @@ describe('runner enrollment control plane', () => {
102
103
  headers: {authorization: `Bearer ${controlToken}`},
103
104
  payload: {labels: ['Linux', 'linux'], provider_kind: 'docker', protocol_version: '1'},
104
105
  });
105
- expect(enrolled.statusCode).toBe(204);
106
+ expect(enrolled.statusCode).toBe(200);
107
+ expect(enrolled.json()).toEqual({activation_token: null});
106
108
 
107
109
  const attached = await app.inject({
108
110
  method: 'POST',
@@ -137,6 +139,122 @@ describe('runner enrollment control plane', () => {
137
139
  expect(jobs.statusCode).toBe(401);
138
140
  });
139
141
 
142
+ it('promotes an intended reservation during enrollment and returns its activation token', async () => {
143
+ const workspaceId = crypto.randomUUID();
144
+ const [reservation] = await db()
145
+ .insert(reservations)
146
+ .values({
147
+ workspaceId,
148
+ provisionerId,
149
+ requiredLabels: ['linux'],
150
+ count: 1,
151
+ expiresAt: new Date(Date.now() + 60_000),
152
+ })
153
+ .returning();
154
+ if (!reservation) throw new Error('Reservation insert returned no row');
155
+
156
+ const created = await app.inject({
157
+ method: 'POST',
158
+ url: '/provisioners/runner-instances/batch',
159
+ headers: {authorization: `Bearer ${token}`},
160
+ payload: {
161
+ runner_instances: [{template_key: 'linux', reservation_id: reservation.id}],
162
+ },
163
+ });
164
+ const runner = created.json().runner_instances[0];
165
+ const exchanged = await app.inject({
166
+ method: 'POST',
167
+ url: '/runner-enrollment/exchange',
168
+ payload: {bootstrap_token: runner.bootstrap_token},
169
+ });
170
+ const controlToken = exchanged.json().control_session_token;
171
+
172
+ const attached = await app.inject({
173
+ method: 'POST',
174
+ url: `/provisioners/runner-instances/${runner.runner_instance_id}/provider-runner`,
175
+ headers: {authorization: `Bearer ${token}`},
176
+ payload: {provider_runner_id: 'container-intended-assignment'},
177
+ });
178
+ expect(attached.json()).toEqual({attached: true});
179
+
180
+ const enrolled = await app.inject({
181
+ method: 'POST',
182
+ url: '/runner-control/enrollment',
183
+ headers: {authorization: `Bearer ${controlToken}`},
184
+ payload: {labels: ['linux'], provider_kind: 'docker', protocol_version: '1'},
185
+ });
186
+
187
+ expect(enrolled.statusCode).toBe(200);
188
+ expect(enrolled.json()).toEqual({activation_token: expect.any(String)});
189
+ const [instance] = await db()
190
+ .select()
191
+ .from(providerRunners)
192
+ .where(eq(providerRunners.id, runner.runner_instance_id));
193
+ expect(instance).toMatchObject({
194
+ workspaceId,
195
+ reservationId: reservation.id,
196
+ intendedReservationId: null,
197
+ state: 'running',
198
+ providerRunnerId: 'container-intended-assignment',
199
+ assignedAt: expect.any(Date),
200
+ });
201
+ });
202
+
203
+ it('keeps enrollment successful when an intended reservation cannot be promoted', async () => {
204
+ const workspaceId = crypto.randomUUID();
205
+ const [reservation] = await db()
206
+ .insert(reservations)
207
+ .values({
208
+ workspaceId,
209
+ provisionerId,
210
+ requiredLabels: ['gpu'],
211
+ count: 1,
212
+ expiresAt: new Date(Date.now() + 60_000),
213
+ })
214
+ .returning();
215
+ if (!reservation) throw new Error('Reservation insert returned no row');
216
+
217
+ const created = await app.inject({
218
+ method: 'POST',
219
+ url: '/provisioners/runner-instances/batch',
220
+ headers: {authorization: `Bearer ${token}`},
221
+ payload: {runner_instances: [{reservation_id: reservation.id}]},
222
+ });
223
+ const runner = created.json().runner_instances[0];
224
+ const exchanged = await app.inject({
225
+ method: 'POST',
226
+ url: '/runner-enrollment/exchange',
227
+ payload: {bootstrap_token: runner.bootstrap_token},
228
+ });
229
+ const controlToken = exchanged.json().control_session_token;
230
+ await app.inject({
231
+ method: 'POST',
232
+ url: `/provisioners/runner-instances/${runner.runner_instance_id}/provider-runner`,
233
+ headers: {authorization: `Bearer ${token}`},
234
+ payload: {provider_runner_id: 'container-intended-mismatch'},
235
+ });
236
+
237
+ const enrolled = await app.inject({
238
+ method: 'POST',
239
+ url: '/runner-control/enrollment',
240
+ headers: {authorization: `Bearer ${controlToken}`},
241
+ payload: {labels: ['linux'], provider_kind: 'docker', protocol_version: '1'},
242
+ });
243
+
244
+ expect(enrolled.statusCode).toBe(200);
245
+ expect(enrolled.json()).toEqual({activation_token: null});
246
+ const [instance] = await db()
247
+ .select()
248
+ .from(providerRunners)
249
+ .where(eq(providerRunners.id, runner.runner_instance_id));
250
+ expect(instance).toMatchObject({
251
+ workspaceId: null,
252
+ reservationId: null,
253
+ intendedReservationId: reservation.id,
254
+ state: 'running',
255
+ });
256
+ });
257
+
140
258
  it('rejects enrollment when the authenticated runner instance is terminal', async () => {
141
259
  const created = await app.inject({
142
260
  method: 'POST',
@@ -276,6 +394,31 @@ describe('runner enrollment control plane', () => {
276
394
  expect(closed.statusCode).toBe(401);
277
395
  });
278
396
 
397
+ it('keeps polling when an assignment cannot yet mint an activation token', async () => {
398
+ vi.useFakeTimers();
399
+ const abortController = new AbortController();
400
+ const getAssignment = vi.fn().mockResolvedValue({workspaceId: 'workspace-id'});
401
+ const issueActivationToken = vi
402
+ .fn()
403
+ .mockResolvedValueOnce(null)
404
+ .mockResolvedValueOnce('activation-token');
405
+ try {
406
+ const activationToken = pollForRunnerActivationToken({
407
+ deadline: Date.now() + 1_000,
408
+ intervalMs: 5,
409
+ signal: abortController.signal,
410
+ getAssignment,
411
+ issueActivationToken,
412
+ });
413
+ await Promise.resolve();
414
+ await Promise.resolve();
415
+ expect(issueActivationToken).toHaveBeenCalledTimes(1);
416
+ await vi.advanceTimersByTimeAsync(5);
417
+ await expect(activationToken).resolves.toBe('activation-token');
418
+ } finally {
419
+ vi.useRealTimers();
420
+ }
421
+ });
279
422
  it('rejects registration with an expired activation token', async () => {
280
423
  const created = await app.inject({
281
424
  method: 'POST',
@@ -11,6 +11,7 @@ import {
11
11
  runnerBootstrapExchangeResponseSchema,
12
12
  runnerControlHeartbeatResponseSchema,
13
13
  runnerEnrollmentBodySchema,
14
+ runnerEnrollmentResponseSchema,
14
15
  } from '@shipfox/api-runners-dto';
15
16
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
16
17
  import {z} from 'zod';
@@ -43,9 +44,10 @@ export const createRunnerInstancesRoute = defineRoute({
43
44
  const results = await createRunnerInstancesWithBootstrapTokens({
44
45
  provisionerId: provisionerTokenId,
45
46
  ...(request.body.provider_kind ? {providerKind: request.body.provider_kind} : {}),
46
- runnerInstances: request.body.runner_instances.map((runner) =>
47
- runner.template_key ? {templateKey: runner.template_key} : {},
48
- ),
47
+ runnerInstances: request.body.runner_instances.map((runner) => ({
48
+ ...(runner.template_key ? {templateKey: runner.template_key} : {}),
49
+ ...(runner.reservation_id ? {reservationId: runner.reservation_id} : {}),
50
+ })),
49
51
  ttlSeconds: config.RUNNER_BOOTSTRAP_TOKEN_TTL_SECONDS,
50
52
  });
51
53
  return {
@@ -111,16 +113,16 @@ export const enrollRunnerRoute = defineRoute({
111
113
  method: 'POST',
112
114
  path: '/enrollment',
113
115
  description: 'Declare the authenticated runner instance labels and protocol capabilities',
114
- schema: {body: runnerEnrollmentBodySchema, response: {204: z.null()}},
116
+ schema: {body: runnerEnrollmentBodySchema, response: {200: runnerEnrollmentResponseSchema}},
115
117
  preHandler: authenticateRunnerControlSession,
116
118
  errorHandler: (error) => {
117
119
  if (error instanceof RunnerControlSessionInvalidError)
118
120
  throw new ClientError(error.message, 'runner-control-session-invalid', {status: 409});
119
121
  throw error;
120
122
  },
121
- handler: async (request, reply) => {
123
+ handler: async (request) => {
122
124
  const session = requireRunnerControlSessionContext(request);
123
- await enrollRunnerControlSession({
125
+ const activationToken = await enrollRunnerControlSession({
124
126
  runnerInstanceId: session.runnerInstanceId,
125
127
  provisionerId: session.provisionerId,
126
128
  labels: request.body.labels,
@@ -128,7 +130,7 @@ export const enrollRunnerRoute = defineRoute({
128
130
  providerKind: request.body.provider_kind,
129
131
  protocolVersion: request.body.protocol_version,
130
132
  });
131
- return await reply.code(204).send();
133
+ return {activation_token: activationToken};
132
134
  },
133
135
  });
134
136
 
@@ -166,6 +168,27 @@ export const runnerControlHeartbeatRoute = defineRoute({
166
168
  },
167
169
  });
168
170
 
171
+ type RunnerAssignmentPollParams = {
172
+ deadline: number;
173
+ intervalMs: number;
174
+ signal: AbortSignal;
175
+ getAssignment: () => Promise<unknown | null>;
176
+ issueActivationToken: () => Promise<string | null>;
177
+ };
178
+ export async function pollForRunnerActivationToken(
179
+ params: RunnerAssignmentPollParams,
180
+ ): Promise<string | null> {
181
+ while (Date.now() <= params.deadline) {
182
+ if (params.signal.aborted) return null;
183
+ const assignment = await params.getAssignment();
184
+ if (assignment) {
185
+ const activationToken = await params.issueActivationToken();
186
+ if (activationToken) return activationToken;
187
+ }
188
+ await new Promise((resolve) => setTimeout(resolve, params.intervalMs));
189
+ }
190
+ return null;
191
+ }
169
192
  export const runnerAssignmentPollRoute = defineRoute({
170
193
  method: 'GET',
171
194
  path: '/assignment',
@@ -177,20 +200,17 @@ export const runnerAssignmentPollRoute = defineRoute({
177
200
  const deadline = Date.now() + config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS * 1000;
178
201
  const abortController = new AbortController();
179
202
  reply.raw.on('close', () => abortController.abort());
180
- while (Date.now() <= deadline) {
181
- if (abortController.signal.aborted) return {activation_token: null};
182
- const assignment = await getRunnerAssignment(session);
183
- if (assignment) {
184
- const activationToken = await issueRunnerActivationToken({
203
+ const activationToken = await pollForRunnerActivationToken({
204
+ deadline,
205
+ intervalMs: config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS,
206
+ signal: abortController.signal,
207
+ getAssignment: () => getRunnerAssignment(session),
208
+ issueActivationToken: () =>
209
+ issueRunnerActivationToken({
185
210
  ...session,
186
211
  ttlSeconds: config.RUNNER_ACTIVATION_TOKEN_TTL_SECONDS,
187
- });
188
- return {activation_token: activationToken};
189
- }
190
- await new Promise((resolve) =>
191
- setTimeout(resolve, config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS),
192
- );
193
- }
194
- return {activation_token: null};
212
+ }),
213
+ });
214
+ return {activation_token: activationToken};
195
215
  },
196
216
  });