@shipfox/api-runners 12.2.0 → 12.4.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 +34 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +10 -3
- package/dist/config.js.map +1 -1
- package/dist/core/demand.d.ts +4 -2
- package/dist/core/demand.d.ts.map +1 -1
- package/dist/core/demand.js +22 -6
- package/dist/core/demand.js.map +1 -1
- package/dist/core/entities/reservation.d.ts +1 -0
- package/dist/core/entities/reservation.d.ts.map +1 -1
- package/dist/core/entities/reservation.js.map +1 -1
- package/dist/core/entities/runner-instance.d.ts +3 -0
- package/dist/core/entities/runner-instance.d.ts.map +1 -1
- package/dist/core/entities/runner-instance.js.map +1 -1
- package/dist/core/runner-activation.d.ts +4 -1
- package/dist/core/runner-activation.d.ts.map +1 -1
- package/dist/core/runner-activation.js +32 -3
- package/dist/core/runner-activation.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts +8 -4
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +69 -11
- package/dist/core/runner-control-sessions.js.map +1 -1
- package/dist/core/runner-instances.d.ts +1 -0
- package/dist/core/runner-instances.d.ts.map +1 -1
- package/dist/core/runner-instances.js +1 -0
- package/dist/core/runner-instances.js.map +1 -1
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.js +1 -1
- package/dist/db/ephemeral-registration-tokens.js.map +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/reservation-locks.d.ts +6 -0
- package/dist/db/reservation-locks.d.ts.map +1 -0
- package/dist/db/reservation-locks.js +15 -0
- package/dist/db/reservation-locks.js.map +1 -0
- package/dist/db/reservations.d.ts +17 -3
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +256 -45
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +17 -0
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +103 -8
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts +5 -2
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +134 -25
- package/dist/db/runner-instances.js.map +1 -1
- package/dist/db/runner-sessions.d.ts.map +1 -1
- package/dist/db/runner-sessions.js +22 -15
- package/dist/db/runner-sessions.js.map +1 -1
- package/dist/db/runner-states.d.ts +2 -0
- package/dist/db/runner-states.d.ts.map +1 -0
- package/dist/db/runner-states.js +7 -0
- package/dist/db/runner-states.js.map +1 -0
- package/dist/db/schema/reservations.d.ts +18 -0
- package/dist/db/schema/reservations.d.ts.map +1 -1
- package/dist/db/schema/reservations.js +7 -1
- package/dist/db/schema/reservations.js.map +1 -1
- package/dist/db/schema/runner-instances.d.ts +18 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +8 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/metrics/index.d.ts +2 -2
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/index.js.map +1 -1
- package/dist/metrics/instance.d.ts +24 -2
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +23 -0
- package/dist/metrics/instance.js.map +1 -1
- package/dist/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +20 -4
- package/dist/metrics/service.js.map +1 -1
- package/dist/presentation/dto/poll-demand.d.ts.map +1 -1
- package/dist/presentation/dto/poll-demand.js +3 -0
- package/dist/presentation/dto/poll-demand.js.map +1 -1
- package/dist/presentation/dto/runner-instances.d.ts +4 -1
- package/dist/presentation/dto/runner-instances.d.ts.map +1 -1
- package/dist/presentation/dto/runner-instances.js +4 -1
- package/dist/presentation/dto/runner-instances.js.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.js +3 -3
- package/dist/presentation/routes/assign-runner-instances.js.map +1 -1
- package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
- package/dist/presentation/routes/poll-demand.js +1 -0
- package/dist/presentation/routes/poll-demand.js.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/reconcile-runner-instances.js +4 -2
- package/dist/presentation/routes/reconcile-runner-instances.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +4 -1
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +11 -6
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0005_previous_lenny_balinger.sql +5 -0
- package/drizzle/meta/0005_snapshot.json +2220 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/config.test.ts +24 -2
- package/src/config.ts +16 -3
- package/src/core/demand.test.ts +192 -3
- package/src/core/demand.ts +44 -4
- package/src/core/entities/reservation.ts +1 -0
- package/src/core/entities/runner-instance.ts +4 -0
- package/src/core/runner-activation.ts +48 -2
- package/src/core/runner-control-sessions.test.ts +230 -4
- package/src/core/runner-control-sessions.ts +84 -10
- package/src/core/runner-instances.test.ts +19 -0
- package/src/core/runner-instances.ts +2 -0
- package/src/core/runner-sessions.test.ts +121 -1
- package/src/db/ephemeral-registration-tokens.test.ts +20 -1
- package/src/db/ephemeral-registration-tokens.ts +1 -0
- package/src/db/index.ts +1 -0
- package/src/db/reservation-locks.ts +22 -0
- package/src/db/reservations.test.ts +1110 -31
- package/src/db/reservations.ts +434 -105
- package/src/db/runner-assignments.test.ts +105 -1
- package/src/db/runner-assignments.ts +178 -9
- package/src/db/runner-instances.test.ts +496 -2
- package/src/db/runner-instances.ts +247 -56
- package/src/db/runner-sessions.ts +26 -15
- package/src/db/runner-states.ts +7 -0
- package/src/db/schema/reservations.ts +5 -1
- package/src/db/schema/runner-instances.ts +9 -0
- package/src/metrics/index.ts +10 -1
- package/src/metrics/instance.ts +59 -2
- package/src/metrics/service.test.ts +106 -0
- package/src/metrics/service.ts +24 -4
- package/src/presentation/dto/poll-demand.ts +3 -0
- package/src/presentation/dto/runner-instances.ts +8 -1
- package/src/presentation/routes/assign-runner-instances.ts +6 -2
- package/src/presentation/routes/late-runner-enrollment.test.ts +447 -0
- package/src/presentation/routes/poll-demand.test.ts +79 -6
- package/src/presentation/routes/poll-demand.ts +1 -0
- package/src/presentation/routes/reconcile-runner-instances.test.ts +34 -2
- package/src/presentation/routes/reconcile-runner-instances.ts +7 -1
- package/src/presentation/routes/runner-enrollment.test.ts +316 -3
- package/src/presentation/routes/runner-enrollment.ts +8 -5
- package/test/factories/reservation.ts +2 -0
- package/test/factories/runner-instance.ts +6 -0
- package/test/fixtures/late-runner-enrollment.ts +103 -0
- package/test/index.ts +5 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createApp,
|
|
7
7
|
extractBearerToken,
|
|
8
8
|
} from '@shipfox/node-fastify';
|
|
9
|
+
import {vi} from '@shipfox/vitest/vi';
|
|
9
10
|
import {eq} from 'drizzle-orm';
|
|
10
11
|
import type {FastifyInstance, FastifyRequest} from 'fastify';
|
|
11
12
|
import {db} from '#db/db.js';
|
|
@@ -13,6 +14,7 @@ import {reservations} from '#db/schema/reservations.js';
|
|
|
13
14
|
import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
|
|
14
15
|
import {runnerBootstrapTokens, runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
15
16
|
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
17
|
+
import {runnerReservationCapacityFailureCount} from '#metrics/instance.js';
|
|
16
18
|
import {
|
|
17
19
|
createRunnerControlSessionAuthMethod,
|
|
18
20
|
createRunnerRegistrationTokenAuthMethod,
|
|
@@ -159,6 +161,7 @@ describe('runner enrollment control plane', () => {
|
|
|
159
161
|
provisionerId,
|
|
160
162
|
labels: ['linux', 'shipfox-managed'],
|
|
161
163
|
providerRunnerId: 'container-1',
|
|
164
|
+
launchKind: 'warm',
|
|
162
165
|
state: 'running',
|
|
163
166
|
protocolVersion: '1',
|
|
164
167
|
});
|
|
@@ -210,7 +213,7 @@ describe('runner enrollment control plane', () => {
|
|
|
210
213
|
expect(instance?.labels).toEqual(['linux']);
|
|
211
214
|
});
|
|
212
215
|
|
|
213
|
-
it('
|
|
216
|
+
it('repairs an intended reservation after a provider report and returns its activation token', async () => {
|
|
214
217
|
const workspaceId = crypto.randomUUID();
|
|
215
218
|
const [reservation] = await db()
|
|
216
219
|
.insert(reservations)
|
|
@@ -240,14 +243,49 @@ describe('runner enrollment control plane', () => {
|
|
|
240
243
|
});
|
|
241
244
|
const controlToken = exchanged.json().control_session_token;
|
|
242
245
|
|
|
246
|
+
const providerRunnerId = 'container-intended-assignment';
|
|
243
247
|
const attached = await app.inject({
|
|
244
248
|
method: 'POST',
|
|
245
249
|
url: `/provisioners/runner-instances/${runner.runner_instance_id}/provider-runner`,
|
|
246
250
|
headers: {authorization: `Bearer ${token}`},
|
|
247
|
-
payload: {provider_runner_id:
|
|
251
|
+
payload: {provider_runner_id: providerRunnerId},
|
|
248
252
|
});
|
|
249
253
|
expect(attached.json()).toEqual({attached: true});
|
|
250
254
|
|
|
255
|
+
const reported = await app.inject({
|
|
256
|
+
method: 'POST',
|
|
257
|
+
url: '/provisioners/runner-instances/report',
|
|
258
|
+
headers: {authorization: `Bearer ${token}`},
|
|
259
|
+
payload: {
|
|
260
|
+
events: [
|
|
261
|
+
{
|
|
262
|
+
provider_runner_id: providerRunnerId,
|
|
263
|
+
reservation_id: reservation.id,
|
|
264
|
+
template_key: 'linux',
|
|
265
|
+
labels: ['linux'],
|
|
266
|
+
state: 'starting',
|
|
267
|
+
reported_at: new Date().toISOString(),
|
|
268
|
+
provider_kind: 'docker',
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
expect(reported.statusCode).toBe(200);
|
|
274
|
+
expect(reported.json()).toEqual({accepted: 1, reservations_released: 0});
|
|
275
|
+
|
|
276
|
+
const [reportedInstance] = await db()
|
|
277
|
+
.select()
|
|
278
|
+
.from(providerRunners)
|
|
279
|
+
.where(eq(providerRunners.id, runner.runner_instance_id));
|
|
280
|
+
expect(reportedInstance).toMatchObject({
|
|
281
|
+
workspaceId: null,
|
|
282
|
+
reservationId: reservation.id,
|
|
283
|
+
intendedReservationId: reservation.id,
|
|
284
|
+
assignedAt: null,
|
|
285
|
+
state: 'starting',
|
|
286
|
+
providerRunnerId,
|
|
287
|
+
});
|
|
288
|
+
|
|
251
289
|
const enrolled = await app.inject({
|
|
252
290
|
method: 'POST',
|
|
253
291
|
url: '/runner-control/enrollment',
|
|
@@ -264,13 +302,288 @@ describe('runner enrollment control plane', () => {
|
|
|
264
302
|
expect(instance).toMatchObject({
|
|
265
303
|
workspaceId,
|
|
266
304
|
reservationId: reservation.id,
|
|
305
|
+
launchKind: 'demand',
|
|
267
306
|
intendedReservationId: null,
|
|
268
307
|
state: 'running',
|
|
269
|
-
providerRunnerId
|
|
308
|
+
providerRunnerId,
|
|
270
309
|
assignedAt: expect.any(Date),
|
|
271
310
|
});
|
|
272
311
|
});
|
|
273
312
|
|
|
313
|
+
it('does not create a duplicate row or bootstrap token after a reservation is consumed', async () => {
|
|
314
|
+
const workspaceId = crypto.randomUUID();
|
|
315
|
+
const [reservation] = await db()
|
|
316
|
+
.insert(reservations)
|
|
317
|
+
.values({
|
|
318
|
+
workspaceId,
|
|
319
|
+
provisionerId,
|
|
320
|
+
requiredLabels: ['linux'],
|
|
321
|
+
count: 1,
|
|
322
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
323
|
+
})
|
|
324
|
+
.returning();
|
|
325
|
+
if (!reservation) throw new Error('Reservation insert returned no row');
|
|
326
|
+
|
|
327
|
+
const first = await app.inject({
|
|
328
|
+
method: 'POST',
|
|
329
|
+
url: '/provisioners/runner-instances/batch',
|
|
330
|
+
headers: {authorization: `Bearer ${token}`},
|
|
331
|
+
payload: {runner_instances: [{reservation_id: reservation.id}]},
|
|
332
|
+
});
|
|
333
|
+
const firstRunner = first.json().runner_instances[0];
|
|
334
|
+
expect(first.statusCode).toBe(200);
|
|
335
|
+
expect(first.json().reservation_unavailable).toBeUndefined();
|
|
336
|
+
|
|
337
|
+
const second = await app.inject({
|
|
338
|
+
method: 'POST',
|
|
339
|
+
url: '/provisioners/runner-instances/batch',
|
|
340
|
+
headers: {authorization: `Bearer ${token}`},
|
|
341
|
+
payload: {runner_instances: [{reservation_id: reservation.id}]},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(second.statusCode).toBe(200);
|
|
345
|
+
expect(second.json()).toMatchObject({
|
|
346
|
+
runner_instances: [],
|
|
347
|
+
reservation_unavailable: true,
|
|
348
|
+
});
|
|
349
|
+
const runners = await db()
|
|
350
|
+
.select()
|
|
351
|
+
.from(providerRunners)
|
|
352
|
+
.where(eq(providerRunners.intendedReservationId, reservation.id));
|
|
353
|
+
const bootstrapTokens = await db()
|
|
354
|
+
.select()
|
|
355
|
+
.from(runnerBootstrapTokens)
|
|
356
|
+
.where(eq(runnerBootstrapTokens.runnerInstanceId, firstRunner.runner_instance_id));
|
|
357
|
+
expect(runners).toHaveLength(1);
|
|
358
|
+
expect(bootstrapTokens).toHaveLength(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('returns a classified shortfall for expired and foreign reservations', async () => {
|
|
362
|
+
const otherProvisioner = await provisionerTokenFactory.create({scope: 'installation'});
|
|
363
|
+
const runnersBefore = await db()
|
|
364
|
+
.select()
|
|
365
|
+
.from(providerRunners)
|
|
366
|
+
.where(eq(providerRunners.provisionerId, provisionerId));
|
|
367
|
+
const bootstrapTokensBefore = await db()
|
|
368
|
+
.select()
|
|
369
|
+
.from(runnerBootstrapTokens)
|
|
370
|
+
.where(eq(runnerBootstrapTokens.provisionerId, provisionerId));
|
|
371
|
+
const expiredReservation = await db()
|
|
372
|
+
.insert(reservations)
|
|
373
|
+
.values({
|
|
374
|
+
workspaceId: crypto.randomUUID(),
|
|
375
|
+
provisionerId,
|
|
376
|
+
requiredLabels: ['linux'],
|
|
377
|
+
count: 1,
|
|
378
|
+
expiresAt: new Date(Date.now() - 1_000),
|
|
379
|
+
})
|
|
380
|
+
.returning({id: reservations.id});
|
|
381
|
+
const foreignReservation = await db()
|
|
382
|
+
.insert(reservations)
|
|
383
|
+
.values({
|
|
384
|
+
workspaceId: crypto.randomUUID(),
|
|
385
|
+
provisionerId: otherProvisioner.id,
|
|
386
|
+
requiredLabels: ['linux'],
|
|
387
|
+
count: 1,
|
|
388
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
389
|
+
})
|
|
390
|
+
.returning({id: reservations.id});
|
|
391
|
+
const boundReservation = await db()
|
|
392
|
+
.insert(reservations)
|
|
393
|
+
.values({
|
|
394
|
+
workspaceId: crypto.randomUUID(),
|
|
395
|
+
provisionerId,
|
|
396
|
+
requiredLabels: ['linux'],
|
|
397
|
+
count: 1,
|
|
398
|
+
kind: 'bound',
|
|
399
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
400
|
+
})
|
|
401
|
+
.returning({id: reservations.id});
|
|
402
|
+
const reservationIds = [
|
|
403
|
+
expiredReservation[0]?.id,
|
|
404
|
+
foreignReservation[0]?.id,
|
|
405
|
+
boundReservation[0]?.id,
|
|
406
|
+
];
|
|
407
|
+
if (reservationIds.some((reservationId) => !reservationId))
|
|
408
|
+
throw new Error('Reservation insert returned no row');
|
|
409
|
+
|
|
410
|
+
const failureSpy = vi.spyOn(runnerReservationCapacityFailureCount, 'add');
|
|
411
|
+
try {
|
|
412
|
+
for (const reservationId of reservationIds) {
|
|
413
|
+
const response = await app.inject({
|
|
414
|
+
method: 'POST',
|
|
415
|
+
url: '/provisioners/runner-instances/batch',
|
|
416
|
+
headers: {authorization: `Bearer ${token}`},
|
|
417
|
+
payload: {runner_instances: [{reservation_id: reservationId}]},
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
expect(response.statusCode).toBe(200);
|
|
421
|
+
expect(response.json()).toMatchObject({
|
|
422
|
+
runner_instances: [],
|
|
423
|
+
reservation_unavailable: true,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-expired'});
|
|
428
|
+
expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-not-found'});
|
|
429
|
+
expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-kind-mismatch'});
|
|
430
|
+
} finally {
|
|
431
|
+
failureSpy.mockRestore();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const runners = await db()
|
|
435
|
+
.select()
|
|
436
|
+
.from(providerRunners)
|
|
437
|
+
.where(eq(providerRunners.provisionerId, provisionerId));
|
|
438
|
+
const bootstrapTokens = await db()
|
|
439
|
+
.select()
|
|
440
|
+
.from(runnerBootstrapTokens)
|
|
441
|
+
.where(eq(runnerBootstrapTokens.provisionerId, provisionerId));
|
|
442
|
+
expect(runners).toHaveLength(runnersBefore.length);
|
|
443
|
+
expect(bootstrapTokens).toHaveLength(bootstrapTokensBefore.length);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('returns the remaining reservation capacity with request indexes', async () => {
|
|
447
|
+
const workspaceId = crypto.randomUUID();
|
|
448
|
+
const [reservation] = await db()
|
|
449
|
+
.insert(reservations)
|
|
450
|
+
.values({
|
|
451
|
+
workspaceId,
|
|
452
|
+
provisionerId,
|
|
453
|
+
requiredLabels: ['linux'],
|
|
454
|
+
count: 2,
|
|
455
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
456
|
+
})
|
|
457
|
+
.returning();
|
|
458
|
+
if (!reservation) throw new Error('Reservation insert returned no row');
|
|
459
|
+
|
|
460
|
+
const first = await app.inject({
|
|
461
|
+
method: 'POST',
|
|
462
|
+
url: '/provisioners/runner-instances/batch',
|
|
463
|
+
headers: {authorization: `Bearer ${token}`},
|
|
464
|
+
payload: {runner_instances: [{reservation_id: reservation.id}]},
|
|
465
|
+
});
|
|
466
|
+
expect(first.statusCode).toBe(200);
|
|
467
|
+
|
|
468
|
+
const second = await app.inject({
|
|
469
|
+
method: 'POST',
|
|
470
|
+
url: '/provisioners/runner-instances/batch',
|
|
471
|
+
headers: {authorization: `Bearer ${token}`},
|
|
472
|
+
payload: {
|
|
473
|
+
runner_instances: [
|
|
474
|
+
{template_key: 'linux', reservation_id: reservation.id},
|
|
475
|
+
{template_key: 'linux', reservation_id: reservation.id},
|
|
476
|
+
],
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
expect(second.statusCode).toBe(200);
|
|
481
|
+
expect(second.json()).toMatchObject({
|
|
482
|
+
runner_instances: [
|
|
483
|
+
{
|
|
484
|
+
request_index: 0,
|
|
485
|
+
runner_instance_id: expect.any(String),
|
|
486
|
+
bootstrap_token: expect.any(String),
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
reservation_unavailable: true,
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('preserves request indexes when a mixed reservation batch has a shortfall', async () => {
|
|
494
|
+
const [exhaustedReservation, availableReservation] = await db()
|
|
495
|
+
.insert(reservations)
|
|
496
|
+
.values([
|
|
497
|
+
{
|
|
498
|
+
workspaceId: crypto.randomUUID(),
|
|
499
|
+
provisionerId,
|
|
500
|
+
requiredLabels: ['linux'],
|
|
501
|
+
count: 1,
|
|
502
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
workspaceId: crypto.randomUUID(),
|
|
506
|
+
provisionerId,
|
|
507
|
+
requiredLabels: ['linux'],
|
|
508
|
+
count: 1,
|
|
509
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
510
|
+
},
|
|
511
|
+
])
|
|
512
|
+
.returning();
|
|
513
|
+
if (!exhaustedReservation || !availableReservation)
|
|
514
|
+
throw new Error('Reservation insert returned no rows');
|
|
515
|
+
|
|
516
|
+
const consumed = await app.inject({
|
|
517
|
+
method: 'POST',
|
|
518
|
+
url: '/provisioners/runner-instances/batch',
|
|
519
|
+
headers: {authorization: `Bearer ${token}`},
|
|
520
|
+
payload: {runner_instances: [{reservation_id: exhaustedReservation.id}]},
|
|
521
|
+
});
|
|
522
|
+
expect(consumed.statusCode).toBe(200);
|
|
523
|
+
|
|
524
|
+
const mixed = await app.inject({
|
|
525
|
+
method: 'POST',
|
|
526
|
+
url: '/provisioners/runner-instances/batch',
|
|
527
|
+
headers: {authorization: `Bearer ${token}`},
|
|
528
|
+
payload: {
|
|
529
|
+
runner_instances: [
|
|
530
|
+
{template_key: 'linux', reservation_id: exhaustedReservation.id},
|
|
531
|
+
{template_key: 'linux', reservation_id: availableReservation.id},
|
|
532
|
+
],
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
expect(mixed.statusCode).toBe(200);
|
|
537
|
+
expect(mixed.json()).toMatchObject({
|
|
538
|
+
runner_instances: [
|
|
539
|
+
{
|
|
540
|
+
request_index: 1,
|
|
541
|
+
runner_instance_id: expect.any(String),
|
|
542
|
+
bootstrap_token: expect.any(String),
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
reservation_unavailable: true,
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('rejects assignment for a provider-reported runner before enrollment', async () => {
|
|
550
|
+
const workspaceId = crypto.randomUUID();
|
|
551
|
+
const [reservation] = await db()
|
|
552
|
+
.insert(reservations)
|
|
553
|
+
.values({
|
|
554
|
+
workspaceId,
|
|
555
|
+
provisionerId,
|
|
556
|
+
requiredLabels: ['linux'],
|
|
557
|
+
count: 1,
|
|
558
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
559
|
+
})
|
|
560
|
+
.returning();
|
|
561
|
+
if (!reservation) throw new Error('Reservation insert returned no row');
|
|
562
|
+
const [runner] = await db()
|
|
563
|
+
.insert(providerRunners)
|
|
564
|
+
.values({
|
|
565
|
+
provisionerId,
|
|
566
|
+
intendedReservationId: reservation.id,
|
|
567
|
+
reservationId: reservation.id,
|
|
568
|
+
providerRunnerId: crypto.randomUUID(),
|
|
569
|
+
labels: ['linux'],
|
|
570
|
+
state: 'starting',
|
|
571
|
+
reportedAt: new Date(),
|
|
572
|
+
})
|
|
573
|
+
.returning({id: providerRunners.id});
|
|
574
|
+
if (!runner) throw new Error('Runner instance insert returned no row');
|
|
575
|
+
|
|
576
|
+
const assigned = await app.inject({
|
|
577
|
+
method: 'POST',
|
|
578
|
+
url: '/provisioners/runner-instances/assignments',
|
|
579
|
+
headers: {authorization: `Bearer ${token}`},
|
|
580
|
+
payload: {reservation_id: reservation.id, runner_instance_ids: [runner.id]},
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
expect(assigned.statusCode).toBe(409);
|
|
584
|
+
expect(assigned.json()).toMatchObject({code: 'runner-instance-not-assignable'});
|
|
585
|
+
});
|
|
586
|
+
|
|
274
587
|
it('keeps enrollment successful when an intended reservation cannot be promoted', async () => {
|
|
275
588
|
const workspaceId = crypto.randomUUID();
|
|
276
589
|
const [reservation] = await db()
|
|
@@ -42,7 +42,7 @@ export const createRunnerInstancesRoute = defineRoute({
|
|
|
42
42
|
},
|
|
43
43
|
handler: async (request) => {
|
|
44
44
|
const {provisionerTokenId} = requireProvisionerContext(request);
|
|
45
|
-
const
|
|
45
|
+
const result = await createRunnerInstancesWithBootstrapTokens({
|
|
46
46
|
provisionerId: provisionerTokenId,
|
|
47
47
|
...(request.body.provider_kind ? {providerKind: request.body.provider_kind} : {}),
|
|
48
48
|
runnerInstances: request.body.runner_instances.map((runner) => ({
|
|
@@ -52,10 +52,12 @@ export const createRunnerInstancesRoute = defineRoute({
|
|
|
52
52
|
ttlSeconds: config.RUNNER_BOOTSTRAP_TOKEN_TTL_SECONDS,
|
|
53
53
|
});
|
|
54
54
|
return {
|
|
55
|
-
runner_instances:
|
|
56
|
-
runner_instance_id:
|
|
57
|
-
bootstrap_token:
|
|
55
|
+
runner_instances: result.runnerInstances.map((runnerInstance) => ({
|
|
56
|
+
runner_instance_id: runnerInstance.runnerInstanceId,
|
|
57
|
+
bootstrap_token: runnerInstance.bootstrapToken,
|
|
58
|
+
request_index: runnerInstance.requestIndex,
|
|
58
59
|
})),
|
|
60
|
+
...(result.reservationUnavailable ? {reservation_unavailable: true as const} : {}),
|
|
59
61
|
};
|
|
60
62
|
},
|
|
61
63
|
});
|
|
@@ -173,7 +175,7 @@ type RunnerAssignmentPollParams = {
|
|
|
173
175
|
deadline: number;
|
|
174
176
|
intervalMs: number;
|
|
175
177
|
signal: AbortSignal;
|
|
176
|
-
getAssignment: () => Promise<
|
|
178
|
+
getAssignment: () => Promise<{workspaceId: string | null; runnerSessionId: string | null} | null>;
|
|
177
179
|
issueActivationToken: () => Promise<string | null>;
|
|
178
180
|
};
|
|
179
181
|
|
|
@@ -229,6 +231,7 @@ export const runnerAssignmentPollRoute = defineRoute({
|
|
|
229
231
|
issueRunnerActivationToken({
|
|
230
232
|
...session,
|
|
231
233
|
ttlSeconds: config.RUNNER_ACTIVATION_TOKEN_TTL_SECONDS,
|
|
234
|
+
surface: 'poll',
|
|
232
235
|
}),
|
|
233
236
|
});
|
|
234
237
|
return {activation_token: activationToken};
|
|
@@ -7,6 +7,7 @@ interface ReservationAttrs {
|
|
|
7
7
|
provisionerId: string;
|
|
8
8
|
requiredLabels: string[];
|
|
9
9
|
count: number;
|
|
10
|
+
kind?: 'bound' | 'launch';
|
|
10
11
|
expiresAt: Date;
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -19,6 +20,7 @@ export const reservationFactory = Factory.define<ReservationAttrs>(({onCreate})
|
|
|
19
20
|
provisionerId: row.provisionerId,
|
|
20
21
|
requiredLabels: row.requiredLabels,
|
|
21
22
|
count: row.count,
|
|
23
|
+
kind: row.kind,
|
|
22
24
|
expiresAt: row.expiresAt,
|
|
23
25
|
};
|
|
24
26
|
});
|
|
@@ -11,7 +11,9 @@ export const providerRunnerFactory = Factory.define<RunnerInstance>(({onCreate})
|
|
|
11
11
|
workspaceId: providerRunner.workspaceId,
|
|
12
12
|
provisionerId: providerRunner.provisionerId,
|
|
13
13
|
providerRunnerId: providerRunner.providerRunnerId,
|
|
14
|
+
intendedReservationId: providerRunner.intendedReservationId,
|
|
14
15
|
reservationId: providerRunner.reservationId,
|
|
16
|
+
launchKind: providerRunner.launchKind,
|
|
15
17
|
templateKey: providerRunner.templateKey,
|
|
16
18
|
labels: providerRunner.labels,
|
|
17
19
|
state: providerRunner.state,
|
|
@@ -25,6 +27,8 @@ export const providerRunnerFactory = Factory.define<RunnerInstance>(({onCreate})
|
|
|
25
27
|
failedAt: providerRunner.failedAt,
|
|
26
28
|
terminatedAt: providerRunner.terminatedAt,
|
|
27
29
|
reservationReleasedAt: providerRunner.reservationReleasedAt,
|
|
30
|
+
createdAt: providerRunner.createdAt,
|
|
31
|
+
updatedAt: providerRunner.updatedAt,
|
|
28
32
|
})
|
|
29
33
|
.returning();
|
|
30
34
|
|
|
@@ -37,7 +41,9 @@ export const providerRunnerFactory = Factory.define<RunnerInstance>(({onCreate})
|
|
|
37
41
|
workspaceId: crypto.randomUUID(),
|
|
38
42
|
provisionerId: crypto.randomUUID(),
|
|
39
43
|
providerRunnerId: crypto.randomUUID(),
|
|
44
|
+
intendedReservationId: null,
|
|
40
45
|
reservationId: null,
|
|
46
|
+
launchKind: 'manual',
|
|
41
47
|
templateKey: 'linux',
|
|
42
48
|
labels: ['linux'],
|
|
43
49
|
state: 'running',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
|
|
2
|
+
import {db} from '#db/db.js';
|
|
3
|
+
import {deleteReservationsByIds} from '#db/reservations.js';
|
|
4
|
+
import {reservations} from '#db/schema/reservations.js';
|
|
5
|
+
import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
6
|
+
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
7
|
+
|
|
8
|
+
export interface LateRunnerEnrollmentArrangement {
|
|
9
|
+
controlSessionToken: string;
|
|
10
|
+
provisionerId: string;
|
|
11
|
+
reservationId: string;
|
|
12
|
+
runnerInstanceId: string;
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function arrangeExpiredRunnerEnrollment(params: {
|
|
17
|
+
provisionerId: string;
|
|
18
|
+
workspaceId: string;
|
|
19
|
+
}): Promise<LateRunnerEnrollmentArrangement> {
|
|
20
|
+
const reservationId = await insertReservation({
|
|
21
|
+
...params,
|
|
22
|
+
expiresAt: new Date(Date.now() - 1_000),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return await createRunnerEnrollmentArrangement({...params, reservationId});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function arrangeDeletedRunnerEnrollment(params: {
|
|
29
|
+
provisionerId: string;
|
|
30
|
+
workspaceId: string;
|
|
31
|
+
}): Promise<LateRunnerEnrollmentArrangement> {
|
|
32
|
+
const reservationId = await insertReservation({
|
|
33
|
+
...params,
|
|
34
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
35
|
+
});
|
|
36
|
+
const deleted = await deleteReservationsByIds([reservationId]);
|
|
37
|
+
if (deleted !== 1) throw new Error('Expected reservation cleanup to delete one row');
|
|
38
|
+
|
|
39
|
+
// The provider can still enroll from a launch payload carrying the deleted reservation id.
|
|
40
|
+
return await createRunnerEnrollmentArrangement({...params, reservationId});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function insertReservation(params: {
|
|
44
|
+
expiresAt: Date;
|
|
45
|
+
provisionerId: string;
|
|
46
|
+
workspaceId: string;
|
|
47
|
+
}): Promise<string> {
|
|
48
|
+
const [reservation] = await db()
|
|
49
|
+
.insert(reservations)
|
|
50
|
+
.values({
|
|
51
|
+
workspaceId: params.workspaceId,
|
|
52
|
+
provisionerId: params.provisionerId,
|
|
53
|
+
requiredLabels: ['linux'],
|
|
54
|
+
count: 1,
|
|
55
|
+
expiresAt: params.expiresAt,
|
|
56
|
+
})
|
|
57
|
+
.returning({id: reservations.id});
|
|
58
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
59
|
+
|
|
60
|
+
return reservation.id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function createRunnerEnrollmentArrangement(params: {
|
|
64
|
+
provisionerId: string;
|
|
65
|
+
reservationId: string;
|
|
66
|
+
workspaceId: string;
|
|
67
|
+
}): Promise<LateRunnerEnrollmentArrangement> {
|
|
68
|
+
const providerRunnerId = `late-runner-${crypto.randomUUID()}`;
|
|
69
|
+
const controlSessionToken = generateOpaqueToken('runnerControlSession');
|
|
70
|
+
const [runner] = await db()
|
|
71
|
+
.insert(providerRunners)
|
|
72
|
+
.values({
|
|
73
|
+
provisionerId: params.provisionerId,
|
|
74
|
+
providerRunnerId,
|
|
75
|
+
intendedReservationId: params.reservationId,
|
|
76
|
+
templateKey: 'linux',
|
|
77
|
+
labels: ['linux'],
|
|
78
|
+
state: 'starting',
|
|
79
|
+
providerKind: 'docker',
|
|
80
|
+
protocolVersion: '1',
|
|
81
|
+
reportedAt: new Date(),
|
|
82
|
+
})
|
|
83
|
+
.returning({id: providerRunners.id});
|
|
84
|
+
if (!runner) throw new Error('Expected runner instance');
|
|
85
|
+
|
|
86
|
+
await db()
|
|
87
|
+
.insert(runnerControlSessions)
|
|
88
|
+
.values({
|
|
89
|
+
runnerInstanceId: runner.id,
|
|
90
|
+
provisionerId: params.provisionerId,
|
|
91
|
+
hashedToken: hashOpaqueToken(controlSessionToken),
|
|
92
|
+
prefix: extractDisplayPrefix(controlSessionToken),
|
|
93
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
controlSessionToken,
|
|
98
|
+
provisionerId: params.provisionerId,
|
|
99
|
+
reservationId: params.reservationId,
|
|
100
|
+
runnerInstanceId: runner.id,
|
|
101
|
+
workspaceId: params.workspaceId,
|
|
102
|
+
};
|
|
103
|
+
}
|
package/test/index.ts
CHANGED
|
@@ -23,3 +23,8 @@ export {
|
|
|
23
23
|
mintRunnerSessionToken,
|
|
24
24
|
} from './fixtures/auth.js';
|
|
25
25
|
export {runnersTestAuthClient} from './fixtures/auth-inter-module.js';
|
|
26
|
+
export {
|
|
27
|
+
arrangeDeletedRunnerEnrollment,
|
|
28
|
+
arrangeExpiredRunnerEnrollment,
|
|
29
|
+
type LateRunnerEnrollmentArrangement,
|
|
30
|
+
} from './fixtures/late-runner-enrollment.js';
|