@shipfox/api-runners 12.1.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 +35 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +24 -2
- package/dist/config.js.map +1 -1
- package/dist/core/errors.d.ts +4 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +6 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/rate-limit.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.d.ts.map +1 -1
- package/dist/core/runner-control-sessions.js +37 -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.map +1 -1
- package/dist/core/runner-labels.d.ts +11 -0
- package/dist/core/runner-labels.d.ts.map +1 -0
- package/dist/core/runner-labels.js +41 -0
- package/dist/core/runner-labels.js.map +1 -0
- package/dist/core/runner-sessions.d.ts.map +1 -1
- package/dist/core/runner-sessions.js +5 -1
- package/dist/core/runner-sessions.js.map +1 -1
- package/dist/db/db.d.ts +64 -64
- package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
- package/dist/db/ephemeral-registration-tokens.js +12 -2
- 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/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +36 -10
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-instances.d.ts +4 -0
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +16 -5
- 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 +33 -15
- package/dist/db/runner-sessions.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +1 -1
- package/dist/db/schema/ephemeral-registration-tokens.d.ts +1 -1
- package/dist/db/schema/manual-registration-tokens.d.ts +1 -1
- package/dist/db/schema/outbox.d.ts +1 -1
- package/dist/db/schema/pending-job-executions.d.ts +3 -3
- package/dist/db/schema/provisioner-capability-snapshots.d.ts +3 -3
- package/dist/db/schema/provisioner-tokens.d.ts +2 -2
- package/dist/db/schema/rate-limits.d.ts +1 -1
- package/dist/db/schema/reservations.d.ts +3 -3
- package/dist/db/schema/runner-activation-tokens.d.ts +1 -1
- package/dist/db/schema/runner-control-sessions.d.ts +2 -2
- package/dist/db/schema/runner-instances.d.ts +4 -4
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +1 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/db/schema/runner-sessions.d.ts +4 -4
- package/dist/db/schema/running-job-executions.d.ts +5 -5
- package/dist/metrics/index.d.ts +2 -1
- 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 +13 -8
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js +8 -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/admin-provisioner-token.d.ts +1 -1
- package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -1
- package/dist/presentation/dto/admin-runner-instances.d.ts +2 -2
- package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
- package/dist/presentation/routes/poll-demand.js +12 -6
- package/dist/presentation/routes/poll-demand.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/register.d.ts.map +1 -1
- package/dist/presentation/routes/register.js +9 -1
- package/dist/presentation/routes/register.js.map +1 -1
- package/dist/presentation/routes/report-runner-instances.d.ts.map +1 -1
- package/dist/presentation/routes/report-runner-instances.js +1 -0
- package/dist/presentation/routes/report-runner-instances.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0004_huge_skreet.sql +1 -0
- package/drizzle/meta/0004_snapshot.json +2194 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +9 -9
- package/src/config.test.ts +81 -0
- package/src/config.ts +39 -2
- package/src/core/errors.ts +9 -0
- package/src/core/index.ts +1 -0
- package/src/core/runner-control-sessions.test.ts +404 -0
- package/src/core/runner-control-sessions.ts +51 -11
- package/src/core/runner-instances.ts +1 -0
- package/src/core/runner-labels.test.ts +53 -0
- package/src/core/runner-labels.ts +51 -0
- package/src/core/runner-sessions.test.ts +34 -1
- package/src/core/runner-sessions.ts +8 -1
- package/src/db/ephemeral-registration-tokens.ts +15 -1
- package/src/db/index.ts +1 -0
- package/src/db/reservations.test.ts +537 -0
- package/src/db/reservations.ts +116 -17
- package/src/db/runner-instances.test.ts +276 -1
- package/src/db/runner-instances.ts +39 -3
- package/src/db/runner-sessions.ts +39 -15
- package/src/db/schema/runner-instances.ts +3 -0
- package/src/metrics/index.ts +2 -0
- package/src/metrics/instance.ts +18 -0
- 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 +116 -3
- package/src/presentation/routes/poll-demand.ts +18 -6
- package/src/presentation/routes/register.test.ts +116 -2
- package/src/presentation/routes/register.ts +7 -0
- package/src/presentation/routes/report-runner-instances.test.ts +33 -0
- package/src/presentation/routes/report-runner-instances.ts +1 -0
- package/src/presentation/routes/runner-enrollment.test.ts +65 -5
- package/src/temporal/activities/maintenance-activities.test.ts +24 -31
- package/test/env.ts +1 -0
- package/test/fixtures/late-runner-enrollment.ts +103 -0
- package/test/index.ts +5 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -87,6 +87,7 @@ describe('POST /provisioners/demand/poll', () => {
|
|
|
87
87
|
it('returns demand stats and reservations when matching demand exists', async () => {
|
|
88
88
|
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
89
89
|
|
|
90
|
+
const requestStartedAt = Date.now();
|
|
90
91
|
const res = await app.inject({
|
|
91
92
|
method: 'POST',
|
|
92
93
|
url: '/provisioners/demand/poll',
|
|
@@ -102,6 +103,60 @@ describe('POST /provisioners/demand/poll', () => {
|
|
|
102
103
|
});
|
|
103
104
|
expect(res.json().reservations[0].reservation_id).toEqual(expect.any(String));
|
|
104
105
|
expect(res.json().reservations[0].expires_at).toEqual(expect.any(String));
|
|
106
|
+
const expiresAt = Date.parse(res.json().reservations[0].expires_at);
|
|
107
|
+
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 55_000);
|
|
108
|
+
expect(expiresAt).toBeLessThan(requestStartedAt + 65_000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('clamps a requested reservation TTL for workspace provisioners', async () => {
|
|
112
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
113
|
+
|
|
114
|
+
const requestStartedAt = Date.now();
|
|
115
|
+
const res = await app.inject({
|
|
116
|
+
method: 'POST',
|
|
117
|
+
url: '/provisioners/demand/poll',
|
|
118
|
+
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
119
|
+
payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(res.statusCode).toBe(200);
|
|
123
|
+
const expiresAt = Date.parse(res.json().reservations[0].expires_at);
|
|
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);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('applies a requested reservation TTL below the ceiling', async () => {
|
|
146
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
147
|
+
|
|
148
|
+
const requestStartedAt = Date.now();
|
|
149
|
+
const res = await app.inject({
|
|
150
|
+
method: 'POST',
|
|
151
|
+
url: '/provisioners/demand/poll',
|
|
152
|
+
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
153
|
+
payload: body({max_reservations: 1, reservation_ttl_seconds: 120}),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(res.statusCode).toBe(200);
|
|
157
|
+
const expiresAt = Date.parse(res.json().reservations[0].expires_at);
|
|
158
|
+
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 115_000);
|
|
159
|
+
expect(expiresAt).toBeLessThan(requestStartedAt + 125_000);
|
|
105
160
|
});
|
|
106
161
|
|
|
107
162
|
it('rejects installation provisioner credentials from workspace demand polling', async () => {
|
|
@@ -173,6 +228,39 @@ describe('POST /provisioners/demand/poll', () => {
|
|
|
173
228
|
expect(clearedSnapshots).toEqual([]);
|
|
174
229
|
});
|
|
175
230
|
|
|
231
|
+
it('strips reserved labels from workspace template advertisements', async () => {
|
|
232
|
+
const res = await app.inject({
|
|
233
|
+
method: 'POST',
|
|
234
|
+
url: '/provisioners/demand/poll',
|
|
235
|
+
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
236
|
+
payload: {
|
|
237
|
+
wait_seconds: 0,
|
|
238
|
+
max_reservations: 0,
|
|
239
|
+
templates: [
|
|
240
|
+
{
|
|
241
|
+
template_key: 'linux',
|
|
242
|
+
labels: ['linux', 'shipfox-managed'],
|
|
243
|
+
available_slots: 1,
|
|
244
|
+
starting: 0,
|
|
245
|
+
running: 0,
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const snapshots = await db()
|
|
252
|
+
.select()
|
|
253
|
+
.from(provisionerCapabilitySnapshots)
|
|
254
|
+
.where(eq(provisionerCapabilitySnapshots.provisionerId, provisionerTokenId));
|
|
255
|
+
|
|
256
|
+
expect(res.statusCode).toBe(200);
|
|
257
|
+
expect(snapshots).toEqual([
|
|
258
|
+
expect.objectContaining({
|
|
259
|
+
labels: ['linux'],
|
|
260
|
+
}),
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
176
264
|
it('returns terminate intent ids for active provisioned runners with cancelled latest jobs', async () => {
|
|
177
265
|
await providerRunnerFactory.create({
|
|
178
266
|
workspaceId,
|
|
@@ -330,10 +418,13 @@ describe('POST /provisioners/demand/poll', () => {
|
|
|
330
418
|
expect(res.statusCode).toBe(401);
|
|
331
419
|
});
|
|
332
420
|
|
|
333
|
-
function body(params: {max_reservations: number}) {
|
|
421
|
+
function body(params: {max_reservations: number; reservation_ttl_seconds?: number}) {
|
|
334
422
|
return {
|
|
335
423
|
wait_seconds: 0,
|
|
336
424
|
max_reservations: params.max_reservations,
|
|
425
|
+
...(params.reservation_ttl_seconds === undefined
|
|
426
|
+
? {}
|
|
427
|
+
: {reservation_ttl_seconds: params.reservation_ttl_seconds}),
|
|
337
428
|
templates: [
|
|
338
429
|
{
|
|
339
430
|
template_key: 'linux',
|
|
@@ -377,6 +468,7 @@ describe('POST /provisioners/demand/poll with installation provisioning configur
|
|
|
377
468
|
let app: FastifyInstance;
|
|
378
469
|
let workspaceId: string;
|
|
379
470
|
let provisionerTokenId: string;
|
|
471
|
+
const filterEligibleWorkspaceIds = vi.fn().mockResolvedValue(new Set<string>());
|
|
380
472
|
|
|
381
473
|
const fakeProvisionerAuth: AuthMethod = {
|
|
382
474
|
name: AUTH_PROVISIONER_TOKEN,
|
|
@@ -402,7 +494,7 @@ describe('POST /provisioners/demand/poll with installation provisioning configur
|
|
|
402
494
|
],
|
|
403
495
|
routes: createRunnerRoutes(runnersTestAuthClient, {
|
|
404
496
|
installationProvisioning: {
|
|
405
|
-
policy: {filterEligibleWorkspaceIds
|
|
497
|
+
policy: {filterEligibleWorkspaceIds},
|
|
406
498
|
},
|
|
407
499
|
}),
|
|
408
500
|
swagger: false,
|
|
@@ -444,10 +536,31 @@ describe('POST /provisioners/demand/poll with installation provisioning configur
|
|
|
444
536
|
expect(res.statusCode).toBe(200);
|
|
445
537
|
});
|
|
446
538
|
|
|
447
|
-
|
|
539
|
+
it('clamps a requested reservation TTL for installation provisioners', async () => {
|
|
540
|
+
await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
541
|
+
filterEligibleWorkspaceIds.mockResolvedValueOnce(new Set([workspaceId]));
|
|
542
|
+
|
|
543
|
+
const requestStartedAt = Date.now();
|
|
544
|
+
const res = await app.inject({
|
|
545
|
+
method: 'POST',
|
|
546
|
+
url: '/provisioners/demand/poll',
|
|
547
|
+
headers: {authorization: `Bearer ${INSTALLATION_PROVISIONER_TOKEN}`},
|
|
548
|
+
payload: body({max_reservations: 1, reservation_ttl_seconds: 601}),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
expect(res.statusCode).toBe(200);
|
|
552
|
+
const expiresAt = Date.parse(res.json().reservations[0].expires_at);
|
|
553
|
+
expect(expiresAt).toBeGreaterThanOrEqual(requestStartedAt + 595_000);
|
|
554
|
+
expect(expiresAt).toBeLessThan(requestStartedAt + 605_000);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
function body(params: {max_reservations: number; reservation_ttl_seconds?: number}) {
|
|
448
558
|
return {
|
|
449
559
|
wait_seconds: 0,
|
|
450
560
|
max_reservations: params.max_reservations,
|
|
561
|
+
...(params.reservation_ttl_seconds === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: {reservation_ttl_seconds: params.reservation_ttl_seconds}),
|
|
451
564
|
templates: [
|
|
452
565
|
{
|
|
453
566
|
template_key: 'linux',
|
|
@@ -9,6 +9,7 @@ import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
|
9
9
|
import {logger} from '@shipfox/node-opentelemetry';
|
|
10
10
|
import {config} from '#config.js';
|
|
11
11
|
import {pollDemand, releaseReservationGrants} from '#core/demand.js';
|
|
12
|
+
import {sanitizeRunnerLabels} from '#core/runner-labels.js';
|
|
12
13
|
import {publishWorkspaceProvisionerCapabilitySnapshot} from '#db/provisioner-capability-snapshots.js';
|
|
13
14
|
import {
|
|
14
15
|
listQueuedDemandWorkspaceIds,
|
|
@@ -21,10 +22,17 @@ import {toPollDemandResponseDto} from '#presentation/dto/index.js';
|
|
|
21
22
|
|
|
22
23
|
const TERMINATE_INTENT_LIMIT = 1000;
|
|
23
24
|
|
|
24
|
-
function toReservationTemplates(
|
|
25
|
+
function toReservationTemplates(
|
|
26
|
+
templates: PollDemandTemplateDto[],
|
|
27
|
+
scope: 'installation' | 'workspace',
|
|
28
|
+
): ReservationTemplate[] {
|
|
25
29
|
return templates.map((template) => ({
|
|
26
30
|
templateKey: template.template_key,
|
|
27
|
-
labels: template.labels,
|
|
31
|
+
labels: sanitizeRunnerLabels(template.labels, {
|
|
32
|
+
scope,
|
|
33
|
+
logLevel: 'debug',
|
|
34
|
+
source: 'provisioner template advertisement',
|
|
35
|
+
}),
|
|
28
36
|
availableSlots: template.available_slots,
|
|
29
37
|
starting: template.starting,
|
|
30
38
|
running: template.running,
|
|
@@ -66,6 +74,10 @@ export function createPollDemandRoute(options: CreateRunnersModuleOptions = {})
|
|
|
66
74
|
}
|
|
67
75
|
});
|
|
68
76
|
const provisionerContext = requireProvisionerContext(request);
|
|
77
|
+
const ttlSeconds = Math.min(
|
|
78
|
+
request.body.reservation_ttl_seconds ?? config.RESERVATION_TTL_SECONDS,
|
|
79
|
+
config.RESERVATION_TTL_MAX_SECONDS,
|
|
80
|
+
);
|
|
69
81
|
if (provisionerContext.scope === 'installation') {
|
|
70
82
|
if (!options.installationProvisioning) {
|
|
71
83
|
throw new ClientError(
|
|
@@ -81,11 +93,11 @@ export function createPollDemandRoute(options: CreateRunnersModuleOptions = {})
|
|
|
81
93
|
await options.installationProvisioning.policy.filterEligibleWorkspaceIds(
|
|
82
94
|
candidateWorkspaceIds,
|
|
83
95
|
);
|
|
84
|
-
const templates = toReservationTemplates(request.body.templates);
|
|
96
|
+
const templates = toReservationTemplates(request.body.templates, 'installation');
|
|
85
97
|
const result = await pollInstallationDemandAndReserve({
|
|
86
98
|
provisionerId: provisionerContext.provisionerTokenId,
|
|
87
99
|
maxReservations: request.body.max_reservations,
|
|
88
|
-
ttlSeconds
|
|
100
|
+
ttlSeconds,
|
|
89
101
|
templates,
|
|
90
102
|
capabilityWindowSeconds: config.PROVISIONER_ACTIVE_WINDOW_SECONDS,
|
|
91
103
|
eligibleWorkspaceIds,
|
|
@@ -101,7 +113,7 @@ export function createPollDemandRoute(options: CreateRunnersModuleOptions = {})
|
|
|
101
113
|
}
|
|
102
114
|
const {provisionerTokenId, workspaceId} = requireWorkspaceProvisionerContext(request);
|
|
103
115
|
|
|
104
|
-
const templates = toReservationTemplates(request.body.templates);
|
|
116
|
+
const templates = toReservationTemplates(request.body.templates, 'workspace');
|
|
105
117
|
await publishWorkspaceProvisionerCapabilitySnapshot({
|
|
106
118
|
workspaceId,
|
|
107
119
|
provisionerId: provisionerTokenId,
|
|
@@ -113,7 +125,7 @@ export function createPollDemandRoute(options: CreateRunnersModuleOptions = {})
|
|
|
113
125
|
provisionerId: provisionerTokenId,
|
|
114
126
|
maxReservations: request.body.max_reservations,
|
|
115
127
|
waitSeconds: request.body.wait_seconds,
|
|
116
|
-
ttlSeconds
|
|
128
|
+
ttlSeconds,
|
|
117
129
|
terminateIntentLimit: TERMINATE_INTENT_LIMIT,
|
|
118
130
|
templates,
|
|
119
131
|
signal: abortController.signal,
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import {AUTH_PROVISIONER_TOKEN, AUTH_USER} from '@shipfox/api-auth-context';
|
|
2
2
|
import type {RunnerToolCapabilitiesDto} from '@shipfox/api-runners-dto';
|
|
3
3
|
import type {AuthMethod} from '@shipfox/node-fastify';
|
|
4
|
-
import {closeApp, createApp} from '@shipfox/node-fastify';
|
|
4
|
+
import {ClientError, closeApp, createApp} from '@shipfox/node-fastify';
|
|
5
5
|
import {generateOpaqueToken} from '@shipfox/node-tokens';
|
|
6
6
|
import {eq, sql} from 'drizzle-orm';
|
|
7
|
-
import type {FastifyInstance} from 'fastify';
|
|
7
|
+
import type {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify';
|
|
8
8
|
import {config} from '#config.js';
|
|
9
|
+
import {RunnerLabelsReservedError} from '#core/errors.js';
|
|
9
10
|
import {hashRunnersRateLimitIdentifier} from '#core/rate-limit.js';
|
|
10
11
|
import {db} from '#db/db.js';
|
|
11
12
|
import {revokeManualRegistrationToken} from '#db/manual-registration-tokens.js';
|
|
@@ -19,9 +20,11 @@ import {
|
|
|
19
20
|
fakeRunnerSessionAuthMethod,
|
|
20
21
|
getRunnerSessionTokenClaims,
|
|
21
22
|
manualRegistrationTokenFactory,
|
|
23
|
+
provisionerTokenFactory,
|
|
22
24
|
runnersTestAuthClient,
|
|
23
25
|
} from '#test/index.js';
|
|
24
26
|
import {createRunnerRoutes} from './index.js';
|
|
27
|
+
import {createRegisterRoute} from './register.js';
|
|
25
28
|
|
|
26
29
|
const fakeUserAuth: AuthMethod = {
|
|
27
30
|
name: AUTH_USER,
|
|
@@ -123,6 +126,38 @@ describe('POST /runners/register', () => {
|
|
|
123
126
|
expect(session?.toolCapabilitiesReportedAt).toBeInstanceOf(Date);
|
|
124
127
|
});
|
|
125
128
|
|
|
129
|
+
it('strips reserved labels from manual registration', async () => {
|
|
130
|
+
const res = await app.inject({
|
|
131
|
+
method: 'POST',
|
|
132
|
+
url: '/runners/register',
|
|
133
|
+
headers: {authorization: `Bearer ${rawToken}`},
|
|
134
|
+
payload: {labels: ['linux', 'shipfox-managed', 'x64']},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const [session] = await db()
|
|
138
|
+
.select()
|
|
139
|
+
.from(runnerSessions)
|
|
140
|
+
.where(eq(runnerSessions.id, res.json().session_id));
|
|
141
|
+
|
|
142
|
+
expect(res.statusCode).toBe(200);
|
|
143
|
+
expect(session?.labels).toEqual(['linux', 'x64']);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('returns a distinct error when all manual registration labels are reserved', async () => {
|
|
147
|
+
const res = await app.inject({
|
|
148
|
+
method: 'POST',
|
|
149
|
+
url: '/runners/register',
|
|
150
|
+
headers: {authorization: `Bearer ${rawToken}`},
|
|
151
|
+
payload: {labels: ['shipfox-managed']},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(res.statusCode).toBe(400);
|
|
155
|
+
expect(res.json()).toMatchObject({
|
|
156
|
+
code: 'runner-labels-reserved',
|
|
157
|
+
details: {labels: ['shipfox-managed']},
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
126
161
|
it('exchanges an ephemeral registration token for a one-claim runner session', async () => {
|
|
127
162
|
const ephemeralRawToken = generateOpaqueToken('ephemeralRegistrationToken');
|
|
128
163
|
const token = await ephemeralRegistrationTokenFactory.create(
|
|
@@ -165,6 +200,65 @@ describe('POST /runners/register', () => {
|
|
|
165
200
|
expect(consumed?.consumedSessionId).toBe(body.session_id);
|
|
166
201
|
});
|
|
167
202
|
|
|
203
|
+
it('strips reserved labels from ephemeral registration', async () => {
|
|
204
|
+
const ephemeralRawToken = generateOpaqueToken('ephemeralRegistrationToken');
|
|
205
|
+
const provisioner = await provisionerTokenFactory.create({
|
|
206
|
+
scope: 'workspace',
|
|
207
|
+
workspaceId,
|
|
208
|
+
});
|
|
209
|
+
const token = await ephemeralRegistrationTokenFactory.create(
|
|
210
|
+
{workspaceId, provisionerId: provisioner.id},
|
|
211
|
+
{transient: {rawToken: ephemeralRawToken}},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const res = await app.inject({
|
|
215
|
+
method: 'POST',
|
|
216
|
+
url: '/runners/register',
|
|
217
|
+
headers: {authorization: `Bearer ${ephemeralRawToken}`},
|
|
218
|
+
payload: {labels: ['linux', 'shipfox-managed']},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const [session] = await db()
|
|
222
|
+
.select()
|
|
223
|
+
.from(runnerSessions)
|
|
224
|
+
.where(eq(runnerSessions.id, res.json().session_id));
|
|
225
|
+
|
|
226
|
+
expect(res.statusCode).toBe(200);
|
|
227
|
+
expect(session).toMatchObject({
|
|
228
|
+
id: res.json().session_id,
|
|
229
|
+
provisionerId: token.provisionerId,
|
|
230
|
+
labels: ['linux'],
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('preserves reserved labels for installation-scope ephemeral registration', async () => {
|
|
235
|
+
const ephemeralRawToken = generateOpaqueToken('ephemeralRegistrationToken');
|
|
236
|
+
const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
|
|
237
|
+
const token = await ephemeralRegistrationTokenFactory.create(
|
|
238
|
+
{workspaceId, provisionerId: provisioner.id},
|
|
239
|
+
{transient: {rawToken: ephemeralRawToken}},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const res = await app.inject({
|
|
243
|
+
method: 'POST',
|
|
244
|
+
url: '/runners/register',
|
|
245
|
+
headers: {authorization: `Bearer ${ephemeralRawToken}`},
|
|
246
|
+
payload: {labels: ['linux', 'shipfox-managed']},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const [session] = await db()
|
|
250
|
+
.select()
|
|
251
|
+
.from(runnerSessions)
|
|
252
|
+
.where(eq(runnerSessions.id, res.json().session_id));
|
|
253
|
+
|
|
254
|
+
expect(res.statusCode).toBe(200);
|
|
255
|
+
expect(session).toMatchObject({
|
|
256
|
+
id: res.json().session_id,
|
|
257
|
+
provisionerId: token.provisionerId,
|
|
258
|
+
labels: ['linux', 'shipfox-managed'],
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
168
262
|
it('rejects malformed capability reports without creating a runner session', async () => {
|
|
169
263
|
const res = await app.inject({
|
|
170
264
|
method: 'POST',
|
|
@@ -418,6 +512,26 @@ describe('POST /runners/register', () => {
|
|
|
418
512
|
expect(res.statusCode).toBe(200);
|
|
419
513
|
});
|
|
420
514
|
|
|
515
|
+
it('maps reserved-label-only registration failures to a public error code', () => {
|
|
516
|
+
const route = createRegisterRoute(runnersTestAuthClient);
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
route.errorHandler?.(
|
|
520
|
+
new RunnerLabelsReservedError(['shipfox-managed']),
|
|
521
|
+
{} as FastifyRequest,
|
|
522
|
+
{} as FastifyReply,
|
|
523
|
+
);
|
|
524
|
+
throw new Error('Expected register route error handler to throw');
|
|
525
|
+
} catch (error) {
|
|
526
|
+
expect(error).toBeInstanceOf(ClientError);
|
|
527
|
+
expect(error).toMatchObject({
|
|
528
|
+
code: 'runner-labels-reserved',
|
|
529
|
+
details: {labels: ['shipfox-managed']},
|
|
530
|
+
status: 400,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
421
535
|
async function seedEphemeralRegisterRateLimit(
|
|
422
536
|
tokenId: string,
|
|
423
537
|
seedCount: number,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
EmptyRunnerLabelsError,
|
|
6
6
|
RegistrationTokenConsumedError,
|
|
7
7
|
RegistrationTokenExpiredError,
|
|
8
|
+
RunnerLabelsReservedError,
|
|
8
9
|
} from '#core/errors.js';
|
|
9
10
|
import {registerRunnerSession} from '#core/runner-sessions.js';
|
|
10
11
|
import {getRunnerContext} from '#presentation/auth/index.js';
|
|
@@ -23,6 +24,12 @@ export function createRegisterRoute(auth: AuthInterModuleClient) {
|
|
|
23
24
|
},
|
|
24
25
|
preHandler: createEphemeralRegisterRateLimitPreHandler(),
|
|
25
26
|
errorHandler: (error, request) => {
|
|
27
|
+
if (error instanceof RunnerLabelsReservedError) {
|
|
28
|
+
throw new ClientError(error.message, 'runner-labels-reserved', {
|
|
29
|
+
details: {labels: error.labels},
|
|
30
|
+
status: 400,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
26
33
|
if (error instanceof EmptyRunnerLabelsError) {
|
|
27
34
|
throw new ClientError(error.message, 'empty-runner-labels', {status: 400});
|
|
28
35
|
}
|
|
@@ -113,6 +113,39 @@ describe('POST /provisioners/runner-instances/report', () => {
|
|
|
113
113
|
expect(rows[0]?.reportedAt.toISOString()).toBe(reportedAt);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
it('strips reserved labels from workspace runner reports', async () => {
|
|
117
|
+
const res = await app.inject({
|
|
118
|
+
method: 'POST',
|
|
119
|
+
url: '/provisioners/runner-instances/report',
|
|
120
|
+
headers: {authorization: `Bearer ${VALID_PROVISIONER_TOKEN}`},
|
|
121
|
+
payload: {
|
|
122
|
+
events: [
|
|
123
|
+
{
|
|
124
|
+
provider_runner_id: 'provisioned-runner-reserved-label',
|
|
125
|
+
labels: ['linux', 'shipfox-managed'],
|
|
126
|
+
state: 'starting',
|
|
127
|
+
reported_at: new Date('2025-01-01T00:00:00.000Z').toISOString(),
|
|
128
|
+
provider_kind: 'docker',
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const [row] = await db()
|
|
135
|
+
.select()
|
|
136
|
+
.from(providerRunners)
|
|
137
|
+
.where(
|
|
138
|
+
and(
|
|
139
|
+
eq(providerRunners.workspaceId, workspaceId),
|
|
140
|
+
eq(providerRunners.provisionerId, provisionerTokenId),
|
|
141
|
+
eq(providerRunners.providerRunnerId, 'provisioned-runner-reserved-label'),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(res.statusCode).toBe(200);
|
|
146
|
+
expect(row?.labels).toEqual(['linux']);
|
|
147
|
+
});
|
|
148
|
+
|
|
116
149
|
it('returns 400 when the batch exceeds the DTO limit', async () => {
|
|
117
150
|
const event = {
|
|
118
151
|
provider_runner_id: 'provisioned-runner-1',
|
|
@@ -20,6 +20,7 @@ export const reportRunnerInstancesRoute = defineRoute({
|
|
|
20
20
|
handler: async (request) => {
|
|
21
21
|
const context = requireProvisionerContext(request);
|
|
22
22
|
const result = await reportRunnerInstances({
|
|
23
|
+
scope: context.scope,
|
|
23
24
|
workspaceId: context.scope === 'workspace' ? context.workspaceId : null,
|
|
24
25
|
provisionerId: context.provisionerTokenId,
|
|
25
26
|
events: request.body.events.map((event) => ({
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
fakeLeaseTokenAuthMethod,
|
|
22
22
|
fakeRunnerSessionAuthMethod,
|
|
23
|
+
provisionerTokenFactory,
|
|
23
24
|
runnersTestAuthClient,
|
|
24
25
|
} from '#test/index.js';
|
|
25
26
|
import {createRunnerRoutes} from './index.js';
|
|
@@ -29,15 +30,27 @@ import {
|
|
|
29
30
|
} from './runner-enrollment.js';
|
|
30
31
|
|
|
31
32
|
const token = 'provisioner-test-token';
|
|
33
|
+
const workspaceToken = 'workspace-provisioner-test-token';
|
|
32
34
|
const fakeUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
|
|
33
35
|
|
|
34
36
|
describe('runner enrollment control plane', () => {
|
|
35
37
|
let app: FastifyInstance;
|
|
36
38
|
let provisionerId: string;
|
|
39
|
+
let workspaceProvisionerId: string;
|
|
40
|
+
let workspaceProvisionerWorkspaceId: string;
|
|
37
41
|
const provisionerAuth: AuthMethod = {
|
|
38
42
|
name: AUTH_PROVISIONER_TOKEN,
|
|
39
43
|
authenticate: (request: FastifyRequest) => {
|
|
40
|
-
|
|
44
|
+
const rawToken = extractBearerToken(request.headers.authorization);
|
|
45
|
+
if (rawToken === workspaceToken) {
|
|
46
|
+
setProvisionerContext(request, {
|
|
47
|
+
scope: 'workspace',
|
|
48
|
+
workspaceId: workspaceProvisionerWorkspaceId,
|
|
49
|
+
provisionerTokenId: workspaceProvisionerId,
|
|
50
|
+
});
|
|
51
|
+
return Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
if (rawToken !== token)
|
|
41
54
|
throw new ClientError('Invalid provisioner token', 'unauthorized', {status: 401});
|
|
42
55
|
setProvisionerContext(request, {scope: 'installation', provisionerTokenId: provisionerId});
|
|
43
56
|
return Promise.resolve();
|
|
@@ -64,8 +77,17 @@ describe('runner enrollment control plane', () => {
|
|
|
64
77
|
await closeApp();
|
|
65
78
|
});
|
|
66
79
|
|
|
67
|
-
beforeEach(() => {
|
|
68
|
-
|
|
80
|
+
beforeEach(async () => {
|
|
81
|
+
const provisioner = await provisionerTokenFactory.create({scope: 'installation'});
|
|
82
|
+
const workspaceProvisioner = await provisionerTokenFactory.create({
|
|
83
|
+
scope: 'workspace',
|
|
84
|
+
});
|
|
85
|
+
if (workspaceProvisioner.scope !== 'workspace') {
|
|
86
|
+
throw new Error('Expected a workspace provisioner token');
|
|
87
|
+
}
|
|
88
|
+
provisionerId = provisioner.id;
|
|
89
|
+
workspaceProvisionerId = workspaceProvisioner.id;
|
|
90
|
+
workspaceProvisionerWorkspaceId = workspaceProvisioner.workspaceId;
|
|
69
91
|
});
|
|
70
92
|
|
|
71
93
|
it('uses the requested assignment wait below the server cap and caps larger requests', () => {
|
|
@@ -112,7 +134,11 @@ describe('runner enrollment control plane', () => {
|
|
|
112
134
|
method: 'POST',
|
|
113
135
|
url: '/runner-control/enrollment',
|
|
114
136
|
headers: {authorization: `Bearer ${controlToken}`},
|
|
115
|
-
payload: {
|
|
137
|
+
payload: {
|
|
138
|
+
labels: ['Linux', 'linux', 'shipfox-managed'],
|
|
139
|
+
provider_kind: 'docker',
|
|
140
|
+
protocol_version: '1',
|
|
141
|
+
},
|
|
116
142
|
});
|
|
117
143
|
expect(enrolled.statusCode).toBe(200);
|
|
118
144
|
expect(enrolled.json()).toEqual({activation_token: null});
|
|
@@ -131,7 +157,7 @@ describe('runner enrollment control plane', () => {
|
|
|
131
157
|
.where(eq(providerRunners.id, runner.runner_instance_id));
|
|
132
158
|
expect(instance).toMatchObject({
|
|
133
159
|
provisionerId,
|
|
134
|
-
labels: ['linux'],
|
|
160
|
+
labels: ['linux', 'shipfox-managed'],
|
|
135
161
|
providerRunnerId: 'container-1',
|
|
136
162
|
state: 'running',
|
|
137
163
|
protocolVersion: '1',
|
|
@@ -150,6 +176,40 @@ describe('runner enrollment control plane', () => {
|
|
|
150
176
|
expect(jobs.statusCode).toBe(401);
|
|
151
177
|
});
|
|
152
178
|
|
|
179
|
+
it('strips reserved labels when a workspace provisioner enrolls a runner', async () => {
|
|
180
|
+
const created = await app.inject({
|
|
181
|
+
method: 'POST',
|
|
182
|
+
url: '/provisioners/runner-instances/batch',
|
|
183
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
184
|
+
payload: {runner_instances: [{}]},
|
|
185
|
+
});
|
|
186
|
+
const runner = created.json().runner_instances[0];
|
|
187
|
+
const exchanged = await app.inject({
|
|
188
|
+
method: 'POST',
|
|
189
|
+
url: '/runner-enrollment/exchange',
|
|
190
|
+
payload: {bootstrap_token: runner.bootstrap_token},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const enrolled = await app.inject({
|
|
194
|
+
method: 'POST',
|
|
195
|
+
url: '/runner-control/enrollment',
|
|
196
|
+
headers: {authorization: `Bearer ${exchanged.json().control_session_token}`},
|
|
197
|
+
payload: {
|
|
198
|
+
labels: ['linux', 'shipfox-managed'],
|
|
199
|
+
provider_kind: 'docker',
|
|
200
|
+
protocol_version: '1',
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const [instance] = await db()
|
|
205
|
+
.select()
|
|
206
|
+
.from(providerRunners)
|
|
207
|
+
.where(eq(providerRunners.id, runner.runner_instance_id));
|
|
208
|
+
|
|
209
|
+
expect(enrolled.statusCode).toBe(200);
|
|
210
|
+
expect(instance?.labels).toEqual(['linux']);
|
|
211
|
+
});
|
|
212
|
+
|
|
153
213
|
it('promotes an intended reservation during enrollment and returns its activation token', async () => {
|
|
154
214
|
const workspaceId = crypto.randomUUID();
|
|
155
215
|
const [reservation] = await db()
|