@shipfox/api-runners 13.0.0 → 14.0.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 +19 -0
- 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/provisioner-tokens.d.ts +8 -0
- package/dist/core/provisioner-tokens.d.ts.map +1 -1
- package/dist/core/provisioner-tokens.js +14 -1
- package/dist/core/provisioner-tokens.js.map +1 -1
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +44 -2
- package/dist/db/job-executions.js.map +1 -1
- package/dist/presentation/routes/list-active-provisioners.d.ts +4 -0
- package/dist/presentation/routes/list-active-provisioners.d.ts.map +1 -1
- package/dist/presentation/routes/list-active-provisioners.js +36 -24
- package/dist/presentation/routes/list-active-provisioners.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +2 -0
- package/src/core/provisioner-tokens.test.ts +46 -0
- package/src/core/provisioner-tokens.ts +17 -1
- package/src/db/job-executions.test.ts +100 -8
- package/src/db/job-executions.ts +75 -9
- package/src/presentation/routes/list-active-provisioners.ts +34 -17
- package/src/presentation/routes/provisioner-tokens.test.ts +44 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-runners",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "14.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"@shipfox/api-common-dto": "12.0.0",
|
|
25
25
|
"@shipfox/api-auth-context": "12.2.0",
|
|
26
26
|
"@shipfox/api-auth-dto": "12.0.0",
|
|
27
|
-
"@shipfox/api-runners-dto": "
|
|
28
|
-
"@shipfox/api-workflows-dto": "
|
|
27
|
+
"@shipfox/api-runners-dto": "14.0.0",
|
|
28
|
+
"@shipfox/api-workflows-dto": "14.0.0",
|
|
29
29
|
"@shipfox/config": "1.2.4",
|
|
30
30
|
"@shipfox/inter-module": "0.2.3",
|
|
31
31
|
"@shipfox/node-drizzle": "0.3.5",
|
package/src/core/index.ts
CHANGED
|
@@ -40,7 +40,9 @@ export {
|
|
|
40
40
|
createAdministratorInstallationProvisionerToken,
|
|
41
41
|
createInstallationProvisionerToken,
|
|
42
42
|
createWorkspaceProvisionerToken,
|
|
43
|
+
type InstallationRunnersStatus,
|
|
43
44
|
installationProvisionerTokenStatus,
|
|
45
|
+
installationRunnersStatus,
|
|
44
46
|
listActiveProvisioners,
|
|
45
47
|
listAdministratorInstallationProvisionerTokens,
|
|
46
48
|
listUsableProvisionerTokens,
|
|
@@ -6,6 +6,7 @@ import {provisionerTokenFactory} from '#test/index.js';
|
|
|
6
6
|
import {
|
|
7
7
|
createInstallationProvisionerToken,
|
|
8
8
|
createWorkspaceProvisionerToken,
|
|
9
|
+
installationRunnersStatus,
|
|
9
10
|
listUsableProvisionerTokens,
|
|
10
11
|
revokeInstallationProvisionerToken,
|
|
11
12
|
revokeWorkspaceProvisionerToken,
|
|
@@ -79,3 +80,48 @@ describe('provisioner token core', () => {
|
|
|
79
80
|
await expect(result).rejects.toThrow(`Provisioner token not found: ${token.id}`);
|
|
80
81
|
});
|
|
81
82
|
});
|
|
83
|
+
|
|
84
|
+
describe('installationRunnersStatus', () => {
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
await db().delete(provisionerTokens).where(eq(provisionerTokens.scope, 'installation'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reports managed when reserved labels are configured, without any token', async () => {
|
|
90
|
+
await expect(installationRunnersStatus(['linux'])).resolves.toBe('managed');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports none without reserved labels and without installation tokens', async () => {
|
|
94
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reports managed when an installation token is active', async () => {
|
|
98
|
+
await provisionerTokenFactory.create({scope: 'installation'});
|
|
99
|
+
|
|
100
|
+
await expect(installationRunnersStatus([])).resolves.toBe('managed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('reports none when the only installation token has expired', async () => {
|
|
104
|
+
await provisionerTokenFactory.create({
|
|
105
|
+
scope: 'installation',
|
|
106
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reports none when the only installation token is revoked', async () => {
|
|
113
|
+
const token = await provisionerTokenFactory.create({scope: 'installation'});
|
|
114
|
+
await revokeInstallationProvisionerToken({
|
|
115
|
+
tokenId: token.id,
|
|
116
|
+
revokedByUserId: crypto.randomUUID(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('ignores workspace-scope tokens', async () => {
|
|
123
|
+
await provisionerTokenFactory.create({workspaceId: crypto.randomUUID()});
|
|
124
|
+
|
|
125
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
revokeInstallationProvisionerTokenWithAudit,
|
|
15
15
|
revokeProvisionerToken,
|
|
16
16
|
} from '#db/provisioner-tokens.js';
|
|
17
|
-
import {config} from '../config.js';
|
|
17
|
+
import {config, runnerReservedLabels} from '../config.js';
|
|
18
18
|
import type {ActiveProvisionerToken, ProvisionerToken} from './entities/provisioner-token.js';
|
|
19
19
|
import {
|
|
20
20
|
ProvisionerAdminIdempotencyReplayUnavailableError,
|
|
@@ -167,6 +167,22 @@ export function installationProvisionerTokenStatus(
|
|
|
167
167
|
return 'active';
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
export type InstallationRunnersStatus = 'managed' | 'none';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether the installation itself provides runner capacity. Both signals are
|
|
174
|
+
* declarations of intent, not heartbeats: a quiet hosted fleet must not flip
|
|
175
|
+
* the value. Reserved labels dominate even when every installation token has
|
|
176
|
+
* expired or been revoked.
|
|
177
|
+
*/
|
|
178
|
+
export async function installationRunnersStatus(
|
|
179
|
+
reservedLabels: readonly string[] = runnerReservedLabels,
|
|
180
|
+
): Promise<InstallationRunnersStatus> {
|
|
181
|
+
if (reservedLabels.length > 0) return 'managed';
|
|
182
|
+
const {tokens} = await listInstallationProvisionerTokens({limit: 1, status: 'active'});
|
|
183
|
+
return tokens.length > 0 ? 'managed' : 'none';
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
export async function listAdministratorInstallationProvisionerTokens(params: {
|
|
171
187
|
limit: number;
|
|
172
188
|
cursor?: TimestampIdCursor | undefined;
|
|
@@ -482,6 +482,79 @@ describe('claimPendingJobExecution', () => {
|
|
|
482
482
|
expect(runner?.firstClaimedAt).toBeInstanceOf(Date);
|
|
483
483
|
});
|
|
484
484
|
|
|
485
|
+
it('releases a provisioned runner reservation on its first claim only', async () => {
|
|
486
|
+
const provisionerId = crypto.randomUUID();
|
|
487
|
+
const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
488
|
+
const [reservation] = await db()
|
|
489
|
+
.insert(reservations)
|
|
490
|
+
.values({
|
|
491
|
+
workspaceId,
|
|
492
|
+
provisionerId,
|
|
493
|
+
requiredLabels: sessionLabels,
|
|
494
|
+
count: 2,
|
|
495
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
496
|
+
})
|
|
497
|
+
.returning({id: reservations.id});
|
|
498
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
499
|
+
|
|
500
|
+
const runner = await providerRunnerFactory.create({
|
|
501
|
+
workspaceId,
|
|
502
|
+
provisionerId,
|
|
503
|
+
providerRunnerId,
|
|
504
|
+
reservationId: reservation.id,
|
|
505
|
+
runnerSessionId,
|
|
506
|
+
state: 'running',
|
|
507
|
+
labels: sessionLabels,
|
|
508
|
+
});
|
|
509
|
+
await db()
|
|
510
|
+
.update(runnerSessions)
|
|
511
|
+
.set({
|
|
512
|
+
registrationTokenKind: 'ephemeral',
|
|
513
|
+
maxClaims: 2,
|
|
514
|
+
provisionerId,
|
|
515
|
+
providerRunnerId,
|
|
516
|
+
})
|
|
517
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
518
|
+
|
|
519
|
+
const firstPending = await pendingJobFactory.create({workspaceId});
|
|
520
|
+
const firstClaim = await claimPendingJobExecution({
|
|
521
|
+
workspaceId,
|
|
522
|
+
runnerSessionId,
|
|
523
|
+
maxClaims: 2,
|
|
524
|
+
});
|
|
525
|
+
const [afterFirstClaim] = await db()
|
|
526
|
+
.select({count: reservations.count})
|
|
527
|
+
.from(reservations)
|
|
528
|
+
.where(eq(reservations.id, reservation.id));
|
|
529
|
+
const [runnerAfterFirstClaim] = await db()
|
|
530
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
531
|
+
.from(providerRunners)
|
|
532
|
+
.where(eq(providerRunners.id, runner.id));
|
|
533
|
+
const firstReleaseAt = runnerAfterFirstClaim?.reservationReleasedAt;
|
|
534
|
+
|
|
535
|
+
const secondPending = await pendingJobFactory.create({workspaceId});
|
|
536
|
+
const secondClaim = await claimPendingJobExecution({
|
|
537
|
+
workspaceId,
|
|
538
|
+
runnerSessionId,
|
|
539
|
+
maxClaims: 2,
|
|
540
|
+
});
|
|
541
|
+
const [afterSecondClaim] = await db()
|
|
542
|
+
.select({count: reservations.count})
|
|
543
|
+
.from(reservations)
|
|
544
|
+
.where(eq(reservations.id, reservation.id));
|
|
545
|
+
const [runnerAfterSecondClaim] = await db()
|
|
546
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
547
|
+
.from(providerRunners)
|
|
548
|
+
.where(eq(providerRunners.id, runner.id));
|
|
549
|
+
|
|
550
|
+
expect(firstClaim?.jobExecutionId).toBe(firstPending.jobExecutionId);
|
|
551
|
+
expect(afterFirstClaim?.count).toBe(1);
|
|
552
|
+
expect(firstReleaseAt).toBeInstanceOf(Date);
|
|
553
|
+
expect(secondClaim?.jobExecutionId).toBe(secondPending.jobExecutionId);
|
|
554
|
+
expect(afterSecondClaim?.count).toBe(1);
|
|
555
|
+
expect(runnerAfterSecondClaim?.reservationReleasedAt).toEqual(firstReleaseAt);
|
|
556
|
+
});
|
|
557
|
+
|
|
485
558
|
it('allows a manual session to claim repeatedly', async () => {
|
|
486
559
|
const first = await pendingJobFactory.create({workspaceId});
|
|
487
560
|
const second = await pendingJobFactory.create({workspaceId});
|
|
@@ -1037,7 +1110,7 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1037
1110
|
expect(rows[0]?.cancellationRequestedAt).not.toBeNull();
|
|
1038
1111
|
});
|
|
1039
1112
|
|
|
1040
|
-
it('
|
|
1113
|
+
it('does not double-release a reservation when a claimed runner becomes terminal', async () => {
|
|
1041
1114
|
const provisionerId = crypto.randomUUID();
|
|
1042
1115
|
const providerRunnerId = crypto.randomUUID();
|
|
1043
1116
|
const [reservation] = await db()
|
|
@@ -1046,7 +1119,7 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1046
1119
|
workspaceId,
|
|
1047
1120
|
provisionerId,
|
|
1048
1121
|
requiredLabels: sessionLabels,
|
|
1049
|
-
count:
|
|
1122
|
+
count: 2,
|
|
1050
1123
|
expiresAt: new Date(Date.now() + 60_000),
|
|
1051
1124
|
})
|
|
1052
1125
|
.returning({id: reservations.id});
|
|
@@ -1057,12 +1130,29 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1057
1130
|
providerRunnerId,
|
|
1058
1131
|
reservationId: reservation.id,
|
|
1059
1132
|
runnerSessionId,
|
|
1060
|
-
state: '
|
|
1061
|
-
terminatedAt: new Date(),
|
|
1133
|
+
state: 'running',
|
|
1062
1134
|
});
|
|
1135
|
+
await db()
|
|
1136
|
+
.update(runnerSessions)
|
|
1137
|
+
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
|
|
1138
|
+
.where(eq(runnerSessions.id, runnerSessionId));
|
|
1063
1139
|
const pending = await pendingJobFactory.create({workspaceId});
|
|
1064
|
-
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims:
|
|
1140
|
+
const claimed = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
1065
1141
|
expect(claimed?.jobExecutionId).toBe(pending.jobExecutionId);
|
|
1142
|
+
const [afterClaim] = await db()
|
|
1143
|
+
.select({count: reservations.count})
|
|
1144
|
+
.from(reservations)
|
|
1145
|
+
.where(eq(reservations.id, reservation.id));
|
|
1146
|
+
const [runnerAfterClaim] = await db()
|
|
1147
|
+
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
1148
|
+
.from(providerRunners)
|
|
1149
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1150
|
+
expect(afterClaim?.count).toBe(1);
|
|
1151
|
+
expect(runnerAfterClaim?.reservationReleasedAt).toBeInstanceOf(Date);
|
|
1152
|
+
await db()
|
|
1153
|
+
.update(providerRunners)
|
|
1154
|
+
.set({state: 'terminated', terminatedAt: new Date()})
|
|
1155
|
+
.where(eq(providerRunners.providerRunnerId, providerRunnerId));
|
|
1066
1156
|
await db()
|
|
1067
1157
|
.update(runningJobExecutions)
|
|
1068
1158
|
.set({provisionerId, providerRunnerId})
|
|
@@ -1070,9 +1160,11 @@ describe('reconcileTerminalJobExecution', () => {
|
|
|
1070
1160
|
|
|
1071
1161
|
await reconcileTerminalJobExecution({jobExecutionId: pending.jobExecutionId});
|
|
1072
1162
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1163
|
+
const [afterTerminal] = await db()
|
|
1164
|
+
.select({count: reservations.count})
|
|
1165
|
+
.from(reservations)
|
|
1166
|
+
.where(eq(reservations.id, reservation.id));
|
|
1167
|
+
expect(afterTerminal?.count).toBe(1);
|
|
1076
1168
|
const [runner] = await db()
|
|
1077
1169
|
.select({reservationReleasedAt: providerRunners.reservationReleasedAt})
|
|
1078
1170
|
.from(providerRunners)
|
package/src/db/job-executions.ts
CHANGED
|
@@ -38,9 +38,14 @@ import {
|
|
|
38
38
|
} from '#metrics/instance.js';
|
|
39
39
|
import type {Tx} from './db.js';
|
|
40
40
|
import {db} from './db.js';
|
|
41
|
-
import {
|
|
41
|
+
import {lockRunnerReservationAdvisoryKeysTx} from './reservation-locks.js';
|
|
42
|
+
import {
|
|
43
|
+
releaseReservationUnits,
|
|
44
|
+
releaseTerminalRunnerInstanceReservationsByIds,
|
|
45
|
+
} from './reservations.js';
|
|
42
46
|
import {runnersOutbox} from './schema/outbox.js';
|
|
43
47
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
48
|
+
import {reservations} from './schema/reservations.js';
|
|
44
49
|
import {providerRunners} from './schema/runner-instances.js';
|
|
45
50
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
46
51
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
@@ -262,6 +267,32 @@ export async function claimPendingJobExecution(params: {
|
|
|
262
267
|
providerRunnerId = session.providerRunnerId;
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
const runnerInstanceCondition = runnerInstanceId
|
|
271
|
+
? eq(providerRunners.id, runnerInstanceId)
|
|
272
|
+
: provisionerId && providerRunnerId
|
|
273
|
+
? and(
|
|
274
|
+
eq(providerRunners.provisionerId, provisionerId),
|
|
275
|
+
eq(providerRunners.providerRunnerId, providerRunnerId),
|
|
276
|
+
)
|
|
277
|
+
: null;
|
|
278
|
+
if (runnerInstanceCondition && provisionerId) {
|
|
279
|
+
const [runner] = await tx
|
|
280
|
+
.select({
|
|
281
|
+
reservationId: providerRunners.reservationId,
|
|
282
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
283
|
+
})
|
|
284
|
+
.from(providerRunners)
|
|
285
|
+
.where(runnerInstanceCondition)
|
|
286
|
+
.limit(1);
|
|
287
|
+
await lockRunnerReservationAdvisoryKeysTx(tx, {
|
|
288
|
+
provisionerId,
|
|
289
|
+
reservationIds: [runner?.reservationId, runner?.intendedReservationId].filter(
|
|
290
|
+
(reservationId): reservationId is string =>
|
|
291
|
+
reservationId !== null && reservationId !== undefined,
|
|
292
|
+
),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
265
296
|
// `id` is a uuidv7 (time-ordered), so it is a deterministic FIFO tiebreaker
|
|
266
297
|
// for rows sharing a created_at within a batch. Lock only the FIFO candidate before
|
|
267
298
|
// attempting its execution advisory lock; putting pg_try_advisory_xact_lock in this
|
|
@@ -328,14 +359,6 @@ export async function claimPendingJobExecution(params: {
|
|
|
328
359
|
launchKind: params.maxClaims === null ? 'manual' : 'unknown',
|
|
329
360
|
};
|
|
330
361
|
|
|
331
|
-
const runnerInstanceCondition = runnerInstanceId
|
|
332
|
-
? eq(providerRunners.id, runnerInstanceId)
|
|
333
|
-
: provisionerId && providerRunnerId
|
|
334
|
-
? and(
|
|
335
|
-
eq(providerRunners.provisionerId, provisionerId),
|
|
336
|
-
eq(providerRunners.providerRunnerId, providerRunnerId),
|
|
337
|
-
)
|
|
338
|
-
: null;
|
|
339
362
|
if (runnerInstanceCondition) {
|
|
340
363
|
const [claimedRunner] = await tx
|
|
341
364
|
.update(providerRunners)
|
|
@@ -350,6 +373,8 @@ export async function claimPendingJobExecution(params: {
|
|
|
350
373
|
provider: providerRunners.providerKind,
|
|
351
374
|
launchKind: providerRunners.launchKind,
|
|
352
375
|
runnerInstanceId: providerRunners.id,
|
|
376
|
+
reservationId: providerRunners.reservationId,
|
|
377
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
353
378
|
sessionCreatedAtEpochMs: sql<number | null>`(
|
|
354
379
|
select extract(epoch from ${runnerSessions.createdAt})::double precision * 1000
|
|
355
380
|
from ${runnerSessions}
|
|
@@ -360,6 +385,47 @@ export async function claimPendingJobExecution(params: {
|
|
|
360
385
|
queueTimeObservation.provider = claimedRunner.provider;
|
|
361
386
|
queueTimeObservation.launchKind = claimedRunner.launchKind;
|
|
362
387
|
}
|
|
388
|
+
if (claimedRunner?.isFirstClaim) {
|
|
389
|
+
const [releasedRunner] = await tx
|
|
390
|
+
.update(providerRunners)
|
|
391
|
+
.set({
|
|
392
|
+
intendedReservationId: null,
|
|
393
|
+
reservationReleasedAt: sql`now()`,
|
|
394
|
+
updatedAt: sql`now()`,
|
|
395
|
+
})
|
|
396
|
+
.where(
|
|
397
|
+
and(
|
|
398
|
+
eq(providerRunners.id, claimedRunner.runnerInstanceId),
|
|
399
|
+
or(
|
|
400
|
+
isNotNull(providerRunners.reservationId),
|
|
401
|
+
isNotNull(providerRunners.intendedReservationId),
|
|
402
|
+
),
|
|
403
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
.returning({
|
|
407
|
+
provisionerId: providerRunners.provisionerId,
|
|
408
|
+
});
|
|
409
|
+
const reservationId = claimedRunner.intendedReservationId ?? claimedRunner.reservationId;
|
|
410
|
+
if (releasedRunner?.provisionerId && reservationId) {
|
|
411
|
+
const [reservation] = await tx
|
|
412
|
+
.select({workspaceId: reservations.workspaceId})
|
|
413
|
+
.from(reservations)
|
|
414
|
+
.where(
|
|
415
|
+
and(
|
|
416
|
+
eq(reservations.id, reservationId),
|
|
417
|
+
eq(reservations.provisionerId, releasedRunner.provisionerId),
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
.limit(1);
|
|
421
|
+
if (reservation)
|
|
422
|
+
await releaseReservationUnits(tx, {
|
|
423
|
+
workspaceId: reservation.workspaceId,
|
|
424
|
+
provisionerId: releasedRunner.provisionerId,
|
|
425
|
+
releases: [{reservationId, count: 1}],
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
363
429
|
if (
|
|
364
430
|
claimedRunner?.isFirstClaim &&
|
|
365
431
|
claimedRunner.firstClaimedAt !== null &&
|
|
@@ -2,24 +2,41 @@ import {requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
|
2
2
|
import {listActiveProvisionersResponseSchema} from '@shipfox/api-runners-dto';
|
|
3
3
|
import {defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
5
|
-
import {listActiveProvisioners} from '#core/index.js';
|
|
5
|
+
import {installationRunnersStatus, listActiveProvisioners} from '#core/index.js';
|
|
6
6
|
import {toActiveProvisionerDto} from '#presentation/dto/index.js';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
type InstallationRunnersStatusReader = () => ReturnType<typeof installationRunnersStatus>;
|
|
9
|
+
|
|
10
|
+
export function createListActiveProvisionersRoute(
|
|
11
|
+
readInstallationRunnersStatus: InstallationRunnersStatusReader = installationRunnersStatus,
|
|
12
|
+
) {
|
|
13
|
+
return defineRoute({
|
|
14
|
+
method: 'GET',
|
|
15
|
+
path: '/',
|
|
16
|
+
description: 'List active provisioners for a workspace',
|
|
17
|
+
schema: {
|
|
18
|
+
params: z.object({workspaceId: z.string().uuid()}),
|
|
19
|
+
response: {
|
|
20
|
+
200: listActiveProvisionersResponseSchema,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
handler: async (request) => {
|
|
24
|
+
const {workspaceId} = request.params;
|
|
25
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
26
|
+
|
|
27
|
+
const [provisioners, installationRunners] = await Promise.all([
|
|
28
|
+
listActiveProvisioners(workspaceId),
|
|
29
|
+
readInstallationRunnersStatus().catch((error: unknown) => {
|
|
30
|
+
request.log.warn({err: error}, 'Could not resolve installation runner status');
|
|
31
|
+
return 'none' as const;
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
return {
|
|
35
|
+
provisioners: provisioners.map(toActiveProvisionerDto),
|
|
36
|
+
installation_runners: installationRunners,
|
|
37
|
+
};
|
|
16
38
|
},
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const {workspaceId} = request.params;
|
|
20
|
-
requireWorkspaceAccess({request, workspaceId});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
21
41
|
|
|
22
|
-
|
|
23
|
-
return {provisioners: provisioners.map(toActiveProvisionerDto)};
|
|
24
|
-
},
|
|
25
|
-
});
|
|
42
|
+
export const listActiveProvisionersRoute = createListActiveProvisionersRoute();
|
|
@@ -15,6 +15,7 @@ import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
|
|
|
15
15
|
import {createProvisionerTokenAuthMethod} from '#presentation/auth/index.js';
|
|
16
16
|
import {provisionerTokenFactory} from '#test/index.js';
|
|
17
17
|
import {provisionerRoutes} from './index.js';
|
|
18
|
+
import {createListActiveProvisionersRoute} from './list-active-provisioners.js';
|
|
18
19
|
|
|
19
20
|
const userId = crypto.randomUUID();
|
|
20
21
|
let authenticatedMemberships: ReadonlyArray<UserContextMembership> = [];
|
|
@@ -141,8 +142,51 @@ describe('provisioner token routes', () => {
|
|
|
141
142
|
last_seen_at: expect.any(String),
|
|
142
143
|
}),
|
|
143
144
|
],
|
|
145
|
+
// The test environment configures RUNNER_RESERVED_LABELS, so the
|
|
146
|
+
// installation always reports managed runner capacity.
|
|
147
|
+
installation_runners: 'managed',
|
|
144
148
|
});
|
|
145
149
|
});
|
|
150
|
+
|
|
151
|
+
it('reports managed installation runners from the reserved labels signal alone', async () => {
|
|
152
|
+
const res = await app.inject({
|
|
153
|
+
method: 'GET',
|
|
154
|
+
url: `/workspaces/${workspaceId}/provisioners/active`,
|
|
155
|
+
headers: {authorization: 'Bearer user'},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(res.statusCode).toBe(200);
|
|
159
|
+
expect(res.json().installation_runners).toBe('managed');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('keeps active provisioners available when installation status is unavailable', async () => {
|
|
163
|
+
await closeApp();
|
|
164
|
+
app = await createApp({
|
|
165
|
+
auth: [fakeUserAuth, createProvisionerTokenAuthMethod()],
|
|
166
|
+
routes: [
|
|
167
|
+
{
|
|
168
|
+
prefix: '/workspaces/:workspaceId/provisioners/active',
|
|
169
|
+
auth: AUTH_USER,
|
|
170
|
+
routes: [
|
|
171
|
+
createListActiveProvisionersRoute(() =>
|
|
172
|
+
Promise.reject(new Error('status lookup unavailable')),
|
|
173
|
+
),
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
swagger: false,
|
|
178
|
+
});
|
|
179
|
+
await app.ready();
|
|
180
|
+
|
|
181
|
+
const res = await app.inject({
|
|
182
|
+
method: 'GET',
|
|
183
|
+
url: `/workspaces/${workspaceId}/provisioners/active`,
|
|
184
|
+
headers: {authorization: 'Bearer user'},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(res.statusCode).toBe(200);
|
|
188
|
+
expect(res.json()).toEqual({provisioners: [], installation_runners: 'none'});
|
|
189
|
+
});
|
|
146
190
|
});
|
|
147
191
|
|
|
148
192
|
describe('POST /workspaces/:workspaceId/provisioners/tokens', () => {
|