@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +12 -0
- package/dist/config.js +3 -3
- package/dist/config.js.map +1 -1
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +14 -2
- package/dist/core/runner-control-sessions.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/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +22 -6
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-instances.d.ts +3 -0
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +10 -1
- 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/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +20 -4
- package/dist/metrics/service.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/config.test.ts +2 -2
- package/src/config.ts +3 -3
- package/src/core/runner-control-sessions.test.ts +169 -4
- package/src/core/runner-control-sessions.ts +22 -1
- package/src/db/index.ts +1 -0
- package/src/db/reservations.test.ts +369 -1
- package/src/db/reservations.ts +74 -9
- package/src/db/runner-instances.test.ts +231 -0
- package/src/db/runner-instances.ts +32 -1
- package/src/db/runner-sessions.ts +26 -15
- package/src/metrics/service.test.ts +99 -0
- package/src/metrics/service.ts +24 -4
- package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
- package/src/presentation/routes/poll-demand.test.ts +23 -6
- package/test/fixtures/late-runner-enrollment.ts +103 -0
- package/test/index.ts +5 -0
- 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:
|
|
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 +
|
|
125
|
-
expect(expiresAt).toBeLessThan(requestStartedAt +
|
|
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:
|
|
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 +
|
|
537
|
-
expect(expiresAt).toBeLessThan(requestStartedAt +
|
|
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';
|