@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +53 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/core/runner-activation.d.ts +6 -0
- package/dist/core/runner-activation.d.ts.map +1 -1
- package/dist/core/runner-activation.js +20 -19
- package/dist/core/runner-activation.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts +2 -1
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +65 -19
- package/dist/core/runner-control-sessions.js.map +1 -1
- package/dist/db/db.d.ts +34 -0
- package/dist/db/db.d.ts.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/job-executions.d.ts +7 -0
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +25 -0
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +6 -0
- package/dist/db/runner-assignments.d.ts.map +1 -1
- package/dist/db/runner-assignments.js +57 -42
- package/dist/db/runner-assignments.js.map +1 -1
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +64 -19
- package/dist/db/runner-instances.js.map +1 -1
- package/dist/db/schema/runner-instances.d.ts +17 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +3 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/inter-module.d.ts.map +1 -1
- package/dist/presentation/inter-module.js +7 -2
- package/dist/presentation/inter-module.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +9 -0
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +36 -23
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_intended_reservation.sql +3 -0
- package/drizzle/meta/0001_snapshot.json +2093 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +12 -12
- package/src/config.test.ts +11 -0
- package/src/config.ts +1 -1
- package/src/core/runner-activation.ts +48 -38
- package/src/core/runner-control-sessions.ts +115 -33
- package/src/db/index.ts +1 -0
- package/src/db/job-executions.ts +28 -0
- package/src/db/runner-assignments.test.ts +81 -0
- package/src/db/runner-assignments.ts +134 -86
- package/src/db/runner-instances.test.ts +182 -0
- package/src/db/runner-instances.ts +123 -28
- package/src/db/schema/runner-instances.ts +9 -0
- package/src/index.ts +1 -0
- package/src/presentation/inter-module.ts +4 -0
- package/src/presentation/routes/poll-demand.test.ts +2 -2
- package/src/presentation/routes/runner-enrollment.test.ts +144 -1
- package/src/presentation/routes/runner-enrollment.ts +40 -20
- package/tsconfig.build.tsbuildinfo +1 -1
package/src/index.ts
CHANGED
|
@@ -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
|
|
294
|
-
const templates = Array.from({length:
|
|
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(
|
|
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: {
|
|
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
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
});
|