@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
|
@@ -0,0 +1,447 @@
|
|
|
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 {and, eq} from 'drizzle-orm';
|
|
11
|
+
import type {FastifyInstance, FastifyRequest} from 'fastify';
|
|
12
|
+
import {db} from '#db/db.js';
|
|
13
|
+
import {reservations} from '#db/schema/reservations.js';
|
|
14
|
+
import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
15
|
+
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
16
|
+
import {runnerSessions} from '#db/schema/runner-sessions.js';
|
|
17
|
+
import {runnerReservationPromotionFailureCount} from '#metrics/instance.js';
|
|
18
|
+
import {
|
|
19
|
+
createRunnerControlSessionAuthMethod,
|
|
20
|
+
createRunnerRegistrationTokenAuthMethod,
|
|
21
|
+
} from '#presentation/auth/index.js';
|
|
22
|
+
import {
|
|
23
|
+
arrangeDeletedRunnerEnrollment,
|
|
24
|
+
arrangeExpiredRunnerEnrollment,
|
|
25
|
+
fakeLeaseTokenAuthMethod,
|
|
26
|
+
fakeRunnerSessionAuthMethod,
|
|
27
|
+
pendingJobFactory,
|
|
28
|
+
provisionerTokenFactory,
|
|
29
|
+
runnersTestAuthClient,
|
|
30
|
+
} from '#test/index.js';
|
|
31
|
+
import {createRunnerRoutes} from './index.js';
|
|
32
|
+
|
|
33
|
+
let workspaceToken: string;
|
|
34
|
+
const fakeUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
|
|
35
|
+
|
|
36
|
+
describe('late runner enrollment recovery', () => {
|
|
37
|
+
let app: FastifyInstance;
|
|
38
|
+
let provisionerId: string;
|
|
39
|
+
let workspaceId: string;
|
|
40
|
+
|
|
41
|
+
const provisionerAuth: AuthMethod = {
|
|
42
|
+
name: AUTH_PROVISIONER_TOKEN,
|
|
43
|
+
authenticate: (request: FastifyRequest) => {
|
|
44
|
+
if (extractBearerToken(request.headers.authorization) !== workspaceToken)
|
|
45
|
+
throw new ClientError('Invalid provisioner token', 'unauthorized', {status: 401});
|
|
46
|
+
setProvisionerContext(request, {
|
|
47
|
+
scope: 'workspace',
|
|
48
|
+
workspaceId,
|
|
49
|
+
provisionerTokenId: provisionerId,
|
|
50
|
+
});
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
app = await createApp({
|
|
57
|
+
auth: [
|
|
58
|
+
fakeUserAuth,
|
|
59
|
+
provisionerAuth,
|
|
60
|
+
createRunnerRegistrationTokenAuthMethod(),
|
|
61
|
+
createRunnerControlSessionAuthMethod(),
|
|
62
|
+
fakeRunnerSessionAuthMethod,
|
|
63
|
+
fakeLeaseTokenAuthMethod,
|
|
64
|
+
],
|
|
65
|
+
routes: createRunnerRoutes(runnersTestAuthClient),
|
|
66
|
+
swagger: false,
|
|
67
|
+
});
|
|
68
|
+
await app.ready();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
await closeApp();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
beforeEach(async () => {
|
|
76
|
+
workspaceId = crypto.randomUUID();
|
|
77
|
+
workspaceToken = `late-runner-workspace-provisioner-${crypto.randomUUID()}`;
|
|
78
|
+
const provisioner = await provisionerTokenFactory.create(
|
|
79
|
+
{scope: 'workspace', workspaceId},
|
|
80
|
+
{transient: {rawToken: workspaceToken}},
|
|
81
|
+
);
|
|
82
|
+
provisionerId = provisioner.id;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps an enrolled runner recoverable when its reservation expires first', async () => {
|
|
86
|
+
const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
|
|
87
|
+
const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await enrollRunner(arrangement.controlSessionToken);
|
|
91
|
+
|
|
92
|
+
expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-expired'});
|
|
93
|
+
} finally {
|
|
94
|
+
failureSpy.mockRestore();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [runner] = await db()
|
|
98
|
+
.select()
|
|
99
|
+
.from(providerRunners)
|
|
100
|
+
.where(eq(providerRunners.id, arrangement.runnerInstanceId));
|
|
101
|
+
expect(runner).toMatchObject({
|
|
102
|
+
workspaceId: null,
|
|
103
|
+
reservationId: null,
|
|
104
|
+
intendedReservationId: arrangement.reservationId,
|
|
105
|
+
assignedAt: null,
|
|
106
|
+
runnerSessionId: null,
|
|
107
|
+
state: 'running',
|
|
108
|
+
providerRunnerId: expect.any(String),
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('keeps an enrolled runner recoverable when its reservation was swept away', async () => {
|
|
113
|
+
const arrangement = await arrangeDeletedRunnerEnrollment({provisionerId, workspaceId});
|
|
114
|
+
const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await enrollRunner(arrangement.controlSessionToken);
|
|
118
|
+
|
|
119
|
+
expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-not-found'});
|
|
120
|
+
} finally {
|
|
121
|
+
failureSpy.mockRestore();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [runner] = await db()
|
|
125
|
+
.select()
|
|
126
|
+
.from(providerRunners)
|
|
127
|
+
.where(eq(providerRunners.id, arrangement.runnerInstanceId));
|
|
128
|
+
expect(runner).toMatchObject({
|
|
129
|
+
workspaceId: null,
|
|
130
|
+
reservationId: null,
|
|
131
|
+
intendedReservationId: arrangement.reservationId,
|
|
132
|
+
assignedAt: null,
|
|
133
|
+
runnerSessionId: null,
|
|
134
|
+
state: 'running',
|
|
135
|
+
providerRunnerId: expect.any(String),
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('rebinds a stranded runner through activation and claims its pending job', async () => {
|
|
140
|
+
const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
|
|
141
|
+
await enrollRunner(arrangement.controlSessionToken);
|
|
142
|
+
const pendingJob = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
143
|
+
|
|
144
|
+
const demand = await app.inject({
|
|
145
|
+
method: 'POST',
|
|
146
|
+
url: '/provisioners/demand/poll',
|
|
147
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
148
|
+
payload: {
|
|
149
|
+
wait_seconds: 0,
|
|
150
|
+
max_reservations: 1,
|
|
151
|
+
reservation_ttl_seconds: 60,
|
|
152
|
+
templates: [
|
|
153
|
+
{
|
|
154
|
+
template_key: 'linux',
|
|
155
|
+
labels: ['linux'],
|
|
156
|
+
available_slots: 1,
|
|
157
|
+
starting: 0,
|
|
158
|
+
running: 1,
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(demand.statusCode).toBe(200);
|
|
165
|
+
expect(demand.json().reservations).toEqual([]);
|
|
166
|
+
const reboundReservations = await db()
|
|
167
|
+
.select()
|
|
168
|
+
.from(reservations)
|
|
169
|
+
.where(
|
|
170
|
+
and(
|
|
171
|
+
eq(reservations.workspaceId, workspaceId),
|
|
172
|
+
eq(reservations.provisionerId, provisionerId),
|
|
173
|
+
eq(reservations.kind, 'bound'),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
const reboundReservation = reboundReservations.find(
|
|
177
|
+
(reservation) => reservation.id !== arrangement.reservationId,
|
|
178
|
+
);
|
|
179
|
+
const reboundReservationId = reboundReservation?.id;
|
|
180
|
+
if (!reboundReservationId) throw new Error('Expected rebound reservation');
|
|
181
|
+
expect(reboundReservationId).toEqual(expect.any(String));
|
|
182
|
+
|
|
183
|
+
const [reboundRunner] = await db()
|
|
184
|
+
.select()
|
|
185
|
+
.from(providerRunners)
|
|
186
|
+
.where(eq(providerRunners.id, arrangement.runnerInstanceId));
|
|
187
|
+
expect(reboundRunner).toMatchObject({
|
|
188
|
+
workspaceId,
|
|
189
|
+
reservationId: reboundReservationId,
|
|
190
|
+
intendedReservationId: null,
|
|
191
|
+
assignedAt: expect.any(Date),
|
|
192
|
+
runnerSessionId: null,
|
|
193
|
+
state: 'running',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const assignment = await app.inject({
|
|
197
|
+
method: 'GET',
|
|
198
|
+
url: '/runner-control/assignment?wait_seconds=1',
|
|
199
|
+
headers: {authorization: `Bearer ${arrangement.controlSessionToken}`},
|
|
200
|
+
});
|
|
201
|
+
expect(assignment.statusCode).toBe(200);
|
|
202
|
+
const activationToken = assignment.json().activation_token;
|
|
203
|
+
expect(activationToken).toEqual(expect.any(String));
|
|
204
|
+
|
|
205
|
+
const registered = await app.inject({
|
|
206
|
+
method: 'POST',
|
|
207
|
+
url: '/runners/register',
|
|
208
|
+
headers: {authorization: `Bearer ${activationToken}`},
|
|
209
|
+
payload: {labels: ['linux']},
|
|
210
|
+
});
|
|
211
|
+
expect(registered.statusCode).toBe(200);
|
|
212
|
+
expect(registered.json()).toMatchObject({mode: 'activation', max_claims: 1});
|
|
213
|
+
|
|
214
|
+
const claimed = await app.inject({
|
|
215
|
+
method: 'POST',
|
|
216
|
+
url: '/runners/jobs/request',
|
|
217
|
+
headers: {authorization: `Bearer ${registered.json().session_token}`},
|
|
218
|
+
});
|
|
219
|
+
expect(claimed.statusCode).toBe(200);
|
|
220
|
+
expect(claimed.json()).toMatchObject({job_id: pendingJob.jobId});
|
|
221
|
+
|
|
222
|
+
const [session] = await db()
|
|
223
|
+
.select()
|
|
224
|
+
.from(runnerSessions)
|
|
225
|
+
.where(eq(runnerSessions.id, registered.json().session_id));
|
|
226
|
+
expect(session).toMatchObject({
|
|
227
|
+
registrationTokenKind: 'activation',
|
|
228
|
+
runnerInstanceId: arrangement.runnerInstanceId,
|
|
229
|
+
provisionerId,
|
|
230
|
+
providerRunnerId: reboundRunner?.providerRunnerId,
|
|
231
|
+
maxClaims: 1,
|
|
232
|
+
claimsUsed: 1,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('preserves a rebound assignment across stale provider reports and releases it once', async () => {
|
|
237
|
+
const providerRunnerId = `stale-assignment-${crypto.randomUUID()}`;
|
|
238
|
+
const firstReportAt = new Date(Date.now() - 2_000);
|
|
239
|
+
const [staleReservation] = await db()
|
|
240
|
+
.insert(reservations)
|
|
241
|
+
.values({
|
|
242
|
+
workspaceId,
|
|
243
|
+
provisionerId,
|
|
244
|
+
requiredLabels: ['linux'],
|
|
245
|
+
count: 1,
|
|
246
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
247
|
+
})
|
|
248
|
+
.returning({id: reservations.id});
|
|
249
|
+
if (!staleReservation) throw new Error('Expected stale reservation');
|
|
250
|
+
|
|
251
|
+
const initialReport = await app.inject({
|
|
252
|
+
method: 'POST',
|
|
253
|
+
url: '/provisioners/runner-instances/report',
|
|
254
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
255
|
+
payload: {
|
|
256
|
+
events: [
|
|
257
|
+
{
|
|
258
|
+
provider_runner_id: providerRunnerId,
|
|
259
|
+
reservation_id: staleReservation.id,
|
|
260
|
+
template_key: 'linux',
|
|
261
|
+
labels: ['linux'],
|
|
262
|
+
state: 'running',
|
|
263
|
+
reported_at: firstReportAt.toISOString(),
|
|
264
|
+
provider_kind: 'docker',
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(initialReport.statusCode).toBe(200);
|
|
271
|
+
expect(initialReport.json()).toEqual({accepted: 1, reservations_released: 0});
|
|
272
|
+
|
|
273
|
+
const [reportedRunner] = await db()
|
|
274
|
+
.select()
|
|
275
|
+
.from(providerRunners)
|
|
276
|
+
.where(
|
|
277
|
+
and(
|
|
278
|
+
eq(providerRunners.provisionerId, provisionerId),
|
|
279
|
+
eq(providerRunners.providerRunnerId, providerRunnerId),
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
if (!reportedRunner) throw new Error('Expected reported runner');
|
|
283
|
+
expect(reportedRunner).toMatchObject({
|
|
284
|
+
workspaceId,
|
|
285
|
+
reservationId: staleReservation.id,
|
|
286
|
+
state: 'running',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
await db()
|
|
290
|
+
.insert(runnerControlSessions)
|
|
291
|
+
.values({
|
|
292
|
+
runnerInstanceId: reportedRunner.id,
|
|
293
|
+
provisionerId,
|
|
294
|
+
hashedToken: crypto.randomUUID(),
|
|
295
|
+
prefix: 'test',
|
|
296
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
297
|
+
});
|
|
298
|
+
await db()
|
|
299
|
+
.update(reservations)
|
|
300
|
+
.set({expiresAt: new Date(Date.now() - 1_000)})
|
|
301
|
+
.where(eq(reservations.id, staleReservation.id));
|
|
302
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
303
|
+
|
|
304
|
+
const demand = await app.inject({
|
|
305
|
+
method: 'POST',
|
|
306
|
+
url: '/provisioners/demand/poll',
|
|
307
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
308
|
+
payload: {
|
|
309
|
+
wait_seconds: 0,
|
|
310
|
+
max_reservations: 1,
|
|
311
|
+
reservation_ttl_seconds: 60,
|
|
312
|
+
templates: [
|
|
313
|
+
{
|
|
314
|
+
template_key: 'linux',
|
|
315
|
+
labels: ['linux'],
|
|
316
|
+
available_slots: 1,
|
|
317
|
+
starting: 0,
|
|
318
|
+
running: 1,
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(demand.statusCode).toBe(200);
|
|
325
|
+
expect(demand.json().reservations).toEqual([]);
|
|
326
|
+
const reboundReservations = await db()
|
|
327
|
+
.select()
|
|
328
|
+
.from(reservations)
|
|
329
|
+
.where(
|
|
330
|
+
and(
|
|
331
|
+
eq(reservations.workspaceId, workspaceId),
|
|
332
|
+
eq(reservations.provisionerId, provisionerId),
|
|
333
|
+
eq(reservations.kind, 'bound'),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
const reboundReservation = reboundReservations.find(
|
|
337
|
+
(reservation) => reservation.id !== staleReservation.id,
|
|
338
|
+
);
|
|
339
|
+
const reboundReservationId = reboundReservation?.id;
|
|
340
|
+
if (!reboundReservationId) throw new Error('Expected rebound reservation');
|
|
341
|
+
expect(reboundReservationId).toEqual(expect.any(String));
|
|
342
|
+
expect(reboundReservationId).not.toBe(staleReservation.id);
|
|
343
|
+
|
|
344
|
+
const staleReport = await app.inject({
|
|
345
|
+
method: 'POST',
|
|
346
|
+
url: '/provisioners/runner-instances/report',
|
|
347
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
348
|
+
payload: {
|
|
349
|
+
events: [
|
|
350
|
+
{
|
|
351
|
+
provider_runner_id: providerRunnerId,
|
|
352
|
+
reservation_id: staleReservation.id,
|
|
353
|
+
template_key: 'linux',
|
|
354
|
+
labels: ['linux'],
|
|
355
|
+
state: 'running',
|
|
356
|
+
reported_at: new Date(firstReportAt.getTime() + 1_000).toISOString(),
|
|
357
|
+
provider_kind: 'docker',
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(staleReport.statusCode).toBe(200);
|
|
364
|
+
expect(staleReport.json()).toEqual({accepted: 1, reservations_released: 0});
|
|
365
|
+
|
|
366
|
+
const [reboundRunner] = await db()
|
|
367
|
+
.select()
|
|
368
|
+
.from(providerRunners)
|
|
369
|
+
.where(eq(providerRunners.id, reportedRunner.id));
|
|
370
|
+
expect(reboundRunner).toMatchObject({
|
|
371
|
+
workspaceId,
|
|
372
|
+
reservationId: reboundReservationId,
|
|
373
|
+
state: 'running',
|
|
374
|
+
intendedReservationId: null,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const terminalReport = await app.inject({
|
|
378
|
+
method: 'POST',
|
|
379
|
+
url: '/provisioners/runner-instances/report',
|
|
380
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
381
|
+
payload: {
|
|
382
|
+
events: [
|
|
383
|
+
{
|
|
384
|
+
provider_runner_id: providerRunnerId,
|
|
385
|
+
reservation_id: staleReservation.id,
|
|
386
|
+
template_key: 'linux',
|
|
387
|
+
labels: ['linux'],
|
|
388
|
+
state: 'terminated',
|
|
389
|
+
reported_at: new Date(firstReportAt.getTime() + 2_000).toISOString(),
|
|
390
|
+
provider_kind: 'docker',
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(terminalReport.statusCode).toBe(200);
|
|
397
|
+
expect(terminalReport.json()).toEqual({accepted: 1, reservations_released: 1});
|
|
398
|
+
|
|
399
|
+
const [staleReservationAfterTerminal] = await db()
|
|
400
|
+
.select()
|
|
401
|
+
.from(reservations)
|
|
402
|
+
.where(eq(reservations.id, staleReservation.id));
|
|
403
|
+
const [reboundReservationAfterTerminal] = await db()
|
|
404
|
+
.select()
|
|
405
|
+
.from(reservations)
|
|
406
|
+
.where(eq(reservations.id, reboundReservationId));
|
|
407
|
+
expect(staleReservationAfterTerminal).toMatchObject({
|
|
408
|
+
id: staleReservation.id,
|
|
409
|
+
count: 1,
|
|
410
|
+
});
|
|
411
|
+
expect(reboundReservationAfterTerminal).toBeUndefined();
|
|
412
|
+
|
|
413
|
+
const repeatedTerminalReport = await app.inject({
|
|
414
|
+
method: 'POST',
|
|
415
|
+
url: '/provisioners/runner-instances/report',
|
|
416
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
417
|
+
payload: {
|
|
418
|
+
events: [
|
|
419
|
+
{
|
|
420
|
+
provider_runner_id: providerRunnerId,
|
|
421
|
+
reservation_id: staleReservation.id,
|
|
422
|
+
template_key: 'linux',
|
|
423
|
+
labels: ['linux'],
|
|
424
|
+
state: 'terminated',
|
|
425
|
+
reported_at: new Date(firstReportAt.getTime() + 3_000).toISOString(),
|
|
426
|
+
provider_kind: 'docker',
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
expect(repeatedTerminalReport.statusCode).toBe(200);
|
|
433
|
+
expect(repeatedTerminalReport.json()).toEqual({accepted: 1, reservations_released: 0});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
async function enrollRunner(controlSessionToken: string): Promise<void> {
|
|
437
|
+
const enrolled = await app.inject({
|
|
438
|
+
method: 'POST',
|
|
439
|
+
url: '/runner-control/enrollment',
|
|
440
|
+
headers: {authorization: `Bearer ${controlSessionToken}`},
|
|
441
|
+
payload: {labels: ['linux'], provider_kind: 'docker', protocol_version: '1'},
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(enrolled.statusCode).toBe(200);
|
|
445
|
+
expect(enrolled.json()).toEqual({activation_token: null});
|
|
446
|
+
}
|
|
447
|
+
});
|
|
@@ -20,6 +20,7 @@ import {db} from '#db/db.js';
|
|
|
20
20
|
import {provisionerCapabilitySnapshots} from '#db/schema/provisioner-capability-snapshots.js';
|
|
21
21
|
import {runningJobExecutions} from '#db/schema/running-job-executions.js';
|
|
22
22
|
import {
|
|
23
|
+
providerRunnerActivationOutcomeCount,
|
|
23
24
|
providerRunnerCountDivergenceCount,
|
|
24
25
|
providerRunnerTerminateIntentIssuedCount,
|
|
25
26
|
} from '#metrics/instance.js';
|
|
@@ -99,6 +100,7 @@ describe('POST /provisioners/demand/poll', () => {
|
|
|
99
100
|
expect(res.json()).toMatchObject({
|
|
100
101
|
stats: [{labels: ['linux'], queued: 1, reserved: 1}],
|
|
101
102
|
reservations: [{labels: ['linux'], count: 1}],
|
|
103
|
+
newly_reserved_count: 1,
|
|
102
104
|
terminate_provider_runner_ids: [],
|
|
103
105
|
});
|
|
104
106
|
expect(res.json().reservations[0].reservation_id).toEqual(expect.any(String));
|
|
@@ -116,13 +118,30 @@ describe('POST /provisioners/demand/poll', () => {
|
|
|
116
118
|
method: 'POST',
|
|
117
119
|
url: '/provisioners/demand/poll',
|
|
118
120
|
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
119
|
-
payload: body({max_reservations: 1, reservation_ttl_seconds:
|
|
121
|
+
payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
|
|
120
122
|
});
|
|
121
123
|
|
|
122
124
|
expect(res.statusCode).toBe(200);
|
|
123
125
|
const expiresAt = Date.parse(res.json().reservations[0].expires_at);
|
|
124
|
-
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt +
|
|
125
|
-
expect(expiresAt).toBeLessThan(requestStartedAt +
|
|
126
|
+
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 595_000);
|
|
127
|
+
expect(expiresAt).toBeLessThan(requestStartedAt + 605_000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('accepts a slow-boot reservation TTL below the raised ceiling', async () => {
|
|
131
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
132
|
+
|
|
133
|
+
const requestStartedAt = Date.now();
|
|
134
|
+
const res = await app.inject({
|
|
135
|
+
method: 'POST',
|
|
136
|
+
url: '/provisioners/demand/poll',
|
|
137
|
+
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
138
|
+
payload: body({max_reservations: 1, reservation_ttl_seconds: 540}),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(res.statusCode).toBe(200);
|
|
142
|
+
const expiresAt = Date.parse(res.json().reservations[0].expires_at);
|
|
143
|
+
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 535_000);
|
|
144
|
+
expect(expiresAt).toBeLessThan(requestStartedAt + 545_000);
|
|
126
145
|
});
|
|
127
146
|
|
|
128
147
|
it('applies a requested reservation TTL below the ceiling', async () => {
|
|
@@ -350,6 +369,60 @@ describe('POST /provisioners/demand/poll', () => {
|
|
|
350
369
|
expect(intentCalls).toHaveLength(1);
|
|
351
370
|
});
|
|
352
371
|
|
|
372
|
+
it('counts activation-timeout reaps once across terminate-intent retries', async () => {
|
|
373
|
+
const intentSpy = vi.spyOn(providerRunnerTerminateIntentIssuedCount, 'add');
|
|
374
|
+
const outcomeSpy = vi.spyOn(providerRunnerActivationOutcomeCount, 'add');
|
|
375
|
+
await providerRunnerFactory.create({
|
|
376
|
+
workspaceId,
|
|
377
|
+
provisionerId: provisionerTokenId,
|
|
378
|
+
providerRunnerId: 'stale-demand-runner',
|
|
379
|
+
launchKind: 'demand',
|
|
380
|
+
createdAt: new Date(Date.now() - 301_000),
|
|
381
|
+
templateKey: 'linux',
|
|
382
|
+
state: 'running',
|
|
383
|
+
});
|
|
384
|
+
const intentCallsBefore = intentSpy.mock.calls.length;
|
|
385
|
+
const outcomeCallsBefore = outcomeSpy.mock.calls.length;
|
|
386
|
+
|
|
387
|
+
const poll = () =>
|
|
388
|
+
app.inject({
|
|
389
|
+
method: 'POST',
|
|
390
|
+
url: '/provisioners/demand/poll',
|
|
391
|
+
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
392
|
+
payload: body({max_reservations: 0}),
|
|
393
|
+
});
|
|
394
|
+
const firstResponse = await poll();
|
|
395
|
+
const retryResponse = await poll();
|
|
396
|
+
|
|
397
|
+
expect(firstResponse.statusCode).toBe(200);
|
|
398
|
+
expect(retryResponse.statusCode).toBe(200);
|
|
399
|
+
expect(firstResponse.json()).toMatchObject({
|
|
400
|
+
reservations: [],
|
|
401
|
+
terminate_provider_runner_ids: ['stale-demand-runner'],
|
|
402
|
+
});
|
|
403
|
+
expect(retryResponse.json()).toMatchObject({
|
|
404
|
+
reservations: [],
|
|
405
|
+
terminate_provider_runner_ids: ['stale-demand-runner'],
|
|
406
|
+
});
|
|
407
|
+
const intentCalls = intentSpy.mock.calls
|
|
408
|
+
.slice(intentCallsBefore)
|
|
409
|
+
.filter(
|
|
410
|
+
([value, attributes]) =>
|
|
411
|
+
value === 1 &&
|
|
412
|
+
JSON.stringify(attributes) ===
|
|
413
|
+
JSON.stringify({surface: 'poll-demand', reason: 'activation-timeout'}),
|
|
414
|
+
);
|
|
415
|
+
expect(intentCalls).toHaveLength(2);
|
|
416
|
+
expect(
|
|
417
|
+
outcomeSpy.mock.calls
|
|
418
|
+
.slice(outcomeCallsBefore)
|
|
419
|
+
.filter(
|
|
420
|
+
([value, attributes]) =>
|
|
421
|
+
value === 1 && JSON.stringify(attributes) === JSON.stringify({outcome: 'reaped'}),
|
|
422
|
+
),
|
|
423
|
+
).toHaveLength(1);
|
|
424
|
+
});
|
|
425
|
+
|
|
353
426
|
it('returns 400 for max reservations above the request bound', async () => {
|
|
354
427
|
const res = await app.inject({
|
|
355
428
|
method: 'POST',
|
|
@@ -528,13 +601,13 @@ describe('POST /provisioners/demand/poll with installation provisioning configur
|
|
|
528
601
|
method: 'POST',
|
|
529
602
|
url: '/provisioners/demand/poll',
|
|
530
603
|
headers: {authorization: `Bearer ${INSTALLATION_PROVISIONER_TOKEN}`},
|
|
531
|
-
payload: body({max_reservations: 1, reservation_ttl_seconds:
|
|
604
|
+
payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
|
|
532
605
|
});
|
|
533
606
|
|
|
534
607
|
expect(res.statusCode).toBe(200);
|
|
535
608
|
const expiresAt = Date.parse(res.json().reservations[0].expires_at);
|
|
536
|
-
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt +
|
|
537
|
-
expect(expiresAt).toBeLessThan(requestStartedAt +
|
|
609
|
+
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 595_000);
|
|
610
|
+
expect(expiresAt).toBeLessThan(requestStartedAt + 605_000);
|
|
538
611
|
});
|
|
539
612
|
|
|
540
613
|
function body(params: {max_reservations: number; reservation_ttl_seconds?: number}) {
|
|
@@ -98,6 +98,7 @@ export function createPollDemandRoute(options: CreateRunnersModuleOptions = {})
|
|
|
98
98
|
provisionerId: provisionerContext.provisionerTokenId,
|
|
99
99
|
maxReservations: request.body.max_reservations,
|
|
100
100
|
ttlSeconds,
|
|
101
|
+
activationGraceSeconds: config.RESERVATION_TTL_SECONDS,
|
|
101
102
|
templates,
|
|
102
103
|
capabilityWindowSeconds: config.PROVISIONER_ACTIVE_WINDOW_SECONDS,
|
|
103
104
|
eligibleWorkspaceIds,
|
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
AUTH_USER,
|
|
7
7
|
setProvisionerContext,
|
|
8
8
|
} from '@shipfox/api-auth-context';
|
|
9
|
+
import {
|
|
10
|
+
RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER,
|
|
11
|
+
RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
|
|
12
|
+
} from '@shipfox/api-runners-dto';
|
|
9
13
|
import {
|
|
10
14
|
type AuthMethod,
|
|
11
15
|
ClientError,
|
|
@@ -86,7 +90,11 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
|
|
|
86
90
|
const jobId = crypto.randomUUID();
|
|
87
91
|
const workflowRunId = crypto.randomUUID();
|
|
88
92
|
const workflowRunAttemptId = crypto.randomUUID();
|
|
89
|
-
|
|
93
|
+
const intendedReservationId = crypto.randomUUID();
|
|
94
|
+
await createRunnerInstance({
|
|
95
|
+
providerRunnerId: 'provisioned-runner-1',
|
|
96
|
+
intendedReservationId,
|
|
97
|
+
});
|
|
90
98
|
await insertRunningJob({
|
|
91
99
|
jobId,
|
|
92
100
|
workflowRunId,
|
|
@@ -98,7 +106,11 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
|
|
|
98
106
|
const res = await app.inject({
|
|
99
107
|
method: 'POST',
|
|
100
108
|
url: '/provisioners/runner-instances/reconcile',
|
|
101
|
-
headers: {
|
|
109
|
+
headers: {
|
|
110
|
+
authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`,
|
|
111
|
+
[RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER]:
|
|
112
|
+
RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
|
|
113
|
+
},
|
|
102
114
|
payload: {observed_provider_runner_ids: ['provisioned-runner-1']},
|
|
103
115
|
});
|
|
104
116
|
|
|
@@ -108,6 +120,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
|
|
|
108
120
|
{
|
|
109
121
|
provider_runner_id: 'provisioned-runner-1',
|
|
110
122
|
state: 'running',
|
|
123
|
+
intended_reservation_id: intendedReservationId,
|
|
111
124
|
reservation_id: null,
|
|
112
125
|
runner_session_id: null,
|
|
113
126
|
bound_job: {
|
|
@@ -123,6 +136,23 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
|
|
|
123
136
|
});
|
|
124
137
|
});
|
|
125
138
|
|
|
139
|
+
it('omits the additive reservation field for legacy provisioners', async () => {
|
|
140
|
+
await createRunnerInstance({
|
|
141
|
+
providerRunnerId: 'provisioned-runner-1',
|
|
142
|
+
intendedReservationId: crypto.randomUUID(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const res = await app.inject({
|
|
146
|
+
method: 'POST',
|
|
147
|
+
url: '/provisioners/runner-instances/reconcile',
|
|
148
|
+
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
149
|
+
payload: {observed_provider_runner_ids: ['provisioned-runner-1']},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(res.statusCode).toBe(200);
|
|
153
|
+
expect(Object.hasOwn(res.json().runners[0], 'intended_reservation_id')).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
126
156
|
it('returns terminate for an observed terminal runner', async () => {
|
|
127
157
|
await createRunnerInstance({
|
|
128
158
|
providerRunnerId: 'provisioned-runner-1',
|
|
@@ -340,6 +370,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
|
|
|
340
370
|
async function createRunnerInstance(params: {
|
|
341
371
|
providerRunnerId: string;
|
|
342
372
|
state?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'terminated';
|
|
373
|
+
intendedReservationId?: string | null;
|
|
343
374
|
reservationId?: string | null;
|
|
344
375
|
reportedAt?: Date;
|
|
345
376
|
}) {
|
|
@@ -347,6 +378,7 @@ describe('POST /provisioners/runner-instances/reconcile', () => {
|
|
|
347
378
|
workspaceId,
|
|
348
379
|
provisionerId: provisionerTokenId,
|
|
349
380
|
providerRunnerId: params.providerRunnerId,
|
|
381
|
+
intendedReservationId: params.intendedReservationId ?? null,
|
|
350
382
|
reservationId: params.reservationId ?? null,
|
|
351
383
|
state: params.state ?? 'running',
|
|
352
384
|
reportedAt: params.reportedAt ?? new Date(),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {requireProvisionerContext} from '@shipfox/api-auth-context';
|
|
2
2
|
import {
|
|
3
|
+
RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER,
|
|
4
|
+
RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
|
|
3
5
|
reconcileRunnerInstancesBodySchema,
|
|
4
6
|
reconcileRunnerInstancesResponseSchema,
|
|
5
7
|
} from '@shipfox/api-runners-dto';
|
|
@@ -25,6 +27,10 @@ export const reconcileRunnerInstancesRoute = defineRoute({
|
|
|
25
27
|
observedRunnerInstanceIds: request.body.observed_provider_runner_ids,
|
|
26
28
|
});
|
|
27
29
|
|
|
28
|
-
return toReconcileRunnerInstancesResponseDto(result
|
|
30
|
+
return toReconcileRunnerInstancesResponseDto(result, {
|
|
31
|
+
includeIntendedReservationId:
|
|
32
|
+
request.headers[RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER] ===
|
|
33
|
+
RECONCILE_RUNNER_INSTANCES_INTENDED_RESERVATION_HEADER_VALUE,
|
|
34
|
+
});
|
|
29
35
|
},
|
|
30
36
|
});
|