@shipfox/api-runners 12.2.0 → 12.3.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 (43) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +12 -0
  3. package/dist/config.js +3 -3
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  6. package/dist/core/runner-control-sessions.js +14 -2
  7. package/dist/core/runner-control-sessions.js.map +1 -1
  8. package/dist/db/index.d.ts +1 -1
  9. package/dist/db/index.d.ts.map +1 -1
  10. package/dist/db/index.js +1 -1
  11. package/dist/db/index.js.map +1 -1
  12. package/dist/db/reservations.d.ts.map +1 -1
  13. package/dist/db/reservations.js +22 -6
  14. package/dist/db/reservations.js.map +1 -1
  15. package/dist/db/runner-instances.d.ts +3 -0
  16. package/dist/db/runner-instances.d.ts.map +1 -1
  17. package/dist/db/runner-instances.js +10 -1
  18. package/dist/db/runner-instances.js.map +1 -1
  19. package/dist/db/runner-sessions.d.ts.map +1 -1
  20. package/dist/db/runner-sessions.js +22 -15
  21. package/dist/db/runner-sessions.js.map +1 -1
  22. package/dist/metrics/service.d.ts.map +1 -1
  23. package/dist/metrics/service.js +20 -4
  24. package/dist/metrics/service.js.map +1 -1
  25. package/dist/tsconfig.test.tsbuildinfo +1 -1
  26. package/package.json +3 -3
  27. package/src/config.test.ts +2 -2
  28. package/src/config.ts +3 -3
  29. package/src/core/runner-control-sessions.test.ts +169 -4
  30. package/src/core/runner-control-sessions.ts +22 -1
  31. package/src/db/index.ts +1 -0
  32. package/src/db/reservations.test.ts +369 -1
  33. package/src/db/reservations.ts +74 -9
  34. package/src/db/runner-instances.test.ts +231 -0
  35. package/src/db/runner-instances.ts +32 -1
  36. package/src/db/runner-sessions.ts +26 -15
  37. package/src/metrics/service.test.ts +99 -0
  38. package/src/metrics/service.ts +24 -4
  39. package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
  40. package/src/presentation/routes/poll-demand.test.ts +23 -6
  41. package/test/fixtures/late-runner-enrollment.ts +103 -0
  42. package/test/index.ts +5 -0
  43. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,230 @@
1
+ import {AUTH_PROVISIONER_TOKEN, AUTH_USER, setProvisionerContext} from '@shipfox/api-auth-context';
2
+ import {
3
+ type AuthMethod,
4
+ ClientError,
5
+ closeApp,
6
+ createApp,
7
+ extractBearerToken,
8
+ } from '@shipfox/node-fastify';
9
+ import {vi} from '@shipfox/vitest/vi';
10
+ import {eq} from 'drizzle-orm';
11
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
12
+ import {db} from '#db/db.js';
13
+ import {providerRunners} from '#db/schema/runner-instances.js';
14
+ import {runnerSessions} from '#db/schema/runner-sessions.js';
15
+ import {runnerReservationPromotionFailureCount} from '#metrics/instance.js';
16
+ import {
17
+ createRunnerControlSessionAuthMethod,
18
+ createRunnerRegistrationTokenAuthMethod,
19
+ } from '#presentation/auth/index.js';
20
+ import {
21
+ arrangeDeletedRunnerEnrollment,
22
+ arrangeExpiredRunnerEnrollment,
23
+ fakeLeaseTokenAuthMethod,
24
+ fakeRunnerSessionAuthMethod,
25
+ pendingJobFactory,
26
+ provisionerTokenFactory,
27
+ runnersTestAuthClient,
28
+ } from '#test/index.js';
29
+ import {createRunnerRoutes} from './index.js';
30
+
31
+ let workspaceToken: string;
32
+ const fakeUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
33
+
34
+ describe('late runner enrollment recovery', () => {
35
+ let app: FastifyInstance;
36
+ let provisionerId: string;
37
+ let workspaceId: string;
38
+
39
+ const provisionerAuth: AuthMethod = {
40
+ name: AUTH_PROVISIONER_TOKEN,
41
+ authenticate: (request: FastifyRequest) => {
42
+ if (extractBearerToken(request.headers.authorization) !== workspaceToken)
43
+ throw new ClientError('Invalid provisioner token', 'unauthorized', {status: 401});
44
+ setProvisionerContext(request, {
45
+ scope: 'workspace',
46
+ workspaceId,
47
+ provisionerTokenId: provisionerId,
48
+ });
49
+ return Promise.resolve();
50
+ },
51
+ };
52
+
53
+ beforeAll(async () => {
54
+ app = await createApp({
55
+ auth: [
56
+ fakeUserAuth,
57
+ provisionerAuth,
58
+ createRunnerRegistrationTokenAuthMethod(),
59
+ createRunnerControlSessionAuthMethod(),
60
+ fakeRunnerSessionAuthMethod,
61
+ fakeLeaseTokenAuthMethod,
62
+ ],
63
+ routes: createRunnerRoutes(runnersTestAuthClient),
64
+ swagger: false,
65
+ });
66
+ await app.ready();
67
+ });
68
+
69
+ afterAll(async () => {
70
+ await closeApp();
71
+ });
72
+
73
+ beforeEach(async () => {
74
+ workspaceId = crypto.randomUUID();
75
+ workspaceToken = `late-runner-workspace-provisioner-${crypto.randomUUID()}`;
76
+ const provisioner = await provisionerTokenFactory.create(
77
+ {scope: 'workspace', workspaceId},
78
+ {transient: {rawToken: workspaceToken}},
79
+ );
80
+ provisionerId = provisioner.id;
81
+ });
82
+
83
+ it('keeps an enrolled runner recoverable when its reservation expires first', async () => {
84
+ const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
85
+ const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
86
+
87
+ try {
88
+ await enrollRunner(arrangement.controlSessionToken);
89
+
90
+ expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-expired'});
91
+ } finally {
92
+ failureSpy.mockRestore();
93
+ }
94
+
95
+ const [runner] = await db()
96
+ .select()
97
+ .from(providerRunners)
98
+ .where(eq(providerRunners.id, arrangement.runnerInstanceId));
99
+ expect(runner).toMatchObject({
100
+ workspaceId: null,
101
+ reservationId: null,
102
+ intendedReservationId: arrangement.reservationId,
103
+ assignedAt: null,
104
+ runnerSessionId: null,
105
+ state: 'running',
106
+ providerRunnerId: expect.any(String),
107
+ });
108
+ });
109
+
110
+ it('keeps an enrolled runner recoverable when its reservation was swept away', async () => {
111
+ const arrangement = await arrangeDeletedRunnerEnrollment({provisionerId, workspaceId});
112
+ const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
113
+
114
+ try {
115
+ await enrollRunner(arrangement.controlSessionToken);
116
+
117
+ expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-not-found'});
118
+ } finally {
119
+ failureSpy.mockRestore();
120
+ }
121
+
122
+ const [runner] = await db()
123
+ .select()
124
+ .from(providerRunners)
125
+ .where(eq(providerRunners.id, arrangement.runnerInstanceId));
126
+ expect(runner).toMatchObject({
127
+ workspaceId: null,
128
+ reservationId: null,
129
+ intendedReservationId: arrangement.reservationId,
130
+ assignedAt: null,
131
+ runnerSessionId: null,
132
+ state: 'running',
133
+ providerRunnerId: expect.any(String),
134
+ });
135
+ });
136
+
137
+ it('rebinds a stranded runner through activation and claims its pending job', async () => {
138
+ const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
139
+ await enrollRunner(arrangement.controlSessionToken);
140
+ const pendingJob = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
141
+
142
+ const demand = await app.inject({
143
+ method: 'POST',
144
+ url: '/provisioners/demand/poll',
145
+ headers: {authorization: `Bearer ${workspaceToken}`},
146
+ payload: {
147
+ wait_seconds: 0,
148
+ max_reservations: 1,
149
+ reservation_ttl_seconds: 60,
150
+ templates: [
151
+ {
152
+ template_key: 'linux',
153
+ labels: ['linux'],
154
+ available_slots: 1,
155
+ starting: 0,
156
+ running: 1,
157
+ },
158
+ ],
159
+ },
160
+ });
161
+
162
+ expect(demand.statusCode).toBe(200);
163
+ const reboundReservationId = demand.json().reservations[0]?.reservation_id;
164
+ expect(reboundReservationId).toEqual(expect.any(String));
165
+
166
+ const [reboundRunner] = await db()
167
+ .select()
168
+ .from(providerRunners)
169
+ .where(eq(providerRunners.id, arrangement.runnerInstanceId));
170
+ expect(reboundRunner).toMatchObject({
171
+ workspaceId,
172
+ reservationId: reboundReservationId,
173
+ intendedReservationId: null,
174
+ assignedAt: expect.any(Date),
175
+ runnerSessionId: null,
176
+ state: 'running',
177
+ });
178
+
179
+ const assignment = await app.inject({
180
+ method: 'GET',
181
+ url: '/runner-control/assignment?wait_seconds=1',
182
+ headers: {authorization: `Bearer ${arrangement.controlSessionToken}`},
183
+ });
184
+ expect(assignment.statusCode).toBe(200);
185
+ const activationToken = assignment.json().activation_token;
186
+ expect(activationToken).toEqual(expect.any(String));
187
+
188
+ const registered = await app.inject({
189
+ method: 'POST',
190
+ url: '/runners/register',
191
+ headers: {authorization: `Bearer ${activationToken}`},
192
+ payload: {labels: ['linux']},
193
+ });
194
+ expect(registered.statusCode).toBe(200);
195
+ expect(registered.json()).toMatchObject({mode: 'activation', max_claims: 1});
196
+
197
+ const claimed = await app.inject({
198
+ method: 'POST',
199
+ url: '/runners/jobs/request',
200
+ headers: {authorization: `Bearer ${registered.json().session_token}`},
201
+ });
202
+ expect(claimed.statusCode).toBe(200);
203
+ expect(claimed.json()).toMatchObject({job_id: pendingJob.jobId});
204
+
205
+ const [session] = await db()
206
+ .select()
207
+ .from(runnerSessions)
208
+ .where(eq(runnerSessions.id, registered.json().session_id));
209
+ expect(session).toMatchObject({
210
+ registrationTokenKind: 'activation',
211
+ runnerInstanceId: arrangement.runnerInstanceId,
212
+ provisionerId,
213
+ providerRunnerId: reboundRunner?.providerRunnerId,
214
+ maxClaims: 1,
215
+ claimsUsed: 1,
216
+ });
217
+ });
218
+
219
+ async function enrollRunner(controlSessionToken: string): Promise<void> {
220
+ const enrolled = await app.inject({
221
+ method: 'POST',
222
+ url: '/runner-control/enrollment',
223
+ headers: {authorization: `Bearer ${controlSessionToken}`},
224
+ payload: {labels: ['linux'], provider_kind: 'docker', protocol_version: '1'},
225
+ });
226
+
227
+ expect(enrolled.statusCode).toBe(200);
228
+ expect(enrolled.json()).toEqual({activation_token: null});
229
+ }
230
+ });
@@ -116,13 +116,30 @@ describe('POST /provisioners/demand/poll', () => {
116
116
  method: 'POST',
117
117
  url: '/provisioners/demand/poll',
118
118
  headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
119
- payload: body({max_reservations: 1, reservation_ttl_seconds: 600}),
119
+ payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
120
120
  });
121
121
 
122
122
  expect(res.statusCode).toBe(200);
123
123
  const expiresAt = Date.parse(res.json().reservations[0].expires_at);
124
- expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 295_000);
125
- expect(expiresAt).toBeLessThan(requestStartedAt + 305_000);
124
+ expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 595_000);
125
+ expect(expiresAt).toBeLessThan(requestStartedAt + 605_000);
126
+ });
127
+
128
+ it('accepts a slow-boot reservation TTL below the raised ceiling', async () => {
129
+ await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
130
+
131
+ const requestStartedAt = Date.now();
132
+ const res = await app.inject({
133
+ method: 'POST',
134
+ url: '/provisioners/demand/poll',
135
+ headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
136
+ payload: body({max_reservations: 1, reservation_ttl_seconds: 540}),
137
+ });
138
+
139
+ expect(res.statusCode).toBe(200);
140
+ const expiresAt = Date.parse(res.json().reservations[0].expires_at);
141
+ expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 535_000);
142
+ expect(expiresAt).toBeLessThan(requestStartedAt + 545_000);
126
143
  });
127
144
 
128
145
  it('applies a requested reservation TTL below the ceiling', async () => {
@@ -528,13 +545,13 @@ describe('POST /provisioners/demand/poll with installation provisioning configur
528
545
  method: 'POST',
529
546
  url: '/provisioners/demand/poll',
530
547
  headers: {authorization: `Bearer ${INSTALLATION_PROVISIONER_TOKEN}`},
531
- payload: body({max_reservations: 1, reservation_ttl_seconds: 600}),
548
+ payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
532
549
  });
533
550
 
534
551
  expect(res.statusCode).toBe(200);
535
552
  const expiresAt = Date.parse(res.json().reservations[0].expires_at);
536
- expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 295_000);
537
- expect(expiresAt).toBeLessThan(requestStartedAt + 305_000);
553
+ expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 595_000);
554
+ expect(expiresAt).toBeLessThan(requestStartedAt + 605_000);
538
555
  });
539
556
 
540
557
  function body(params: {max_reservations: number; reservation_ttl_seconds?: number}) {
@@ -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';