@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.
Files changed (129) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +35 -0
  3. package/dist/config.d.ts +3 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +24 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/errors.d.ts +4 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +1 -1
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +1 -1
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/rate-limit.d.ts.map +1 -1
  16. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  17. package/dist/core/runner-control-sessions.js +37 -11
  18. package/dist/core/runner-control-sessions.js.map +1 -1
  19. package/dist/core/runner-instances.d.ts +1 -0
  20. package/dist/core/runner-instances.d.ts.map +1 -1
  21. package/dist/core/runner-instances.js.map +1 -1
  22. package/dist/core/runner-labels.d.ts +11 -0
  23. package/dist/core/runner-labels.d.ts.map +1 -0
  24. package/dist/core/runner-labels.js +41 -0
  25. package/dist/core/runner-labels.js.map +1 -0
  26. package/dist/core/runner-sessions.d.ts.map +1 -1
  27. package/dist/core/runner-sessions.js +5 -1
  28. package/dist/core/runner-sessions.js.map +1 -1
  29. package/dist/db/db.d.ts +64 -64
  30. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  31. package/dist/db/ephemeral-registration-tokens.js +12 -2
  32. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  33. package/dist/db/index.d.ts +1 -1
  34. package/dist/db/index.d.ts.map +1 -1
  35. package/dist/db/index.js +1 -1
  36. package/dist/db/index.js.map +1 -1
  37. package/dist/db/reservations.d.ts.map +1 -1
  38. package/dist/db/reservations.js +36 -10
  39. package/dist/db/reservations.js.map +1 -1
  40. package/dist/db/runner-instances.d.ts +4 -0
  41. package/dist/db/runner-instances.d.ts.map +1 -1
  42. package/dist/db/runner-instances.js +16 -5
  43. package/dist/db/runner-instances.js.map +1 -1
  44. package/dist/db/runner-sessions.d.ts.map +1 -1
  45. package/dist/db/runner-sessions.js +33 -15
  46. package/dist/db/runner-sessions.js.map +1 -1
  47. package/dist/db/schema/admin-command-results.d.ts +1 -1
  48. package/dist/db/schema/ephemeral-registration-tokens.d.ts +1 -1
  49. package/dist/db/schema/manual-registration-tokens.d.ts +1 -1
  50. package/dist/db/schema/outbox.d.ts +1 -1
  51. package/dist/db/schema/pending-job-executions.d.ts +3 -3
  52. package/dist/db/schema/provisioner-capability-snapshots.d.ts +3 -3
  53. package/dist/db/schema/provisioner-tokens.d.ts +2 -2
  54. package/dist/db/schema/rate-limits.d.ts +1 -1
  55. package/dist/db/schema/reservations.d.ts +3 -3
  56. package/dist/db/schema/runner-activation-tokens.d.ts +1 -1
  57. package/dist/db/schema/runner-control-sessions.d.ts +2 -2
  58. package/dist/db/schema/runner-instances.d.ts +4 -4
  59. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  60. package/dist/db/schema/runner-instances.js +1 -0
  61. package/dist/db/schema/runner-instances.js.map +1 -1
  62. package/dist/db/schema/runner-sessions.d.ts +4 -4
  63. package/dist/db/schema/running-job-executions.d.ts +5 -5
  64. package/dist/metrics/index.d.ts +2 -1
  65. package/dist/metrics/index.d.ts.map +1 -1
  66. package/dist/metrics/index.js +1 -1
  67. package/dist/metrics/index.js.map +1 -1
  68. package/dist/metrics/instance.d.ts +13 -8
  69. package/dist/metrics/instance.d.ts.map +1 -1
  70. package/dist/metrics/instance.js +8 -0
  71. package/dist/metrics/instance.js.map +1 -1
  72. package/dist/metrics/service.d.ts.map +1 -1
  73. package/dist/metrics/service.js +20 -4
  74. package/dist/metrics/service.js.map +1 -1
  75. package/dist/presentation/dto/admin-provisioner-token.d.ts +1 -1
  76. package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -1
  77. package/dist/presentation/dto/admin-runner-instances.d.ts +2 -2
  78. package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -1
  79. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  80. package/dist/presentation/routes/poll-demand.js +12 -6
  81. package/dist/presentation/routes/poll-demand.js.map +1 -1
  82. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  83. package/dist/presentation/routes/register.d.ts.map +1 -1
  84. package/dist/presentation/routes/register.js +9 -1
  85. package/dist/presentation/routes/register.js.map +1 -1
  86. package/dist/presentation/routes/report-runner-instances.d.ts.map +1 -1
  87. package/dist/presentation/routes/report-runner-instances.js +1 -0
  88. package/dist/presentation/routes/report-runner-instances.js.map +1 -1
  89. package/dist/tsconfig.test.tsbuildinfo +1 -1
  90. package/drizzle/0004_huge_skreet.sql +1 -0
  91. package/drizzle/meta/0004_snapshot.json +2194 -0
  92. package/drizzle/meta/_journal.json +7 -0
  93. package/package.json +9 -9
  94. package/src/config.test.ts +81 -0
  95. package/src/config.ts +39 -2
  96. package/src/core/errors.ts +9 -0
  97. package/src/core/index.ts +1 -0
  98. package/src/core/runner-control-sessions.test.ts +404 -0
  99. package/src/core/runner-control-sessions.ts +51 -11
  100. package/src/core/runner-instances.ts +1 -0
  101. package/src/core/runner-labels.test.ts +53 -0
  102. package/src/core/runner-labels.ts +51 -0
  103. package/src/core/runner-sessions.test.ts +34 -1
  104. package/src/core/runner-sessions.ts +8 -1
  105. package/src/db/ephemeral-registration-tokens.ts +15 -1
  106. package/src/db/index.ts +1 -0
  107. package/src/db/reservations.test.ts +537 -0
  108. package/src/db/reservations.ts +116 -17
  109. package/src/db/runner-instances.test.ts +276 -1
  110. package/src/db/runner-instances.ts +39 -3
  111. package/src/db/runner-sessions.ts +39 -15
  112. package/src/db/schema/runner-instances.ts +3 -0
  113. package/src/metrics/index.ts +2 -0
  114. package/src/metrics/instance.ts +18 -0
  115. package/src/metrics/service.test.ts +99 -0
  116. package/src/metrics/service.ts +24 -4
  117. package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
  118. package/src/presentation/routes/poll-demand.test.ts +116 -3
  119. package/src/presentation/routes/poll-demand.ts +18 -6
  120. package/src/presentation/routes/register.test.ts +116 -2
  121. package/src/presentation/routes/register.ts +7 -0
  122. package/src/presentation/routes/report-runner-instances.test.ts +33 -0
  123. package/src/presentation/routes/report-runner-instances.ts +1 -0
  124. package/src/presentation/routes/runner-enrollment.test.ts +65 -5
  125. package/src/temporal/activities/maintenance-activities.test.ts +24 -31
  126. package/test/env.ts +1 -0
  127. package/test/fixtures/late-runner-enrollment.ts +103 -0
  128. package/test/index.ts +5 -0
  129. 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: vi.fn().mockResolvedValue(new Set())},
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
- function body(params: {max_reservations: number}) {
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(templates: PollDemandTemplateDto[]): ReservationTemplate[] {
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: config.RESERVATION_TTL_SECONDS,
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: config.RESERVATION_TTL_SECONDS,
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
- if (extractBearerToken(request.headers.authorization) !== token)
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
- provisionerId = crypto.randomUUID();
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: {labels: ['Linux', 'linux'], provider_kind: 'docker', protocol_version: '1'},
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()