@shipfox/api-runners 6.0.0 → 7.0.2
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 +26 -0
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/core/entities/runner-instance.d.ts +1 -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/errors.d.ts +6 -6
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +8 -8
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +2 -4
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -3
- package/dist/core/index.js.map +1 -1
- package/dist/core/runner-assignments.d.ts +6 -0
- package/dist/core/runner-assignments.d.ts.map +1 -0
- package/dist/core/runner-assignments.js +6 -0
- package/dist/core/runner-assignments.js.map +1 -0
- package/dist/core/runner-instances.d.ts +2 -9
- package/dist/core/runner-instances.d.ts.map +1 -1
- package/dist/core/runner-instances.js +3 -6
- package/dist/core/runner-instances.js.map +1 -1
- package/dist/db/db.d.ts +34 -218
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +0 -2
- package/dist/db/db.js.map +1 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +2 -2
- package/dist/db/index.js.map +1 -1
- package/dist/db/runner-assignments.d.ts +7 -0
- package/dist/db/runner-assignments.d.ts.map +1 -0
- package/dist/db/runner-assignments.js +55 -0
- package/dist/db/runner-assignments.js.map +1 -0
- package/dist/db/runner-instances.d.ts +2 -9
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +2 -16
- 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 +5 -1
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/index.d.ts +0 -1
- package/dist/presentation/dto/index.d.ts.map +1 -1
- package/dist/presentation/dto/index.js +0 -1
- package/dist/presentation/dto/index.js.map +1 -1
- package/dist/presentation/routes/assign-runner-instances.d.ts +2 -0
- package/dist/presentation/routes/assign-runner-instances.d.ts.map +1 -0
- package/dist/presentation/routes/assign-runner-instances.js +40 -0
- package/dist/presentation/routes/assign-runner-instances.js.map +1 -0
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +4 -8
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +1 -0
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +27 -0
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_initial.sql +1 -12
- package/drizzle/meta/0000_snapshot.json +6 -97
- package/package.json +8 -29
- package/src/config.ts +1 -1
- package/src/core/entities/runner-instance.ts +1 -0
- package/src/core/errors.ts +8 -8
- package/src/core/index.ts +2 -9
- package/src/core/runner-assignments.ts +9 -0
- package/src/core/runner-instances.ts +4 -13
- package/src/db/db.ts +0 -2
- package/src/db/index.ts +2 -3
- package/src/db/runner-assignments.test.ts +163 -0
- package/src/db/runner-assignments.ts +115 -0
- package/src/db/runner-instances.test.ts +36 -23
- package/src/db/runner-instances.ts +3 -23
- package/src/db/schema/runner-instances.ts +3 -1
- package/src/index.ts +0 -9
- package/src/presentation/dto/index.ts +0 -4
- package/src/presentation/routes/assign-runner-instances.ts +49 -0
- package/src/presentation/routes/index.ts +4 -7
- package/src/presentation/routes/runner-enrollment.test.ts +60 -0
- package/src/presentation/routes/runner-enrollment.ts +22 -0
- package/test/globalSetup.ts +0 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/core/capacity-assignments.d.ts +0 -7
- package/dist/core/capacity-assignments.d.ts.map +0 -1
- package/dist/core/capacity-assignments.js +0 -6
- package/dist/core/capacity-assignments.js.map +0 -1
- package/dist/core/entities/capacity-assignment.d.ts +0 -9
- package/dist/core/entities/capacity-assignment.d.ts.map +0 -1
- package/dist/core/entities/capacity-assignment.js +0 -3
- package/dist/core/entities/capacity-assignment.js.map +0 -1
- package/dist/db/capacity-assignments.d.ts +0 -7
- package/dist/db/capacity-assignments.d.ts.map +0 -1
- package/dist/db/capacity-assignments.js +0 -72
- package/dist/db/capacity-assignments.js.map +0 -1
- package/dist/db/schema/capacity-assignments.d.ts +0 -113
- package/dist/db/schema/capacity-assignments.d.ts.map +0 -1
- package/dist/db/schema/capacity-assignments.js +0 -29
- package/dist/db/schema/capacity-assignments.js.map +0 -1
- package/dist/presentation/dto/mint-registration-tokens.d.ts +0 -7
- package/dist/presentation/dto/mint-registration-tokens.d.ts.map +0 -1
- package/dist/presentation/dto/mint-registration-tokens.js +0 -19
- package/dist/presentation/dto/mint-registration-tokens.js.map +0 -1
- package/dist/presentation/routes/assign-capacity.d.ts +0 -2
- package/dist/presentation/routes/assign-capacity.d.ts.map +0 -1
- package/dist/presentation/routes/assign-capacity.js +0 -44
- package/dist/presentation/routes/assign-capacity.js.map +0 -1
- package/dist/presentation/routes/mint-registration-tokens.d.ts +0 -2
- package/dist/presentation/routes/mint-registration-tokens.d.ts.map +0 -1
- package/dist/presentation/routes/mint-registration-tokens.js +0 -74
- package/dist/presentation/routes/mint-registration-tokens.js.map +0 -1
- package/dist/presentation/routes/planned-capacity.d.ts +0 -3
- package/dist/presentation/routes/planned-capacity.d.ts.map +0 -1
- package/dist/presentation/routes/planned-capacity.js +0 -54
- package/dist/presentation/routes/planned-capacity.js.map +0 -1
- package/src/core/capacity-assignments.ts +0 -10
- package/src/core/entities/capacity-assignment.ts +0 -8
- package/src/db/capacity-assignments.test.ts +0 -159
- package/src/db/capacity-assignments.ts +0 -116
- package/src/db/schema/capacity-assignments.ts +0 -34
- package/src/presentation/dto/mint-registration-tokens.ts +0 -28
- package/src/presentation/routes/assign-capacity.ts +0 -48
- package/src/presentation/routes/mint-registration-tokens.test.ts +0 -453
- package/src/presentation/routes/mint-registration-tokens.ts +0 -80
- package/src/presentation/routes/planned-capacity.ts +0 -49
|
@@ -1,453 +0,0 @@
|
|
|
1
|
-
import {createLeaseTokenAuthMethod, createRunnerSessionAuthMethod} from '@shipfox/api-auth';
|
|
2
|
-
import {
|
|
3
|
-
AUTH_LEASED_JOB,
|
|
4
|
-
AUTH_PROVISIONER_TOKEN,
|
|
5
|
-
AUTH_RUNNER_SESSION,
|
|
6
|
-
AUTH_USER,
|
|
7
|
-
setProvisionerContext,
|
|
8
|
-
} from '@shipfox/api-auth-context';
|
|
9
|
-
import {
|
|
10
|
-
type AuthMethod,
|
|
11
|
-
ClientError,
|
|
12
|
-
closeApp,
|
|
13
|
-
createApp,
|
|
14
|
-
extractBearerToken,
|
|
15
|
-
} from '@shipfox/node-fastify';
|
|
16
|
-
import {hashOpaqueToken, tokenTypeParts} from '@shipfox/node-tokens';
|
|
17
|
-
import {and, count, eq, sql} from 'drizzle-orm';
|
|
18
|
-
import type {FastifyInstance, FastifyRequest} from 'fastify';
|
|
19
|
-
import {config} from '#config.js';
|
|
20
|
-
import {hashRunnersRateLimitIdentifier} from '#core/rate-limit.js';
|
|
21
|
-
import {db} from '#db/db.js';
|
|
22
|
-
import {resolveEphemeralRegistrationTokenByHash} from '#db/ephemeral-registration-tokens.js';
|
|
23
|
-
import {ephemeralRegistrationTokens} from '#db/schema/ephemeral-registration-tokens.js';
|
|
24
|
-
import {runnersRateLimits} from '#db/schema/rate-limits.js';
|
|
25
|
-
import {reservations} from '#db/schema/reservations.js';
|
|
26
|
-
import {createRunnerRegistrationTokenAuthMethod} from '#presentation/auth/index.js';
|
|
27
|
-
import {ephemeralRegistrationTokenFactory, runnersTestAuthClient} from '#test/index.js';
|
|
28
|
-
import {createRunnerRoutes} from './index.js';
|
|
29
|
-
|
|
30
|
-
const VALID_PROVISIONER_TOKEN = 'valid-provisioner-token';
|
|
31
|
-
|
|
32
|
-
const fakeUserAuth: AuthMethod = {
|
|
33
|
-
name: AUTH_USER,
|
|
34
|
-
authenticate: () => Promise.resolve(),
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const passthroughAuth = (name: string): AuthMethod => ({
|
|
38
|
-
name,
|
|
39
|
-
authenticate: () => Promise.resolve(),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('POST /provisioners/runner-registration-tokens/batch', () => {
|
|
43
|
-
let app: FastifyInstance;
|
|
44
|
-
let workspaceId: string;
|
|
45
|
-
let provisionerTokenId: string;
|
|
46
|
-
|
|
47
|
-
const fakeProvisionerAuth: AuthMethod = {
|
|
48
|
-
name: AUTH_PROVISIONER_TOKEN,
|
|
49
|
-
authenticate: (request: FastifyRequest) => {
|
|
50
|
-
const rawToken = extractBearerToken(request.headers.authorization);
|
|
51
|
-
if (rawToken !== VALID_PROVISIONER_TOKEN) {
|
|
52
|
-
throw new ClientError('Invalid provisioner token', 'unauthorized', {status: 401});
|
|
53
|
-
}
|
|
54
|
-
setProvisionerContext(request, {scope: 'workspace', workspaceId, provisionerTokenId});
|
|
55
|
-
return Promise.resolve();
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
beforeAll(async () => {
|
|
60
|
-
app = await createApp({
|
|
61
|
-
auth: [
|
|
62
|
-
fakeUserAuth,
|
|
63
|
-
createRunnerRegistrationTokenAuthMethod(),
|
|
64
|
-
createRunnerSessionAuthMethod(),
|
|
65
|
-
createLeaseTokenAuthMethod(),
|
|
66
|
-
passthroughAuth(AUTH_RUNNER_SESSION),
|
|
67
|
-
passthroughAuth(AUTH_LEASED_JOB),
|
|
68
|
-
fakeProvisionerAuth,
|
|
69
|
-
],
|
|
70
|
-
routes: createRunnerRoutes(runnersTestAuthClient),
|
|
71
|
-
swagger: false,
|
|
72
|
-
});
|
|
73
|
-
await app.ready();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
afterAll(async () => {
|
|
77
|
-
await closeApp();
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
beforeEach(() => {
|
|
81
|
-
workspaceId = crypto.randomUUID();
|
|
82
|
-
provisionerTokenId = crypto.randomUUID();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('mints one registration token per provisioned runner for a valid reservation', async () => {
|
|
86
|
-
const reservationId = await createReservation({count: 2});
|
|
87
|
-
const before = Date.now();
|
|
88
|
-
|
|
89
|
-
const res = await app.inject({
|
|
90
|
-
method: 'POST',
|
|
91
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
92
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
93
|
-
payload: body(reservationId, ['provisioned-runner-a', 'provisioned-runner-b']),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const after = Date.now();
|
|
97
|
-
expect(res.statusCode).toBe(200);
|
|
98
|
-
const tokens = res.json().tokens as {
|
|
99
|
-
provider_runner_id: string;
|
|
100
|
-
registration_token: string;
|
|
101
|
-
expires_at: string;
|
|
102
|
-
}[];
|
|
103
|
-
expect(tokens).toHaveLength(2);
|
|
104
|
-
expect(tokens.map((token) => token.provider_runner_id).sort()).toEqual([
|
|
105
|
-
'provisioned-runner-a',
|
|
106
|
-
'provisioned-runner-b',
|
|
107
|
-
]);
|
|
108
|
-
for (const minted of tokens) {
|
|
109
|
-
expect(
|
|
110
|
-
minted.registration_token.startsWith(`sf_${tokenTypeParts.ephemeralRegistrationToken}_`),
|
|
111
|
-
).toBe(true);
|
|
112
|
-
const persisted = await resolveEphemeralRegistrationTokenByHash(
|
|
113
|
-
hashOpaqueToken(minted.registration_token),
|
|
114
|
-
);
|
|
115
|
-
expect(persisted).toMatchObject({
|
|
116
|
-
workspaceId,
|
|
117
|
-
provisionerId: provisionerTokenId,
|
|
118
|
-
reservationId,
|
|
119
|
-
providerRunnerId: minted.provider_runner_id,
|
|
120
|
-
});
|
|
121
|
-
const expiresMs = new Date(minted.expires_at).getTime();
|
|
122
|
-
expect(expiresMs).toBeGreaterThanOrEqual(
|
|
123
|
-
before + config.EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS * 1000,
|
|
124
|
-
);
|
|
125
|
-
expect(expiresMs).toBeLessThanOrEqual(
|
|
126
|
-
after + config.EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS * 1000,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('returns 404 when the reservation is unknown', async () => {
|
|
132
|
-
const res = await app.inject({
|
|
133
|
-
method: 'POST',
|
|
134
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
135
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
136
|
-
payload: body(crypto.randomUUID(), ['provisioned-runner-a']),
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
expect(res.statusCode).toBe(404);
|
|
140
|
-
expect(res.json().code).toBe('reservation-not-found');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('returns 404 when the reservation belongs to another provisioner', async () => {
|
|
144
|
-
const reservationId = await createReservation({
|
|
145
|
-
count: 1,
|
|
146
|
-
provisionerId: crypto.randomUUID(),
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
const res = await app.inject({
|
|
150
|
-
method: 'POST',
|
|
151
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
152
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
153
|
-
payload: body(reservationId, ['provisioned-runner-a']),
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
expect(res.statusCode).toBe(404);
|
|
157
|
-
expect(res.json().code).toBe('reservation-not-found');
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('returns 404 when the reservation belongs to another workspace', async () => {
|
|
161
|
-
const reservationId = await createReservation({
|
|
162
|
-
count: 1,
|
|
163
|
-
workspaceId: crypto.randomUUID(),
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const res = await app.inject({
|
|
167
|
-
method: 'POST',
|
|
168
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
169
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
170
|
-
payload: body(reservationId, ['provisioned-runner-a']),
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
expect(res.statusCode).toBe(404);
|
|
174
|
-
expect(res.json().code).toBe('reservation-not-found');
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('returns 409 when the reservation is expired', async () => {
|
|
178
|
-
const reservationId = await createReservation({
|
|
179
|
-
count: 1,
|
|
180
|
-
expiresAt: new Date(Date.now() - 1000),
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
const res = await app.inject({
|
|
184
|
-
method: 'POST',
|
|
185
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
186
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
187
|
-
payload: body(reservationId, ['provisioned-runner-a']),
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
expect(res.statusCode).toBe(409);
|
|
191
|
-
expect(res.json().code).toBe('reservation-expired');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('allows a batch equal to the reservation count', async () => {
|
|
195
|
-
const reservationId = await createReservation({count: 2});
|
|
196
|
-
|
|
197
|
-
const res = await app.inject({
|
|
198
|
-
method: 'POST',
|
|
199
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
200
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
201
|
-
payload: body(reservationId, ['provisioned-runner-a', 'provisioned-runner-b']),
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
expect(res.statusCode).toBe(200);
|
|
205
|
-
expect(res.json().tokens).toHaveLength(2);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('returns 409 when the batch exceeds the reservation count', async () => {
|
|
209
|
-
const reservationId = await createReservation({count: 1});
|
|
210
|
-
|
|
211
|
-
const res = await app.inject({
|
|
212
|
-
method: 'POST',
|
|
213
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
214
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
215
|
-
payload: body(reservationId, ['provisioned-runner-a', 'provisioned-runner-b']),
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
expect(res.statusCode).toBe(409);
|
|
219
|
-
expect(res.json()).toMatchObject({
|
|
220
|
-
code: 'batch-exceeds-reservation',
|
|
221
|
-
details: {requested: 2, reservation_count: 1},
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it('rejects the whole batch when a requested provisioned runner has an active token', async () => {
|
|
226
|
-
const reservationId = await createReservation({count: 2});
|
|
227
|
-
await ephemeralRegistrationTokenFactory.create({
|
|
228
|
-
workspaceId,
|
|
229
|
-
provisionerId: provisionerTokenId,
|
|
230
|
-
providerRunnerId: 'provisioned-runner-a',
|
|
231
|
-
expiresAt: new Date(Date.now() + 60_000),
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
const res = await app.inject({
|
|
235
|
-
method: 'POST',
|
|
236
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
237
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
238
|
-
payload: body(reservationId, ['provisioned-runner-a', 'provisioned-runner-b']),
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
const persistedCount = await countEphemeralTokens();
|
|
242
|
-
expect(res.statusCode).toBe(409);
|
|
243
|
-
expect(res.json()).toMatchObject({
|
|
244
|
-
code: 'registration-token-active',
|
|
245
|
-
details: {provider_runner_ids: ['provisioned-runner-a']},
|
|
246
|
-
});
|
|
247
|
-
expect(persistedCount).toBe(1);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('returns 400 for duplicate provisioned runner ids', async () => {
|
|
251
|
-
const reservationId = await createReservation({count: 2});
|
|
252
|
-
|
|
253
|
-
const res = await app.inject({
|
|
254
|
-
method: 'POST',
|
|
255
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
256
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
257
|
-
payload: body(reservationId, ['provisioned-runner-a', 'provisioned-runner-a']),
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
expect(res.statusCode).toBe(400);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('returns 400 when the request exceeds the runtime batch max', async () => {
|
|
264
|
-
const reservationId = await createReservation({count: config.REGISTRATION_TOKEN_BATCH_MAX + 1});
|
|
265
|
-
|
|
266
|
-
const res = await app.inject({
|
|
267
|
-
method: 'POST',
|
|
268
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
269
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
270
|
-
payload: body(
|
|
271
|
-
reservationId,
|
|
272
|
-
Array.from(
|
|
273
|
-
{length: config.REGISTRATION_TOKEN_BATCH_MAX + 1},
|
|
274
|
-
(_, index) => `provisioned-runner-${index}`,
|
|
275
|
-
),
|
|
276
|
-
),
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
expect(res.statusCode).toBe(400);
|
|
280
|
-
expect(res.json()).toMatchObject({
|
|
281
|
-
code: 'batch-too-large',
|
|
282
|
-
details: {requested: config.REGISTRATION_TOKEN_BATCH_MAX + 1},
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('returns 400 when the request exceeds the DTO hard ceiling', async () => {
|
|
287
|
-
const reservationId = await createReservation({count: 1001});
|
|
288
|
-
|
|
289
|
-
const res = await app.inject({
|
|
290
|
-
method: 'POST',
|
|
291
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
292
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
293
|
-
payload: body(
|
|
294
|
-
reservationId,
|
|
295
|
-
Array.from({length: 1001}, (_, index) => `provisioned-runner-${index}`),
|
|
296
|
-
),
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
expect(res.statusCode).toBe(400);
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('returns 429 when the provisioner mint rate limit is exceeded', async () => {
|
|
303
|
-
const reservationId = await createReservation({count: 1});
|
|
304
|
-
await seedProvisionerMintRateLimit(config.PROVISIONER_MINT_RATE_LIMIT_MAX_REQUESTS);
|
|
305
|
-
|
|
306
|
-
const res = await app.inject({
|
|
307
|
-
method: 'POST',
|
|
308
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
309
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
310
|
-
payload: body(reservationId, ['provisioned-runner-a']),
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const persistedCount = await countEphemeralTokens();
|
|
314
|
-
expect(res.statusCode).toBe(429);
|
|
315
|
-
expect(res.headers['retry-after']).toEqual(expect.any(String));
|
|
316
|
-
expect(res.json()).toMatchObject({
|
|
317
|
-
code: 'rate-limited',
|
|
318
|
-
details: {retry_after_seconds: expect.any(Number)},
|
|
319
|
-
});
|
|
320
|
-
expect(persistedCount).toBe(0);
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('returns 503 when the provisioner mint rate limiter is unavailable', async () => {
|
|
324
|
-
const reservationId = await createReservation({count: 1});
|
|
325
|
-
const identifierHmac = await seedProvisionerMintRateLimit(1);
|
|
326
|
-
|
|
327
|
-
await db().transaction(async (tx) => {
|
|
328
|
-
await tx.execute(sql`
|
|
329
|
-
SELECT 1
|
|
330
|
-
FROM runners_rate_limits
|
|
331
|
-
WHERE identifier_hmac = ${identifierHmac}
|
|
332
|
-
FOR UPDATE
|
|
333
|
-
`);
|
|
334
|
-
|
|
335
|
-
const res = await app.inject({
|
|
336
|
-
method: 'POST',
|
|
337
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
338
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
339
|
-
payload: body(reservationId, ['provisioned-runner-a']),
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
expect(res.statusCode).toBe(503);
|
|
343
|
-
expect(res.json().code).toBe('runners-rate-limit-unavailable');
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
const persistedCount = await countEphemeralTokens();
|
|
347
|
-
expect(persistedCount).toBe(0);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it('mints tokens that can register exactly once', async () => {
|
|
351
|
-
const reservationId = await createReservation({count: 1});
|
|
352
|
-
const mint = await app.inject({
|
|
353
|
-
method: 'POST',
|
|
354
|
-
url: '/provisioners/runner-registration-tokens/batch',
|
|
355
|
-
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
356
|
-
payload: body(reservationId, ['provisioned-runner-a']),
|
|
357
|
-
});
|
|
358
|
-
const registrationToken = mint.json().tokens[0].registration_token as string;
|
|
359
|
-
|
|
360
|
-
const first = await app.inject({
|
|
361
|
-
method: 'POST',
|
|
362
|
-
url: '/runners/register',
|
|
363
|
-
headers: {authorization: `Bearer ${registrationToken}`},
|
|
364
|
-
payload: {labels: ['linux']},
|
|
365
|
-
});
|
|
366
|
-
const second = await app.inject({
|
|
367
|
-
method: 'POST',
|
|
368
|
-
url: '/runners/register',
|
|
369
|
-
headers: {authorization: `Bearer ${registrationToken}`},
|
|
370
|
-
payload: {labels: ['linux']},
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
expect(mint.statusCode).toBe(200);
|
|
374
|
-
expect(first.statusCode).toBe(200);
|
|
375
|
-
expect(first.json()).toMatchObject({mode: 'ephemeral', max_claims: 1});
|
|
376
|
-
expect(second.statusCode).toBe(409);
|
|
377
|
-
expect(second.json().code).toBe('registration-token-consumed');
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
async function createReservation(params: {
|
|
381
|
-
count: number;
|
|
382
|
-
workspaceId?: string;
|
|
383
|
-
provisionerId?: string;
|
|
384
|
-
expiresAt?: Date;
|
|
385
|
-
}): Promise<string> {
|
|
386
|
-
const [reservation] = await db()
|
|
387
|
-
.insert(reservations)
|
|
388
|
-
.values({
|
|
389
|
-
workspaceId: params.workspaceId ?? workspaceId,
|
|
390
|
-
provisionerId: params.provisionerId ?? provisionerTokenId,
|
|
391
|
-
requiredLabels: ['linux'],
|
|
392
|
-
count: params.count,
|
|
393
|
-
expiresAt: params.expiresAt ?? new Date(Date.now() + 60_000),
|
|
394
|
-
})
|
|
395
|
-
.returning({id: reservations.id});
|
|
396
|
-
if (!reservation) throw new Error('Insert returned no rows');
|
|
397
|
-
return reservation.id;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
async function countEphemeralTokens(): Promise<number> {
|
|
401
|
-
const [row] = await db()
|
|
402
|
-
.select({value: count()})
|
|
403
|
-
.from(ephemeralRegistrationTokens)
|
|
404
|
-
.where(
|
|
405
|
-
and(
|
|
406
|
-
eq(ephemeralRegistrationTokens.workspaceId, workspaceId),
|
|
407
|
-
eq(ephemeralRegistrationTokens.provisionerId, provisionerTokenId),
|
|
408
|
-
),
|
|
409
|
-
);
|
|
410
|
-
return row?.value ?? 0;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function body(reservationId: string, providerRunnerIds: string[]) {
|
|
414
|
-
return {
|
|
415
|
-
reservation_id: reservationId,
|
|
416
|
-
runner_instances: providerRunnerIds.map((providerRunnerId) => ({
|
|
417
|
-
provider_runner_id: providerRunnerId,
|
|
418
|
-
})),
|
|
419
|
-
};
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async function seedProvisionerMintRateLimit(seedCount: number): Promise<string> {
|
|
423
|
-
const identifierHmac = hashRunnersRateLimitIdentifier({
|
|
424
|
-
action: 'provisioner-mint',
|
|
425
|
-
scope: 'provisioner',
|
|
426
|
-
identifier: provisionerTokenId,
|
|
427
|
-
});
|
|
428
|
-
const windows = rateLimitWindows(config.PROVISIONER_MINT_RATE_LIMIT_WINDOW_SECONDS);
|
|
429
|
-
|
|
430
|
-
await db()
|
|
431
|
-
.insert(runnersRateLimits)
|
|
432
|
-
.values(
|
|
433
|
-
windows.map((windowStart) => ({
|
|
434
|
-
action: 'provisioner-mint',
|
|
435
|
-
scope: 'provisioner',
|
|
436
|
-
identifierHmac,
|
|
437
|
-
windowStart,
|
|
438
|
-
count: seedCount,
|
|
439
|
-
expiresAt: new Date(
|
|
440
|
-
windowStart.getTime() + config.PROVISIONER_MINT_RATE_LIMIT_WINDOW_SECONDS * 1000,
|
|
441
|
-
),
|
|
442
|
-
})),
|
|
443
|
-
);
|
|
444
|
-
|
|
445
|
-
return identifierHmac;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function rateLimitWindows(windowSeconds: number): [Date, Date] {
|
|
449
|
-
const windowMs = windowSeconds * 1000;
|
|
450
|
-
const currentWindowStart = Math.floor(Date.now() / windowMs) * windowMs;
|
|
451
|
-
return [new Date(currentWindowStart), new Date(currentWindowStart + windowMs)];
|
|
452
|
-
}
|
|
453
|
-
});
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import {requireWorkspaceProvisionerContext} from '@shipfox/api-auth-context';
|
|
2
|
-
import {
|
|
3
|
-
mintRegistrationTokensBatchBodySchema,
|
|
4
|
-
mintRegistrationTokensBatchResponseSchema,
|
|
5
|
-
} from '@shipfox/api-runners-dto';
|
|
6
|
-
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
7
|
-
import {config} from '#config.js';
|
|
8
|
-
import {mintEphemeralRegistrationTokensBatch} from '#core/ephemeral-registration-tokens.js';
|
|
9
|
-
import {
|
|
10
|
-
ActiveEphemeralRegistrationTokensExistError,
|
|
11
|
-
RegistrationTokenBatchExceedsReservationError,
|
|
12
|
-
RegistrationTokenBatchTooLargeError,
|
|
13
|
-
ReservationExpiredError,
|
|
14
|
-
ReservationNotFoundError,
|
|
15
|
-
} from '#core/errors.js';
|
|
16
|
-
import {
|
|
17
|
-
toMintRegistrationTokensResponseDto,
|
|
18
|
-
toMintRegistrationTokensRunnerInstances,
|
|
19
|
-
} from '#presentation/dto/index.js';
|
|
20
|
-
import {createProvisionerMintRateLimitPreHandler} from './rate-limit.js';
|
|
21
|
-
|
|
22
|
-
export const mintRegistrationTokensRoute = defineRoute({
|
|
23
|
-
method: 'POST',
|
|
24
|
-
path: '/runner-registration-tokens/batch',
|
|
25
|
-
description: 'Mint ephemeral runner registration tokens for reserved provisioned runners',
|
|
26
|
-
schema: {
|
|
27
|
-
body: mintRegistrationTokensBatchBodySchema,
|
|
28
|
-
response: {
|
|
29
|
-
200: mintRegistrationTokensBatchResponseSchema,
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
preHandler: createProvisionerMintRateLimitPreHandler(),
|
|
33
|
-
errorHandler: (error) => {
|
|
34
|
-
if (error instanceof ReservationNotFoundError) {
|
|
35
|
-
throw new ClientError('Reservation not found', 'reservation-not-found', {status: 404});
|
|
36
|
-
}
|
|
37
|
-
if (error instanceof ReservationExpiredError) {
|
|
38
|
-
throw new ClientError('Reservation has expired', 'reservation-expired', {status: 409});
|
|
39
|
-
}
|
|
40
|
-
if (error instanceof RegistrationTokenBatchExceedsReservationError) {
|
|
41
|
-
throw new ClientError('Batch exceeds reservation count', 'batch-exceeds-reservation', {
|
|
42
|
-
status: 409,
|
|
43
|
-
details: {
|
|
44
|
-
requested: error.requested,
|
|
45
|
-
reservation_count: error.reservationCount,
|
|
46
|
-
already_minted: error.alreadyMinted,
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
if (error instanceof ActiveEphemeralRegistrationTokensExistError) {
|
|
51
|
-
throw new ClientError('Registration token already active', 'registration-token-active', {
|
|
52
|
-
status: 409,
|
|
53
|
-
details: {provider_runner_ids: error.providerRunnerIds},
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
if (error instanceof RegistrationTokenBatchTooLargeError) {
|
|
57
|
-
throw new ClientError('Registration token batch is too large', 'batch-too-large', {
|
|
58
|
-
status: 400,
|
|
59
|
-
details: {
|
|
60
|
-
requested: error.requested,
|
|
61
|
-
max: error.max,
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
throw error;
|
|
66
|
-
},
|
|
67
|
-
handler: async (request) => {
|
|
68
|
-
const {provisionerTokenId, workspaceId} = requireWorkspaceProvisionerContext(request);
|
|
69
|
-
const result = await mintEphemeralRegistrationTokensBatch({
|
|
70
|
-
workspaceId,
|
|
71
|
-
provisionerId: provisionerTokenId,
|
|
72
|
-
reservationId: request.body.reservation_id,
|
|
73
|
-
providerRunners: toMintRegistrationTokensRunnerInstances(request.body.runner_instances),
|
|
74
|
-
ttlSeconds: config.EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS,
|
|
75
|
-
maxBatchSize: config.REGISTRATION_TOKEN_BATCH_MAX,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
return toMintRegistrationTokensResponseDto(result);
|
|
79
|
-
},
|
|
80
|
-
});
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import {requireProvisionerContext} from '@shipfox/api-auth-context';
|
|
2
|
-
import {
|
|
3
|
-
attachProviderRunnerBodySchema,
|
|
4
|
-
attachProviderRunnerResponseSchema,
|
|
5
|
-
createPlannedCapacityBodySchema,
|
|
6
|
-
createPlannedCapacityResponseSchema,
|
|
7
|
-
} from '@shipfox/api-runners-dto';
|
|
8
|
-
import {defineRoute} from '@shipfox/node-fastify';
|
|
9
|
-
import {z} from 'zod';
|
|
10
|
-
import {attachProviderRunnerId, createPlannedProvisionedCapacity} from '#core/index.js';
|
|
11
|
-
|
|
12
|
-
export const createPlannedCapacityRoute = defineRoute({
|
|
13
|
-
method: 'POST',
|
|
14
|
-
path: '/capacity',
|
|
15
|
-
description: 'Create provisioner-owned capacity before provider launch',
|
|
16
|
-
schema: {
|
|
17
|
-
body: createPlannedCapacityBodySchema,
|
|
18
|
-
response: {200: createPlannedCapacityResponseSchema},
|
|
19
|
-
},
|
|
20
|
-
handler: async (request) => {
|
|
21
|
-
const {provisionerTokenId} = requireProvisionerContext(request);
|
|
22
|
-
const result = await createPlannedProvisionedCapacity({
|
|
23
|
-
provisionerId: provisionerTokenId,
|
|
24
|
-
providerKind: request.body.provider_kind ?? null,
|
|
25
|
-
templateKey: request.body.template_key ?? null,
|
|
26
|
-
});
|
|
27
|
-
return {capacity_id: result.capacityId};
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
export const attachProviderRunnerRoute = defineRoute({
|
|
32
|
-
method: 'POST',
|
|
33
|
-
path: '/capacity/:capacityId/provider-runner',
|
|
34
|
-
description: 'Attach a provider runner identity to planned capacity once',
|
|
35
|
-
schema: {
|
|
36
|
-
params: z.object({capacityId: z.string().uuid()}),
|
|
37
|
-
body: attachProviderRunnerBodySchema,
|
|
38
|
-
response: {200: attachProviderRunnerResponseSchema},
|
|
39
|
-
},
|
|
40
|
-
handler: async (request) => {
|
|
41
|
-
const {provisionerTokenId} = requireProvisionerContext(request);
|
|
42
|
-
const attached = await attachProviderRunnerId({
|
|
43
|
-
capacityId: request.params.capacityId,
|
|
44
|
-
provisionerId: provisionerTokenId,
|
|
45
|
-
providerRunnerId: request.body.provider_runner_id,
|
|
46
|
-
});
|
|
47
|
-
return {attached};
|
|
48
|
-
},
|
|
49
|
-
});
|