@shipfox/api-runners 10.0.0 → 10.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 +28 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -13
- package/dist/config.js.map +1 -1
- package/dist/core/admin-runner-instances.d.ts +34 -0
- package/dist/core/admin-runner-instances.d.ts.map +1 -0
- package/dist/core/admin-runner-instances.js +58 -0
- package/dist/core/admin-runner-instances.js.map +1 -0
- package/dist/core/errors.d.ts +6 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +12 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/index.d.ts +3 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +3 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/provisioner-tokens.d.ts +33 -0
- package/dist/core/provisioner-tokens.d.ts.map +1 -1
- package/dist/core/provisioner-tokens.js +107 -2
- package/dist/core/provisioner-tokens.js.map +1 -1
- package/dist/db/admin-runner-instances.d.ts +34 -0
- package/dist/db/admin-runner-instances.d.ts.map +1 -0
- package/dist/db/admin-runner-instances.js +62 -0
- package/dist/db/admin-runner-instances.js.map +1 -0
- package/dist/db/db.d.ts +290 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +2 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/index.d.ts +3 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +2 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/job-executions.d.ts.map +1 -1
- package/dist/db/job-executions.js +11 -0
- package/dist/db/job-executions.js.map +1 -1
- package/dist/db/provisioner-tokens.d.ts +38 -0
- package/dist/db/provisioner-tokens.d.ts.map +1 -1
- package/dist/db/provisioner-tokens.js +157 -3
- package/dist/db/provisioner-tokens.js.map +1 -1
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +60 -8
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +134 -0
- package/dist/db/schema/admin-command-results.d.ts.map +1 -0
- package/dist/db/schema/admin-command-results.js +18 -0
- package/dist/db/schema/admin-command-results.js.map +1 -0
- package/dist/db/schema/runner-instances.d.ts +17 -0
- package/dist/db/schema/runner-instances.d.ts.map +1 -1
- package/dist/db/schema/runner-instances.js +3 -0
- package/dist/db/schema/runner-instances.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/admin-provisioner-token.d.ts +16 -0
- package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -0
- package/dist/presentation/dto/admin-provisioner-token.js +22 -0
- package/dist/presentation/dto/admin-provisioner-token.js.map +1 -0
- package/dist/presentation/dto/admin-runner-instances.d.ts +23 -0
- package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -0
- package/dist/presentation/dto/admin-runner-instances.js +19 -0
- package/dist/presentation/dto/admin-runner-instances.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -1
- package/dist/presentation/dto/index.js +2 -0
- package/dist/presentation/dto/index.js.map +1 -1
- package/dist/presentation/routes/admin-provisioner-tokens.d.ts +4 -0
- package/dist/presentation/routes/admin-provisioner-tokens.d.ts.map +1 -0
- package/dist/presentation/routes/admin-provisioner-tokens.js +167 -0
- package/dist/presentation/routes/admin-provisioner-tokens.js.map +1 -0
- package/dist/presentation/routes/admin-runner-instances.d.ts +3 -0
- package/dist/presentation/routes/admin-runner-instances.d.ts.map +1 -0
- package/dist/presentation/routes/admin-runner-instances.js +63 -0
- package/dist/presentation/routes/admin-runner-instances.js.map +1 -0
- package/dist/presentation/routes/index.d.ts.map +1 -1
- package/dist/presentation/routes/index.js +11 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/runner-enrollment.d.ts +1 -0
- package/dist/presentation/routes/runner-enrollment.d.ts.map +1 -1
- package/dist/presentation/routes/runner-enrollment.js +11 -4
- package/dist/presentation/routes/runner-enrollment.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0002_special_shadowcat.sql +1 -0
- package/drizzle/0003_flaky_sally_floyd.sql +11 -0
- package/drizzle/meta/0002_snapshot.json +2099 -0
- package/drizzle/meta/0003_snapshot.json +2178 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +10 -9
- package/src/config.test.ts +11 -0
- package/src/config.ts +19 -8
- package/src/core/admin-runner-instances.ts +125 -0
- package/src/core/errors.ts +14 -0
- package/src/core/index.ts +12 -0
- package/src/core/provisioner-tokens.ts +157 -1
- package/src/db/admin-runner-instances.ts +121 -0
- package/src/db/db.ts +2 -0
- package/src/db/index.ts +10 -0
- package/src/db/job-executions.test.ts +17 -0
- package/src/db/job-executions.ts +22 -0
- package/src/db/provisioner-tokens.ts +263 -9
- package/src/db/reservations.test.ts +253 -0
- package/src/db/reservations.ts +170 -15
- package/src/db/schema/admin-command-results.ts +29 -0
- package/src/db/schema/runner-instances.ts +1 -0
- package/src/index.test.ts +1 -1
- package/src/index.ts +6 -1
- package/src/presentation/dto/admin-provisioner-token.ts +23 -0
- package/src/presentation/dto/admin-runner-instances.ts +19 -0
- package/src/presentation/dto/index.ts +2 -0
- package/src/presentation/routes/admin-provisioner-tokens.test.ts +227 -0
- package/src/presentation/routes/admin-provisioner-tokens.ts +177 -0
- package/src/presentation/routes/admin-runner-instances.test.ts +671 -0
- package/src/presentation/routes/admin-runner-instances.ts +67 -0
- package/src/presentation/routes/index.ts +8 -0
- package/src/presentation/routes/runner-enrollment.test.ts +60 -2
- package/src/presentation/routes/runner-enrollment.ts +24 -4
- package/test/globalSetup.ts +1 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from './job-executions.js';
|
|
28
28
|
import {runnersOutbox} from './schema/outbox.js';
|
|
29
29
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
30
|
+
import {providerRunners} from './schema/runner-instances.js';
|
|
30
31
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
31
32
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
32
33
|
|
|
@@ -408,10 +409,20 @@ describe('claimPendingJobExecution', () => {
|
|
|
408
409
|
it('does not spend a claim when a capped session polls an empty queue', async () => {
|
|
409
410
|
const provisionerId = crypto.randomUUID();
|
|
410
411
|
const providerRunnerId = `provisioned-runner-${crypto.randomUUID()}`;
|
|
412
|
+
const runnerInstanceId = crypto.randomUUID();
|
|
411
413
|
await db()
|
|
412
414
|
.update(runnerSessions)
|
|
413
415
|
.set({registrationTokenKind: 'ephemeral', maxClaims: 1, provisionerId, providerRunnerId})
|
|
414
416
|
.where(eq(runnerSessions.id, runnerSessionId));
|
|
417
|
+
await db().insert(providerRunners).values({
|
|
418
|
+
id: runnerInstanceId,
|
|
419
|
+
workspaceId,
|
|
420
|
+
provisionerId,
|
|
421
|
+
providerRunnerId,
|
|
422
|
+
labels: sessionLabels,
|
|
423
|
+
state: 'running',
|
|
424
|
+
reportedAt: new Date(),
|
|
425
|
+
});
|
|
415
426
|
|
|
416
427
|
const empty = await claimPendingJobExecution({workspaceId, runnerSessionId, maxClaims: 1});
|
|
417
428
|
|
|
@@ -431,6 +442,12 @@ describe('claimPendingJobExecution', () => {
|
|
|
431
442
|
.where(eq(runnerSessions.id, runnerSessionId));
|
|
432
443
|
expect(claimed?.jobId).toBe(created.jobId);
|
|
433
444
|
expect(afterClaim?.claimsUsed).toBe(1);
|
|
445
|
+
|
|
446
|
+
const [runner] = await db()
|
|
447
|
+
.select({firstClaimedAt: providerRunners.firstClaimedAt})
|
|
448
|
+
.from(providerRunners)
|
|
449
|
+
.where(eq(providerRunners.id, runnerInstanceId));
|
|
450
|
+
expect(runner?.firstClaimedAt).toBeInstanceOf(Date);
|
|
434
451
|
});
|
|
435
452
|
|
|
436
453
|
it('allows a manual session to claim repeatedly', async () => {
|
package/src/db/job-executions.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type {Tx} from './db.js';
|
|
|
34
34
|
import {db} from './db.js';
|
|
35
35
|
import {runnersOutbox} from './schema/outbox.js';
|
|
36
36
|
import {pendingJobExecutions} from './schema/pending-job-executions.js';
|
|
37
|
+
import {providerRunners} from './schema/runner-instances.js';
|
|
37
38
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
38
39
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
39
40
|
|
|
@@ -174,6 +175,7 @@ export async function claimPendingJobExecution(params: {
|
|
|
174
175
|
return await db().transaction(async (tx) => {
|
|
175
176
|
let provisionerId: string | null = null;
|
|
176
177
|
let providerRunnerId: string | null = null;
|
|
178
|
+
let runnerInstanceId: string | null = null;
|
|
177
179
|
|
|
178
180
|
if (params.maxClaims !== null) {
|
|
179
181
|
const [session] = await tx
|
|
@@ -181,6 +183,7 @@ export async function claimPendingJobExecution(params: {
|
|
|
181
183
|
maxClaims: runnerSessions.maxClaims,
|
|
182
184
|
claimsUsed: runnerSessions.claimsUsed,
|
|
183
185
|
revokedAt: runnerSessions.revokedAt,
|
|
186
|
+
runnerInstanceId: runnerSessions.runnerInstanceId,
|
|
184
187
|
provisionerId: runnerSessions.provisionerId,
|
|
185
188
|
providerRunnerId: runnerSessions.providerRunnerId,
|
|
186
189
|
})
|
|
@@ -200,6 +203,7 @@ export async function claimPendingJobExecution(params: {
|
|
|
200
203
|
|
|
201
204
|
// Ephemeral sessions are the only capped sessions, and the DB check keeps
|
|
202
205
|
// their provisioned-runner link present as a pair.
|
|
206
|
+
runnerInstanceId = session.runnerInstanceId;
|
|
203
207
|
provisionerId = session.provisionerId;
|
|
204
208
|
providerRunnerId = session.providerRunnerId;
|
|
205
209
|
}
|
|
@@ -250,6 +254,24 @@ export async function claimPendingJobExecution(params: {
|
|
|
250
254
|
const claimed = inserted[0];
|
|
251
255
|
if (!claimed) return null;
|
|
252
256
|
|
|
257
|
+
const runnerInstanceCondition = runnerInstanceId
|
|
258
|
+
? eq(providerRunners.id, runnerInstanceId)
|
|
259
|
+
: provisionerId && providerRunnerId
|
|
260
|
+
? and(
|
|
261
|
+
eq(providerRunners.provisionerId, provisionerId),
|
|
262
|
+
eq(providerRunners.providerRunnerId, providerRunnerId),
|
|
263
|
+
)
|
|
264
|
+
: null;
|
|
265
|
+
if (runnerInstanceCondition) {
|
|
266
|
+
await tx
|
|
267
|
+
.update(providerRunners)
|
|
268
|
+
.set({
|
|
269
|
+
firstClaimedAt: sql`coalesce(${providerRunners.firstClaimedAt}, ${claimed.claimedAt})`,
|
|
270
|
+
updatedAt: sql`now()`,
|
|
271
|
+
})
|
|
272
|
+
.where(runnerInstanceCondition);
|
|
273
|
+
}
|
|
274
|
+
|
|
253
275
|
if (params.maxClaims !== null) {
|
|
254
276
|
await tx
|
|
255
277
|
.update(runnerSessions)
|
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type {
|
|
2
|
+
AdministrationActionEvent,
|
|
3
|
+
AdministrationActionEventMap,
|
|
4
|
+
} from '@shipfox/api-common-dto';
|
|
5
|
+
import {type TimestampIdCursor, timestampIdCursorWhere} from '@shipfox/node-drizzle';
|
|
6
|
+
import {writeOutboxEvent} from '@shipfox/node-outbox';
|
|
7
|
+
import {and, desc, eq, gt, inArray, isNotNull, isNull, lte, or, type SQL, sql} from 'drizzle-orm';
|
|
2
8
|
import type {
|
|
3
9
|
ActiveProvisionerToken,
|
|
4
10
|
ProvisionerScope,
|
|
5
11
|
ProvisionerToken,
|
|
6
12
|
} from '#core/entities/provisioner-token.js';
|
|
13
|
+
import {ProvisionerAdminIdempotencyKeyReuseError} from '#core/errors.js';
|
|
7
14
|
import {db} from './db.js';
|
|
15
|
+
import {runnersAdminCommandResults} from './schema/admin-command-results.js';
|
|
16
|
+
import {runnersOutbox} from './schema/outbox.js';
|
|
8
17
|
import {provisionerTokens, toProvisionerToken} from './schema/provisioner-tokens.js';
|
|
9
18
|
import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
|
|
10
19
|
import {runnerBootstrapTokens, runnerControlSessions} from './schema/runner-control-sessions.js';
|
|
11
20
|
import {providerRunners} from './schema/runner-instances.js';
|
|
12
21
|
import {runnerSessions} from './schema/runner-sessions.js';
|
|
13
22
|
|
|
23
|
+
type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
24
|
+
|
|
14
25
|
export interface CreateProvisionerTokenParams {
|
|
15
26
|
scope: ProvisionerScope;
|
|
16
27
|
workspaceId?: string | undefined;
|
|
@@ -21,10 +32,7 @@ export interface CreateProvisionerTokenParams {
|
|
|
21
32
|
expiresAt?: Date | undefined;
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
async function cascadeProvisionerRevocation(
|
|
25
|
-
tx: Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0],
|
|
26
|
-
provisionerId: string,
|
|
27
|
-
) {
|
|
35
|
+
async function cascadeProvisionerRevocation(tx: Tx, provisionerId: string) {
|
|
28
36
|
await tx
|
|
29
37
|
.update(runnerBootstrapTokens)
|
|
30
38
|
.set({revokedAt: sql`now()`})
|
|
@@ -75,10 +83,8 @@ async function cascadeProvisionerRevocation(
|
|
|
75
83
|
);
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
): Promise<ProvisionerToken> {
|
|
81
|
-
const rows = await db()
|
|
86
|
+
async function insertProvisionerToken(tx: Tx, params: CreateProvisionerTokenParams) {
|
|
87
|
+
const rows = await tx
|
|
82
88
|
.insert(provisionerTokens)
|
|
83
89
|
.values({
|
|
84
90
|
scope: params.scope,
|
|
@@ -93,9 +99,135 @@ export async function createProvisionerToken(
|
|
|
93
99
|
|
|
94
100
|
const row = rows[0];
|
|
95
101
|
if (!row) throw new Error('Insert returned no rows');
|
|
102
|
+
return row;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function createProvisionerToken(
|
|
106
|
+
params: CreateProvisionerTokenParams,
|
|
107
|
+
): Promise<ProvisionerToken> {
|
|
108
|
+
const row = await db().transaction((tx) => insertProvisionerToken(tx, params));
|
|
96
109
|
return toProvisionerToken(row);
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
interface AuditedProvisionerTokenCommandParams {
|
|
113
|
+
actorId: string;
|
|
114
|
+
command: string;
|
|
115
|
+
correlationId: string;
|
|
116
|
+
idempotencyKeyFingerprint: string;
|
|
117
|
+
requestFingerprint: string;
|
|
118
|
+
event: (tokenId: string) => AdministrationActionEvent;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function lockAdminCommand(
|
|
122
|
+
tx: Tx,
|
|
123
|
+
params: Pick<AuditedProvisionerTokenCommandParams, 'actorId' | 'idempotencyKeyFingerprint'>,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
await tx.execute(
|
|
126
|
+
sql`select pg_advisory_xact_lock(hashtext(${`runners_admin_command:${params.actorId}:${params.idempotencyKeyFingerprint}`}))`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadAdminCommandResult(
|
|
131
|
+
tx: Tx,
|
|
132
|
+
params: Pick<
|
|
133
|
+
AuditedProvisionerTokenCommandParams,
|
|
134
|
+
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
135
|
+
> & {command: string},
|
|
136
|
+
) {
|
|
137
|
+
const rows = await tx
|
|
138
|
+
.select()
|
|
139
|
+
.from(runnersAdminCommandResults)
|
|
140
|
+
.where(
|
|
141
|
+
and(
|
|
142
|
+
eq(runnersAdminCommandResults.actorId, params.actorId),
|
|
143
|
+
eq(runnersAdminCommandResults.idempotencyKeyFingerprint, params.idempotencyKeyFingerprint),
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
.limit(1);
|
|
147
|
+
const result = rows[0];
|
|
148
|
+
if (!result) return undefined;
|
|
149
|
+
if (
|
|
150
|
+
result.command !== params.command ||
|
|
151
|
+
result.requestFingerprint !== params.requestFingerprint
|
|
152
|
+
) {
|
|
153
|
+
throw new ProvisionerAdminIdempotencyKeyReuseError();
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function storeAdminCommandResult(
|
|
159
|
+
tx: Tx,
|
|
160
|
+
params: AuditedProvisionerTokenCommandParams,
|
|
161
|
+
result: {provisionerTokenId: string; correlationId: string},
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
await tx.insert(runnersAdminCommandResults).values({
|
|
164
|
+
actorId: params.actorId,
|
|
165
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
166
|
+
command: params.command,
|
|
167
|
+
requestFingerprint: params.requestFingerprint,
|
|
168
|
+
result,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function writeAdministrationAction(tx: Tx, event: AdministrationActionEvent): Promise<void> {
|
|
173
|
+
await writeOutboxEvent<AdministrationActionEventMap>(tx, runnersOutbox, {
|
|
174
|
+
type: 'administration.action.performed',
|
|
175
|
+
payload: event,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function createInstallationProvisionerTokenWithAudit(
|
|
180
|
+
params: AuditedProvisionerTokenCommandParams & {
|
|
181
|
+
name?: string | undefined;
|
|
182
|
+
ttlSeconds?: number | undefined;
|
|
183
|
+
hashedToken: string;
|
|
184
|
+
prefix: string;
|
|
185
|
+
},
|
|
186
|
+
): Promise<{token: ProvisionerToken; correlationId: string; replayed: boolean}> {
|
|
187
|
+
const command = 'runners.provisioner_token.create';
|
|
188
|
+
return await db().transaction(async (tx) => {
|
|
189
|
+
await lockAdminCommand(tx, params);
|
|
190
|
+
const existing = await loadAdminCommandResult(tx, {
|
|
191
|
+
actorId: params.actorId,
|
|
192
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
193
|
+
requestFingerprint: params.requestFingerprint,
|
|
194
|
+
command,
|
|
195
|
+
});
|
|
196
|
+
if (existing) {
|
|
197
|
+
const rows = await tx
|
|
198
|
+
.select()
|
|
199
|
+
.from(provisionerTokens)
|
|
200
|
+
.where(eq(provisionerTokens.id, existing.result.provisionerTokenId))
|
|
201
|
+
.limit(1);
|
|
202
|
+
const row = rows[0];
|
|
203
|
+
if (!row) throw new Error('Idempotent provisioner token result is missing');
|
|
204
|
+
return {
|
|
205
|
+
token: toProvisionerToken(row),
|
|
206
|
+
correlationId: existing.result.correlationId,
|
|
207
|
+
replayed: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const expiresAt =
|
|
212
|
+
params.ttlSeconds === undefined ? undefined : new Date(Date.now() + params.ttlSeconds * 1000);
|
|
213
|
+
const row = await insertProvisionerToken(tx, {
|
|
214
|
+
scope: 'installation',
|
|
215
|
+
hashedToken: params.hashedToken,
|
|
216
|
+
prefix: params.prefix,
|
|
217
|
+
createdByUserId: params.actorId,
|
|
218
|
+
name: params.name,
|
|
219
|
+
expiresAt,
|
|
220
|
+
});
|
|
221
|
+
const event = params.event(row.id);
|
|
222
|
+
await writeAdministrationAction(tx, event);
|
|
223
|
+
await storeAdminCommandResult(tx, params, {
|
|
224
|
+
provisionerTokenId: row.id,
|
|
225
|
+
correlationId: event.correlationId,
|
|
226
|
+
});
|
|
227
|
+
return {token: toProvisionerToken(row), correlationId: event.correlationId, replayed: false};
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
99
231
|
export async function revokeInstallationProvisionerToken(params: {
|
|
100
232
|
tokenId: string;
|
|
101
233
|
revokedByUserId: string;
|
|
@@ -132,6 +264,128 @@ export async function revokeInstallationProvisionerToken(params: {
|
|
|
132
264
|
return existingRow ? toProvisionerToken(existingRow) : undefined;
|
|
133
265
|
}
|
|
134
266
|
|
|
267
|
+
export async function revokeInstallationProvisionerTokenWithAudit(
|
|
268
|
+
params: AuditedProvisionerTokenCommandParams & {tokenId: string},
|
|
269
|
+
): Promise<{token: ProvisionerToken; correlationId: string; replayed: boolean} | undefined> {
|
|
270
|
+
const command = 'runners.provisioner_token.revoke';
|
|
271
|
+
return await db().transaction(async (tx) => {
|
|
272
|
+
await lockAdminCommand(tx, params);
|
|
273
|
+
const existing = await loadAdminCommandResult(tx, {
|
|
274
|
+
actorId: params.actorId,
|
|
275
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
276
|
+
requestFingerprint: params.requestFingerprint,
|
|
277
|
+
command,
|
|
278
|
+
});
|
|
279
|
+
if (existing) {
|
|
280
|
+
const rows = await tx
|
|
281
|
+
.select()
|
|
282
|
+
.from(provisionerTokens)
|
|
283
|
+
.where(eq(provisionerTokens.id, existing.result.provisionerTokenId))
|
|
284
|
+
.limit(1);
|
|
285
|
+
const row = rows[0];
|
|
286
|
+
if (!row) throw new Error('Idempotent provisioner token result is missing');
|
|
287
|
+
return {
|
|
288
|
+
token: toProvisionerToken(row),
|
|
289
|
+
correlationId: existing.result.correlationId,
|
|
290
|
+
replayed: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const updatedRows = await tx
|
|
295
|
+
.update(provisionerTokens)
|
|
296
|
+
.set({revokedAt: new Date(), revokedByUserId: params.actorId, updatedAt: new Date()})
|
|
297
|
+
.where(
|
|
298
|
+
and(
|
|
299
|
+
eq(provisionerTokens.id, params.tokenId),
|
|
300
|
+
eq(provisionerTokens.scope, 'installation'),
|
|
301
|
+
isNull(provisionerTokens.revokedAt),
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
.returning();
|
|
305
|
+
const row = updatedRows[0];
|
|
306
|
+
if (row) await cascadeProvisionerRevocation(tx, params.tokenId);
|
|
307
|
+
|
|
308
|
+
const existingRows = row
|
|
309
|
+
? [row]
|
|
310
|
+
: await tx
|
|
311
|
+
.select()
|
|
312
|
+
.from(provisionerTokens)
|
|
313
|
+
.where(
|
|
314
|
+
and(
|
|
315
|
+
eq(provisionerTokens.id, params.tokenId),
|
|
316
|
+
eq(provisionerTokens.scope, 'installation'),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
.limit(1);
|
|
320
|
+
const tokenRow = existingRows[0];
|
|
321
|
+
if (!tokenRow) return undefined;
|
|
322
|
+
|
|
323
|
+
const changed = row !== undefined;
|
|
324
|
+
const event = changed ? params.event(tokenRow.id) : undefined;
|
|
325
|
+
if (event) await writeAdministrationAction(tx, event);
|
|
326
|
+
await storeAdminCommandResult(tx, params, {
|
|
327
|
+
provisionerTokenId: tokenRow.id,
|
|
328
|
+
correlationId: event?.correlationId ?? params.correlationId,
|
|
329
|
+
});
|
|
330
|
+
return {
|
|
331
|
+
token: toProvisionerToken(tokenRow),
|
|
332
|
+
correlationId: event?.correlationId ?? params.correlationId,
|
|
333
|
+
replayed: false,
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export type InstallationProvisionerTokenStatus = 'active' | 'expired' | 'revoked';
|
|
339
|
+
|
|
340
|
+
export interface ListInstallationProvisionerTokensParams {
|
|
341
|
+
limit: number;
|
|
342
|
+
cursor?: TimestampIdCursor | undefined;
|
|
343
|
+
status?: InstallationProvisionerTokenStatus | undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function listInstallationProvisionerTokens(
|
|
347
|
+
params: ListInstallationProvisionerTokensParams,
|
|
348
|
+
): Promise<{tokens: ProvisionerToken[]; nextCursor: TimestampIdCursor | null}> {
|
|
349
|
+
const conditions: SQL[] = [eq(provisionerTokens.scope, 'installation')];
|
|
350
|
+
const cursor = timestampIdCursorWhere({
|
|
351
|
+
timestampColumn: provisionerTokens.createdAt,
|
|
352
|
+
idColumn: provisionerTokens.id,
|
|
353
|
+
cursor: params.cursor,
|
|
354
|
+
});
|
|
355
|
+
if (cursor) conditions.push(cursor);
|
|
356
|
+
|
|
357
|
+
if (params.status === 'active') {
|
|
358
|
+
conditions.push(isNull(provisionerTokens.revokedAt));
|
|
359
|
+
const activeExpiry = or(
|
|
360
|
+
isNull(provisionerTokens.expiresAt),
|
|
361
|
+
gt(provisionerTokens.expiresAt, sql`now()`),
|
|
362
|
+
);
|
|
363
|
+
if (activeExpiry) conditions.push(activeExpiry);
|
|
364
|
+
}
|
|
365
|
+
if (params.status === 'expired') {
|
|
366
|
+
conditions.push(
|
|
367
|
+
isNull(provisionerTokens.revokedAt),
|
|
368
|
+
isNotNull(provisionerTokens.expiresAt),
|
|
369
|
+
lte(provisionerTokens.expiresAt, sql`now()`),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (params.status === 'revoked') conditions.push(isNotNull(provisionerTokens.revokedAt));
|
|
373
|
+
|
|
374
|
+
const rows = await db()
|
|
375
|
+
.select()
|
|
376
|
+
.from(provisionerTokens)
|
|
377
|
+
.where(and(...conditions))
|
|
378
|
+
.orderBy(desc(provisionerTokens.createdAt), desc(provisionerTokens.id))
|
|
379
|
+
.limit(params.limit + 1);
|
|
380
|
+
const pageRows = rows.length > params.limit ? rows.slice(0, params.limit) : rows;
|
|
381
|
+
const last = pageRows.at(-1);
|
|
382
|
+
return {
|
|
383
|
+
tokens: pageRows.map(toProvisionerToken),
|
|
384
|
+
nextCursor:
|
|
385
|
+
rows.length > params.limit && last ? {createdAt: last.createdAt, id: last.id} : null,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
135
389
|
export async function listUsableProvisionerTokensByWorkspaceId(
|
|
136
390
|
workspaceId: string,
|
|
137
391
|
): Promise<ProvisionerToken[]> {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import {and, eq, inArray, or, sql, sum} from 'drizzle-orm';
|
|
2
2
|
import {db} from '#db/db.js';
|
|
3
3
|
import {
|
|
4
|
+
deleteExpiredReservations,
|
|
4
5
|
deleteReservationsByIds,
|
|
5
6
|
pollDemandAndReserve,
|
|
6
7
|
pollInstallationDemandAndReserve,
|
|
7
8
|
releaseReservationUnits,
|
|
8
9
|
} from '#db/reservations.js';
|
|
9
10
|
import {reservations} from '#db/schema/reservations.js';
|
|
11
|
+
import {runnerActivationTokens} from '#db/schema/runner-activation-tokens.js';
|
|
12
|
+
import {runnerControlSessions} from '#db/schema/runner-control-sessions.js';
|
|
13
|
+
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
10
14
|
import {pendingJobFactory, reservationFactory} from '#test/index.js';
|
|
11
15
|
|
|
12
16
|
describe('pollDemandAndReserve', () => {
|
|
@@ -34,6 +38,131 @@ describe('pollDemandAndReserve', () => {
|
|
|
34
38
|
expect(result.stats[0]).toMatchObject({labels: ['linux'], queued: 50, reserved: 20});
|
|
35
39
|
});
|
|
36
40
|
|
|
41
|
+
it('binds the oldest matching enrolled runners to a new reservation', async () => {
|
|
42
|
+
const olderRunner = await createIdleRunner({
|
|
43
|
+
labels: ['linux'],
|
|
44
|
+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
45
|
+
});
|
|
46
|
+
const newerRunner = await createIdleRunner({
|
|
47
|
+
labels: ['linux'],
|
|
48
|
+
createdAt: new Date('2026-01-02T00:00:00.000Z'),
|
|
49
|
+
});
|
|
50
|
+
const unmatchedRunner = await createIdleRunner({
|
|
51
|
+
labels: ['macos'],
|
|
52
|
+
createdAt: new Date('2026-01-03T00:00:00.000Z'),
|
|
53
|
+
});
|
|
54
|
+
await createPendingJobs(2, ['linux']);
|
|
55
|
+
|
|
56
|
+
const result = await pollDemandAndReserve({
|
|
57
|
+
workspaceId,
|
|
58
|
+
provisionerId,
|
|
59
|
+
maxReservations: 2,
|
|
60
|
+
ttlSeconds: 60,
|
|
61
|
+
templates: [template('linux', ['linux'], 2)],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const rows = await db()
|
|
65
|
+
.select()
|
|
66
|
+
.from(providerRunners)
|
|
67
|
+
.where(inArray(providerRunners.id, [olderRunner.id, newerRunner.id, unmatchedRunner.id]));
|
|
68
|
+
const rowsById = new Map(rows.map((row) => [row.id, row]));
|
|
69
|
+
const reservationId = result.reservations[0]?.reservationId;
|
|
70
|
+
|
|
71
|
+
expect(reservationId).toEqual(expect.any(String));
|
|
72
|
+
expect(rowsById.get(olderRunner.id)).toMatchObject({
|
|
73
|
+
workspaceId,
|
|
74
|
+
reservationId,
|
|
75
|
+
assignedAt: expect.any(Date),
|
|
76
|
+
});
|
|
77
|
+
expect(rowsById.get(newerRunner.id)).toMatchObject({
|
|
78
|
+
workspaceId,
|
|
79
|
+
reservationId,
|
|
80
|
+
assignedAt: expect.any(Date),
|
|
81
|
+
});
|
|
82
|
+
expect(rowsById.get(unmatchedRunner.id)).toMatchObject({
|
|
83
|
+
workspaceId: null,
|
|
84
|
+
reservationId: null,
|
|
85
|
+
assignedAt: null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not bind a running runner without an active control session', async () => {
|
|
90
|
+
const [runner] = await db()
|
|
91
|
+
.insert(providerRunners)
|
|
92
|
+
.values({
|
|
93
|
+
provisionerId,
|
|
94
|
+
providerRunnerId: 'un-enrolled-runner',
|
|
95
|
+
labels: ['linux'],
|
|
96
|
+
state: 'running',
|
|
97
|
+
reportedAt: new Date(),
|
|
98
|
+
})
|
|
99
|
+
.returning();
|
|
100
|
+
if (!runner) throw new Error('Expected runner instance');
|
|
101
|
+
await createPendingJobs(1, ['linux']);
|
|
102
|
+
|
|
103
|
+
const result = await pollDemandAndReserve({
|
|
104
|
+
workspaceId,
|
|
105
|
+
provisionerId,
|
|
106
|
+
maxReservations: 1,
|
|
107
|
+
ttlSeconds: 60,
|
|
108
|
+
templates: [template('linux', ['linux'], 1)],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const [storedRunner] = await db()
|
|
112
|
+
.select()
|
|
113
|
+
.from(providerRunners)
|
|
114
|
+
.where(eq(providerRunners.id, runner.id));
|
|
115
|
+
expect(result.reservations[0]?.count).toBe(1);
|
|
116
|
+
expect(storedRunner).toMatchObject({workspaceId: null, reservationId: null});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not bind a runner with an expired control session', async () => {
|
|
120
|
+
const runner = await createIdleRunner({
|
|
121
|
+
labels: ['linux'],
|
|
122
|
+
controlSessionExpiresAt: new Date(Date.now() - 60_000),
|
|
123
|
+
});
|
|
124
|
+
await createPendingJobs(1, ['linux']);
|
|
125
|
+
|
|
126
|
+
const result = await pollDemandAndReserve({
|
|
127
|
+
workspaceId,
|
|
128
|
+
provisionerId,
|
|
129
|
+
maxReservations: 1,
|
|
130
|
+
ttlSeconds: 60,
|
|
131
|
+
templates: [template('linux', ['linux'], 1)],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const [storedRunner] = await db()
|
|
135
|
+
.select()
|
|
136
|
+
.from(providerRunners)
|
|
137
|
+
.where(eq(providerRunners.id, runner.id));
|
|
138
|
+
expect(result.reservations[0]?.count).toBe(1);
|
|
139
|
+
expect(storedRunner).toMatchObject({workspaceId: null, reservationId: null});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not grant another reservation after idle runners are covered', async () => {
|
|
143
|
+
await createIdleRunner({labels: ['linux']});
|
|
144
|
+
await createPendingJobs(1, ['linux']);
|
|
145
|
+
|
|
146
|
+
const firstResult = await pollDemandAndReserve({
|
|
147
|
+
workspaceId,
|
|
148
|
+
provisionerId,
|
|
149
|
+
maxReservations: 1,
|
|
150
|
+
ttlSeconds: 60,
|
|
151
|
+
templates: [template('linux', ['linux'], 1)],
|
|
152
|
+
});
|
|
153
|
+
const secondResult = await pollDemandAndReserve({
|
|
154
|
+
workspaceId,
|
|
155
|
+
provisionerId,
|
|
156
|
+
maxReservations: 1,
|
|
157
|
+
ttlSeconds: 60,
|
|
158
|
+
templates: [template('linux', ['linux'], 1)],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(firstResult.reservations).toHaveLength(1);
|
|
162
|
+
expect(secondResult.reservations).toEqual([]);
|
|
163
|
+
expect(secondResult.stats[0]).toMatchObject({queued: 1, reserved: 1});
|
|
164
|
+
});
|
|
165
|
+
|
|
37
166
|
it('allocates overlapping label sets most-specific-first', async () => {
|
|
38
167
|
await createPendingJobs(2, ['linux']);
|
|
39
168
|
await createPendingJobs(1, ['linux', 'gpu']);
|
|
@@ -288,6 +417,98 @@ describe('pollDemandAndReserve', () => {
|
|
|
288
417
|
expect(remaining[0]?.requiredLabels).toEqual(['macos']);
|
|
289
418
|
});
|
|
290
419
|
|
|
420
|
+
it('unbinds prewarmed runners and revokes activation tokens when deleting a reservation', async () => {
|
|
421
|
+
const runner = await createIdleRunner({labels: ['linux']});
|
|
422
|
+
await createPendingJobs(1, ['linux']);
|
|
423
|
+
|
|
424
|
+
const result = await pollDemandAndReserve({
|
|
425
|
+
workspaceId,
|
|
426
|
+
provisionerId,
|
|
427
|
+
maxReservations: 1,
|
|
428
|
+
ttlSeconds: 60,
|
|
429
|
+
templates: [template('linux', ['linux'], 1)],
|
|
430
|
+
});
|
|
431
|
+
const reservationId = result.reservations[0]?.reservationId;
|
|
432
|
+
if (!reservationId) throw new Error('Expected reservation');
|
|
433
|
+
|
|
434
|
+
const [activationToken] = await db()
|
|
435
|
+
.insert(runnerActivationTokens)
|
|
436
|
+
.values({
|
|
437
|
+
runnerInstanceId: runner.id,
|
|
438
|
+
hashedToken: crypto.randomUUID(),
|
|
439
|
+
prefix: 'test',
|
|
440
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
441
|
+
})
|
|
442
|
+
.returning();
|
|
443
|
+
if (!activationToken) throw new Error('Expected activation token');
|
|
444
|
+
|
|
445
|
+
const deleted = await deleteReservationsByIds([reservationId]);
|
|
446
|
+
|
|
447
|
+
const [storedRunner] = await db()
|
|
448
|
+
.select()
|
|
449
|
+
.from(providerRunners)
|
|
450
|
+
.where(eq(providerRunners.id, runner.id));
|
|
451
|
+
const [storedToken] = await db()
|
|
452
|
+
.select()
|
|
453
|
+
.from(runnerActivationTokens)
|
|
454
|
+
.where(eq(runnerActivationTokens.id, activationToken.id));
|
|
455
|
+
expect(deleted).toBe(1);
|
|
456
|
+
expect(storedRunner).toMatchObject({
|
|
457
|
+
workspaceId: null,
|
|
458
|
+
reservationId: null,
|
|
459
|
+
assignedAt: null,
|
|
460
|
+
});
|
|
461
|
+
expect(storedToken?.revokedAt).toBeInstanceOf(Date);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('unbinds prewarmed runners and revokes activation tokens when deleting expired reservations', async () => {
|
|
465
|
+
const runner = await createIdleRunner({labels: ['linux']});
|
|
466
|
+
await createPendingJobs(1, ['linux']);
|
|
467
|
+
|
|
468
|
+
const result = await pollDemandAndReserve({
|
|
469
|
+
workspaceId,
|
|
470
|
+
provisionerId,
|
|
471
|
+
maxReservations: 1,
|
|
472
|
+
ttlSeconds: 60,
|
|
473
|
+
templates: [template('linux', ['linux'], 1)],
|
|
474
|
+
});
|
|
475
|
+
const reservationId = result.reservations[0]?.reservationId;
|
|
476
|
+
if (!reservationId) throw new Error('Expected reservation');
|
|
477
|
+
|
|
478
|
+
await db()
|
|
479
|
+
.update(reservations)
|
|
480
|
+
.set({expiresAt: new Date(Date.now() - 60_000)})
|
|
481
|
+
.where(eq(reservations.id, reservationId));
|
|
482
|
+
const [activationToken] = await db()
|
|
483
|
+
.insert(runnerActivationTokens)
|
|
484
|
+
.values({
|
|
485
|
+
runnerInstanceId: runner.id,
|
|
486
|
+
hashedToken: crypto.randomUUID(),
|
|
487
|
+
prefix: 'test',
|
|
488
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
489
|
+
})
|
|
490
|
+
.returning();
|
|
491
|
+
if (!activationToken) throw new Error('Expected activation token');
|
|
492
|
+
|
|
493
|
+
const deleted = await deleteExpiredReservations();
|
|
494
|
+
|
|
495
|
+
const [storedRunner] = await db()
|
|
496
|
+
.select()
|
|
497
|
+
.from(providerRunners)
|
|
498
|
+
.where(eq(providerRunners.id, runner.id));
|
|
499
|
+
const [storedToken] = await db()
|
|
500
|
+
.select()
|
|
501
|
+
.from(runnerActivationTokens)
|
|
502
|
+
.where(eq(runnerActivationTokens.id, activationToken.id));
|
|
503
|
+
expect(deleted).toBeGreaterThanOrEqual(1);
|
|
504
|
+
expect(storedRunner).toMatchObject({
|
|
505
|
+
workspaceId: null,
|
|
506
|
+
reservationId: null,
|
|
507
|
+
assignedAt: null,
|
|
508
|
+
});
|
|
509
|
+
expect(storedToken?.revokedAt).toBeInstanceOf(Date);
|
|
510
|
+
});
|
|
511
|
+
|
|
291
512
|
it('decrements reservation units inside a caller transaction', async () => {
|
|
292
513
|
await reservationFactory.create({
|
|
293
514
|
workspaceId,
|
|
@@ -420,6 +641,38 @@ describe('pollDemandAndReserve', () => {
|
|
|
420
641
|
}
|
|
421
642
|
}
|
|
422
643
|
|
|
644
|
+
async function createIdleRunner(params: {
|
|
645
|
+
labels: string[];
|
|
646
|
+
createdAt?: Date;
|
|
647
|
+
controlSessionExpiresAt?: Date;
|
|
648
|
+
}) {
|
|
649
|
+
const createdAt = params.createdAt ?? new Date();
|
|
650
|
+
const [runner] = await db()
|
|
651
|
+
.insert(providerRunners)
|
|
652
|
+
.values({
|
|
653
|
+
provisionerId,
|
|
654
|
+
providerRunnerId: crypto.randomUUID(),
|
|
655
|
+
labels: params.labels,
|
|
656
|
+
state: 'running',
|
|
657
|
+
reportedAt: createdAt,
|
|
658
|
+
createdAt,
|
|
659
|
+
updatedAt: createdAt,
|
|
660
|
+
})
|
|
661
|
+
.returning();
|
|
662
|
+
if (!runner) throw new Error('Expected runner instance');
|
|
663
|
+
|
|
664
|
+
await db()
|
|
665
|
+
.insert(runnerControlSessions)
|
|
666
|
+
.values({
|
|
667
|
+
runnerInstanceId: runner.id,
|
|
668
|
+
provisionerId,
|
|
669
|
+
hashedToken: crypto.randomUUID(),
|
|
670
|
+
prefix: 'test',
|
|
671
|
+
expiresAt: params.controlSessionExpiresAt ?? new Date(Date.now() + 60_000),
|
|
672
|
+
});
|
|
673
|
+
return runner;
|
|
674
|
+
}
|
|
675
|
+
|
|
423
676
|
async function activeReservedCount(): Promise<number> {
|
|
424
677
|
const [row] = await db()
|
|
425
678
|
.select({value: sum(reservations.count)})
|