@shipfox/api-runners 12.1.0 → 12.2.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 +23 -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 +23 -9
- 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/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +15 -5
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-instances.d.ts +1 -0
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +6 -4
- 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 +12 -1
- 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/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 +239 -0
- package/src/core/runner-control-sessions.ts +29 -10
- 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/reservations.test.ts +169 -0
- package/src/db/reservations.ts +42 -8
- package/src/db/runner-instances.test.ts +45 -1
- package/src/db/runner-instances.ts +7 -2
- package/src/db/runner-sessions.ts +14 -1
- 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/presentation/routes/poll-demand.test.ts +99 -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/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import {canonicalizeLabels} from '@shipfox/runner-labels';
|
|
3
|
+
import {runnerReservedLabels} from '#config.js';
|
|
4
|
+
import {RunnerLabelsReservedError} from './errors.js';
|
|
5
|
+
|
|
6
|
+
export type RunnerLabelScope = 'installation' | 'workspace' | 'manual';
|
|
7
|
+
|
|
8
|
+
type SanitizeRunnerLabelsParams = {
|
|
9
|
+
scope: RunnerLabelScope;
|
|
10
|
+
source: string;
|
|
11
|
+
logLevel?: 'debug' | 'warn';
|
|
12
|
+
reservedLabels?: readonly string[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function sanitizeCanonicalRunnerLabels(
|
|
16
|
+
canonicalLabels: readonly string[],
|
|
17
|
+
params: SanitizeRunnerLabelsParams,
|
|
18
|
+
): string[] {
|
|
19
|
+
if (params.scope === 'installation') return [...canonicalLabels];
|
|
20
|
+
|
|
21
|
+
const reservedLabels = new Set(canonicalizeLabels(params.reservedLabels ?? runnerReservedLabels));
|
|
22
|
+
if (reservedLabels.size === 0) return [...canonicalLabels];
|
|
23
|
+
|
|
24
|
+
const removedLabels = canonicalLabels.filter((label) => reservedLabels.has(label));
|
|
25
|
+
if (removedLabels.length === 0) return [...canonicalLabels];
|
|
26
|
+
|
|
27
|
+
const details = {source: params.source, removedLabels};
|
|
28
|
+
if (params.logLevel === 'debug')
|
|
29
|
+
logger().debug(details, 'Removed reserved runner labels from a non-installation source');
|
|
30
|
+
else logger().warn(details, 'Removed reserved runner labels from a non-installation source');
|
|
31
|
+
return canonicalLabels.filter((label) => !reservedLabels.has(label));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sanitizeRunnerLabels(
|
|
35
|
+
labels: readonly string[],
|
|
36
|
+
params: SanitizeRunnerLabelsParams,
|
|
37
|
+
): string[] {
|
|
38
|
+
return sanitizeCanonicalRunnerLabels([...canonicalizeLabels(labels)], params);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sanitizeRunnerLabelsOrThrow(
|
|
42
|
+
labels: readonly string[],
|
|
43
|
+
params: SanitizeRunnerLabelsParams,
|
|
44
|
+
): string[] {
|
|
45
|
+
const canonicalLabels = [...canonicalizeLabels(labels)];
|
|
46
|
+
const sanitizedLabels = sanitizeCanonicalRunnerLabels(canonicalLabels, params);
|
|
47
|
+
if (sanitizedLabels.length === 0 && canonicalLabels.length > 0) {
|
|
48
|
+
throw new RunnerLabelsReservedError(canonicalLabels);
|
|
49
|
+
}
|
|
50
|
+
return sanitizedLabels;
|
|
51
|
+
}
|
|
@@ -9,12 +9,14 @@ import {
|
|
|
9
9
|
getRunnerSessionTokenClaims,
|
|
10
10
|
manualRegistrationTokenFactory,
|
|
11
11
|
providerRunnerFactory,
|
|
12
|
+
provisionerTokenFactory,
|
|
12
13
|
runnersTestAuthClient,
|
|
13
14
|
} from '#test/index.js';
|
|
14
15
|
import {
|
|
15
16
|
EmptyRunnerLabelsError,
|
|
16
17
|
RegistrationTokenConsumedError,
|
|
17
18
|
RegistrationTokenWorkspaceMismatchError,
|
|
19
|
+
RunnerLabelsReservedError,
|
|
18
20
|
} from './errors.js';
|
|
19
21
|
import {issueRunnerActivationToken} from './runner-activation.js';
|
|
20
22
|
import {registerRunnerSession} from './runner-sessions.js';
|
|
@@ -66,6 +68,15 @@ describe('registerRunnerSession', () => {
|
|
|
66
68
|
).rejects.toBeInstanceOf(EmptyRunnerLabelsError);
|
|
67
69
|
});
|
|
68
70
|
|
|
71
|
+
it('names labels removed by the reserved-label policy', () => {
|
|
72
|
+
const error = new RunnerLabelsReservedError(['shipfox-managed']);
|
|
73
|
+
|
|
74
|
+
expect(error.message).toBe(
|
|
75
|
+
'All supplied runner labels are reserved for installation-scope provisioners: shipfox-managed',
|
|
76
|
+
);
|
|
77
|
+
expect(error.labels).toEqual(['shipfox-managed']);
|
|
78
|
+
});
|
|
79
|
+
|
|
69
80
|
it('consumes an ephemeral token and creates a one-claim session', async () => {
|
|
70
81
|
const token = await ephemeralRegistrationTokenFactory.create({workspaceId});
|
|
71
82
|
|
|
@@ -223,7 +234,8 @@ describe('activation runner sessions', () => {
|
|
|
223
234
|
|
|
224
235
|
beforeEach(async () => {
|
|
225
236
|
workspaceId = crypto.randomUUID();
|
|
226
|
-
|
|
237
|
+
const provisioner = await provisionerTokenFactory.create({workspaceId});
|
|
238
|
+
provisionerId = provisioner.id;
|
|
227
239
|
const runner = await providerRunnerFactory.create({workspaceId, provisionerId});
|
|
228
240
|
runnerInstanceId = runner.id;
|
|
229
241
|
});
|
|
@@ -296,4 +308,25 @@ describe('activation runner sessions', () => {
|
|
|
296
308
|
expect(storedToken?.consumedAt).toBeInstanceOf(Date);
|
|
297
309
|
expect(sessions).toHaveLength(1);
|
|
298
310
|
});
|
|
311
|
+
|
|
312
|
+
it('strips reserved labels from workspace activation registration', async () => {
|
|
313
|
+
const rawToken = await issueRunnerActivationToken({
|
|
314
|
+
runnerInstanceId,
|
|
315
|
+
provisionerId,
|
|
316
|
+
ttlSeconds: 60,
|
|
317
|
+
});
|
|
318
|
+
const [activationToken] = await db()
|
|
319
|
+
.select()
|
|
320
|
+
.from(runnerActivationTokens)
|
|
321
|
+
.where(eq(runnerActivationTokens.runnerInstanceId, runnerInstanceId));
|
|
322
|
+
if (!rawToken || !activationToken) throw new Error('Activation token was not created');
|
|
323
|
+
|
|
324
|
+
const result = await registerRunnerSession({
|
|
325
|
+
auth: runnersTestAuthClient,
|
|
326
|
+
credential: {kind: 'activation', activationTokenId: activationToken.id, workspaceId},
|
|
327
|
+
labels: ['linux', 'shipfox-managed'],
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(result.session.labels).toEqual(['linux']);
|
|
331
|
+
});
|
|
299
332
|
});
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from '#db/runner-sessions.js';
|
|
9
9
|
import type {RunnerSession} from './entities/runner-session.js';
|
|
10
10
|
import {EmptyRunnerLabelsError} from './errors.js';
|
|
11
|
+
import {sanitizeRunnerLabelsOrThrow} from './runner-labels.js';
|
|
11
12
|
|
|
12
13
|
export interface RegisterRunnerSessionResult {
|
|
13
14
|
session: RunnerSession;
|
|
@@ -38,7 +39,13 @@ export async function registerRunnerSession(params: {
|
|
|
38
39
|
labels: string[];
|
|
39
40
|
toolCapabilities?: RunnerToolCapabilitiesDto | null;
|
|
40
41
|
}): Promise<RegisterRunnerSessionResult> {
|
|
41
|
-
const labels =
|
|
42
|
+
const labels =
|
|
43
|
+
params.credential.kind === 'manual'
|
|
44
|
+
? sanitizeRunnerLabelsOrThrow(params.labels, {
|
|
45
|
+
scope: 'manual',
|
|
46
|
+
source: 'manual runner registration',
|
|
47
|
+
})
|
|
48
|
+
: [...canonicalizeLabels(params.labels)];
|
|
42
49
|
if (labels.length === 0) throw new EmptyRunnerLabelsError();
|
|
43
50
|
|
|
44
51
|
const mode = params.credential.kind;
|
|
@@ -4,6 +4,7 @@ import type {EphemeralRegistrationToken} from '#core/entities/ephemeral-registra
|
|
|
4
4
|
import {
|
|
5
5
|
ActiveEphemeralRegistrationTokenExistsError,
|
|
6
6
|
ActiveEphemeralRegistrationTokensExistError,
|
|
7
|
+
EmptyRunnerLabelsError,
|
|
7
8
|
RegistrationTokenBatchExceedsReservationError,
|
|
8
9
|
RegistrationTokenConsumedError,
|
|
9
10
|
RegistrationTokenExpiredError,
|
|
@@ -11,11 +12,13 @@ import {
|
|
|
11
12
|
ReservationExpiredError,
|
|
12
13
|
ReservationNotFoundError,
|
|
13
14
|
} from '#core/errors.js';
|
|
15
|
+
import {sanitizeRunnerLabelsOrThrow} from '#core/runner-labels.js';
|
|
14
16
|
import {db} from './db.js';
|
|
15
17
|
import {
|
|
16
18
|
ephemeralRegistrationTokens,
|
|
17
19
|
toEphemeralRegistrationToken,
|
|
18
20
|
} from './schema/ephemeral-registration-tokens.js';
|
|
21
|
+
import {provisionerTokens} from './schema/provisioner-tokens.js';
|
|
19
22
|
import {reservations} from './schema/reservations.js';
|
|
20
23
|
import {providerRunners} from './schema/runner-instances.js';
|
|
21
24
|
import {runnerSessions, toRunnerSession} from './schema/runner-sessions.js';
|
|
@@ -307,6 +310,17 @@ export async function createRunnerSessionConsumingEphemeralToken(params: {
|
|
|
307
310
|
);
|
|
308
311
|
}
|
|
309
312
|
|
|
313
|
+
const [provisioner] = await tx
|
|
314
|
+
.select({scope: provisionerTokens.scope})
|
|
315
|
+
.from(provisionerTokens)
|
|
316
|
+
.where(eq(provisionerTokens.id, consumed[0].provisionerId))
|
|
317
|
+
.limit(1);
|
|
318
|
+
const labels = sanitizeRunnerLabelsOrThrow(params.labels, {
|
|
319
|
+
scope: provisioner?.scope ?? 'workspace',
|
|
320
|
+
source: 'ephemeral runner registration',
|
|
321
|
+
});
|
|
322
|
+
if (labels.length === 0) throw new EmptyRunnerLabelsError();
|
|
323
|
+
|
|
310
324
|
const [session] = await tx
|
|
311
325
|
.insert(runnerSessions)
|
|
312
326
|
.values({
|
|
@@ -316,7 +330,7 @@ export async function createRunnerSessionConsumingEphemeralToken(params: {
|
|
|
316
330
|
registrationTokenKind: 'ephemeral',
|
|
317
331
|
provisionerId: consumed[0].provisionerId,
|
|
318
332
|
providerRunnerId: consumed[0].providerRunnerId,
|
|
319
|
-
labels
|
|
333
|
+
labels,
|
|
320
334
|
toolCapabilities: params.toolCapabilities ?? null,
|
|
321
335
|
toolCapabilitiesReportedAt: params.toolCapabilities ? sql`now()` : null,
|
|
322
336
|
maxClaims: params.maxClaims,
|
|
@@ -461,6 +461,64 @@ describe('pollDemandAndReserve', () => {
|
|
|
461
461
|
expect(storedToken?.revokedAt).toBeInstanceOf(Date);
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
+
it('preserves activation tokens for active runners with an intended reservation', async () => {
|
|
465
|
+
const [reservation] = await db()
|
|
466
|
+
.insert(reservations)
|
|
467
|
+
.values({
|
|
468
|
+
workspaceId,
|
|
469
|
+
provisionerId,
|
|
470
|
+
requiredLabels: ['linux'],
|
|
471
|
+
count: 1,
|
|
472
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
473
|
+
})
|
|
474
|
+
.returning();
|
|
475
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
476
|
+
const [runner] = await db()
|
|
477
|
+
.insert(providerRunners)
|
|
478
|
+
.values({
|
|
479
|
+
workspaceId,
|
|
480
|
+
provisionerId,
|
|
481
|
+
providerRunnerId: crypto.randomUUID(),
|
|
482
|
+
reservationId: reservation.id,
|
|
483
|
+
intendedReservationId: reservation.id,
|
|
484
|
+
runnerSessionId: crypto.randomUUID(),
|
|
485
|
+
labels: ['linux'],
|
|
486
|
+
state: 'running',
|
|
487
|
+
reportedAt: new Date(),
|
|
488
|
+
})
|
|
489
|
+
.returning();
|
|
490
|
+
if (!runner) throw new Error('Expected runner instance');
|
|
491
|
+
const [activationToken] = await db()
|
|
492
|
+
.insert(runnerActivationTokens)
|
|
493
|
+
.values({
|
|
494
|
+
runnerInstanceId: runner.id,
|
|
495
|
+
hashedToken: crypto.randomUUID(),
|
|
496
|
+
prefix: 'test',
|
|
497
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
498
|
+
})
|
|
499
|
+
.returning();
|
|
500
|
+
if (!activationToken) throw new Error('Expected activation token');
|
|
501
|
+
|
|
502
|
+
const deleted = await deleteReservationsByIds([reservation.id]);
|
|
503
|
+
|
|
504
|
+
const [storedRunner] = await db()
|
|
505
|
+
.select()
|
|
506
|
+
.from(providerRunners)
|
|
507
|
+
.where(eq(providerRunners.id, runner.id));
|
|
508
|
+
const [storedToken] = await db()
|
|
509
|
+
.select()
|
|
510
|
+
.from(runnerActivationTokens)
|
|
511
|
+
.where(eq(runnerActivationTokens.id, activationToken.id));
|
|
512
|
+
expect(deleted).toBe(1);
|
|
513
|
+
expect(storedRunner).toMatchObject({
|
|
514
|
+
reservationId: reservation.id,
|
|
515
|
+
intendedReservationId: null,
|
|
516
|
+
runnerSessionId: expect.any(String),
|
|
517
|
+
reservationReleasedAt: null,
|
|
518
|
+
});
|
|
519
|
+
expect(storedToken?.revokedAt).toBeNull();
|
|
520
|
+
});
|
|
521
|
+
|
|
464
522
|
it('unbinds prewarmed runners and revokes activation tokens when deleting expired reservations', async () => {
|
|
465
523
|
const runner = await createIdleRunner({labels: ['linux']});
|
|
466
524
|
await createPendingJobs(1, ['linux']);
|
|
@@ -509,6 +567,115 @@ describe('pollDemandAndReserve', () => {
|
|
|
509
567
|
expect(storedToken?.revokedAt).toBeInstanceOf(Date);
|
|
510
568
|
});
|
|
511
569
|
|
|
570
|
+
it('clears intended reservations and rebinds runners when deleting a reservation by id', async () => {
|
|
571
|
+
const [reservation, survivingReservation] = await db()
|
|
572
|
+
.insert(reservations)
|
|
573
|
+
.values([
|
|
574
|
+
{
|
|
575
|
+
workspaceId,
|
|
576
|
+
provisionerId,
|
|
577
|
+
requiredLabels: ['linux'],
|
|
578
|
+
count: 1,
|
|
579
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
workspaceId: crypto.randomUUID(),
|
|
583
|
+
provisionerId,
|
|
584
|
+
requiredLabels: ['linux'],
|
|
585
|
+
count: 1,
|
|
586
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
587
|
+
},
|
|
588
|
+
])
|
|
589
|
+
.returning();
|
|
590
|
+
if (!reservation || !survivingReservation) throw new Error('Expected reservations');
|
|
591
|
+
const runner = await createIdleRunner({
|
|
592
|
+
labels: ['linux'],
|
|
593
|
+
intendedReservationId: reservation.id,
|
|
594
|
+
});
|
|
595
|
+
const survivingRunner = await createIdleRunner({
|
|
596
|
+
labels: ['linux'],
|
|
597
|
+
intendedReservationId: survivingReservation.id,
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const deleted = await deleteReservationsByIds([reservation.id]);
|
|
601
|
+
|
|
602
|
+
const [storedRunner] = await db()
|
|
603
|
+
.select()
|
|
604
|
+
.from(providerRunners)
|
|
605
|
+
.where(eq(providerRunners.id, runner.id));
|
|
606
|
+
expect(deleted).toBe(1);
|
|
607
|
+
expect(storedRunner).toMatchObject({
|
|
608
|
+
intendedReservationId: null,
|
|
609
|
+
workspaceId: null,
|
|
610
|
+
reservationId: null,
|
|
611
|
+
assignedAt: null,
|
|
612
|
+
state: 'running',
|
|
613
|
+
reservationReleasedAt: null,
|
|
614
|
+
});
|
|
615
|
+
const [storedSurvivingRunner] = await db()
|
|
616
|
+
.select()
|
|
617
|
+
.from(providerRunners)
|
|
618
|
+
.where(eq(providerRunners.id, survivingRunner.id));
|
|
619
|
+
expect(storedSurvivingRunner?.intendedReservationId).toBe(survivingReservation.id);
|
|
620
|
+
|
|
621
|
+
await createPendingJobs(1, ['linux']);
|
|
622
|
+
const result = await pollDemandAndReserve({
|
|
623
|
+
workspaceId,
|
|
624
|
+
provisionerId,
|
|
625
|
+
maxReservations: 1,
|
|
626
|
+
ttlSeconds: 60,
|
|
627
|
+
templates: [template('linux', ['linux'], 1)],
|
|
628
|
+
});
|
|
629
|
+
const reboundReservationId = result.reservations[0]?.reservationId;
|
|
630
|
+
const [reboundRunner] = await db()
|
|
631
|
+
.select()
|
|
632
|
+
.from(providerRunners)
|
|
633
|
+
.where(eq(providerRunners.id, runner.id));
|
|
634
|
+
expect(reboundReservationId).toEqual(expect.any(String));
|
|
635
|
+
expect(reboundRunner).toMatchObject({
|
|
636
|
+
intendedReservationId: null,
|
|
637
|
+
workspaceId,
|
|
638
|
+
reservationId: reboundReservationId,
|
|
639
|
+
assignedAt: expect.any(Date),
|
|
640
|
+
state: 'running',
|
|
641
|
+
reservationReleasedAt: null,
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('clears intended reservations when deleting expired reservations', async () => {
|
|
646
|
+
const [reservation] = await db()
|
|
647
|
+
.insert(reservations)
|
|
648
|
+
.values({
|
|
649
|
+
workspaceId,
|
|
650
|
+
provisionerId,
|
|
651
|
+
requiredLabels: ['linux'],
|
|
652
|
+
count: 1,
|
|
653
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
654
|
+
})
|
|
655
|
+
.returning();
|
|
656
|
+
if (!reservation) throw new Error('Expected reservation');
|
|
657
|
+
const runner = await createIdleRunner({
|
|
658
|
+
labels: ['linux'],
|
|
659
|
+
intendedReservationId: reservation.id,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const deleted = await deleteExpiredReservations();
|
|
663
|
+
|
|
664
|
+
const [storedRunner] = await db()
|
|
665
|
+
.select()
|
|
666
|
+
.from(providerRunners)
|
|
667
|
+
.where(eq(providerRunners.id, runner.id));
|
|
668
|
+
expect(deleted).toBeGreaterThanOrEqual(1);
|
|
669
|
+
expect(storedRunner).toMatchObject({
|
|
670
|
+
intendedReservationId: null,
|
|
671
|
+
workspaceId: null,
|
|
672
|
+
reservationId: null,
|
|
673
|
+
assignedAt: null,
|
|
674
|
+
state: 'running',
|
|
675
|
+
reservationReleasedAt: null,
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
512
679
|
it('decrements reservation units inside a caller transaction', async () => {
|
|
513
680
|
await reservationFactory.create({
|
|
514
681
|
workspaceId,
|
|
@@ -645,6 +812,7 @@ describe('pollDemandAndReserve', () => {
|
|
|
645
812
|
labels: string[];
|
|
646
813
|
createdAt?: Date;
|
|
647
814
|
controlSessionExpiresAt?: Date;
|
|
815
|
+
intendedReservationId?: string;
|
|
648
816
|
}) {
|
|
649
817
|
const createdAt = params.createdAt ?? new Date();
|
|
650
818
|
const [runner] = await db()
|
|
@@ -652,6 +820,7 @@ describe('pollDemandAndReserve', () => {
|
|
|
652
820
|
.values({
|
|
653
821
|
provisionerId,
|
|
654
822
|
providerRunnerId: crypto.randomUUID(),
|
|
823
|
+
intendedReservationId: params.intendedReservationId,
|
|
655
824
|
labels: params.labels,
|
|
656
825
|
state: 'running',
|
|
657
826
|
reportedAt: createdAt,
|
package/src/db/reservations.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
isNotNull,
|
|
11
11
|
isNull,
|
|
12
12
|
lt,
|
|
13
|
+
or,
|
|
13
14
|
sql,
|
|
14
15
|
} from 'drizzle-orm';
|
|
15
16
|
import type {Tx} from './db.js';
|
|
@@ -432,16 +433,26 @@ async function deleteReservationsWithCleanupTx(
|
|
|
432
433
|
): Promise<number> {
|
|
433
434
|
if (ids.length === 0) return 0;
|
|
434
435
|
|
|
435
|
-
const
|
|
436
|
-
.select({
|
|
436
|
+
const affectedRunners = await tx
|
|
437
|
+
.select({
|
|
438
|
+
id: providerRunners.id,
|
|
439
|
+
reservationId: providerRunners.reservationId,
|
|
440
|
+
intendedReservationId: providerRunners.intendedReservationId,
|
|
441
|
+
runnerSessionId: providerRunners.runnerSessionId,
|
|
442
|
+
reservationReleasedAt: providerRunners.reservationReleasedAt,
|
|
443
|
+
})
|
|
437
444
|
.from(providerRunners)
|
|
438
445
|
.where(
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
446
|
+
or(
|
|
447
|
+
and(
|
|
448
|
+
inArray(providerRunners.reservationId, ids),
|
|
449
|
+
isNull(providerRunners.runnerSessionId),
|
|
450
|
+
isNull(providerRunners.reservationReleasedAt),
|
|
451
|
+
),
|
|
452
|
+
inArray(providerRunners.intendedReservationId, ids),
|
|
443
453
|
),
|
|
444
454
|
)
|
|
455
|
+
.orderBy(asc(providerRunners.id))
|
|
445
456
|
.for('update');
|
|
446
457
|
const reservationRows = await tx
|
|
447
458
|
.select({id: reservations.id})
|
|
@@ -457,8 +468,20 @@ async function deleteReservationsWithCleanupTx(
|
|
|
457
468
|
|
|
458
469
|
if (reservationIds.length === 0) return 0;
|
|
459
470
|
|
|
460
|
-
const assignedRunnerIds =
|
|
461
|
-
.filter(
|
|
471
|
+
const assignedRunnerIds = affectedRunners
|
|
472
|
+
.filter(
|
|
473
|
+
(runner) =>
|
|
474
|
+
runner.reservationId &&
|
|
475
|
+
reservationIds.includes(runner.reservationId) &&
|
|
476
|
+
!runner.runnerSessionId &&
|
|
477
|
+
!runner.reservationReleasedAt,
|
|
478
|
+
)
|
|
479
|
+
.map((runner) => runner.id);
|
|
480
|
+
const intendedRunnerIds = affectedRunners
|
|
481
|
+
.filter(
|
|
482
|
+
(runner) =>
|
|
483
|
+
runner.intendedReservationId && reservationIds.includes(runner.intendedReservationId),
|
|
484
|
+
)
|
|
462
485
|
.map((runner) => runner.id);
|
|
463
486
|
if (assignedRunnerIds.length > 0) {
|
|
464
487
|
await tx
|
|
@@ -487,6 +510,17 @@ async function deleteReservationsWithCleanupTx(
|
|
|
487
510
|
),
|
|
488
511
|
);
|
|
489
512
|
}
|
|
513
|
+
if (intendedRunnerIds.length > 0) {
|
|
514
|
+
await tx
|
|
515
|
+
.update(providerRunners)
|
|
516
|
+
.set({intendedReservationId: null, updatedAt: sql`now()`})
|
|
517
|
+
.where(
|
|
518
|
+
and(
|
|
519
|
+
inArray(providerRunners.id, intendedRunnerIds),
|
|
520
|
+
inArray(providerRunners.intendedReservationId, reservationIds),
|
|
521
|
+
),
|
|
522
|
+
);
|
|
523
|
+
}
|
|
490
524
|
|
|
491
525
|
const deleted = await tx
|
|
492
526
|
.delete(reservations)
|