@shipfox/api-runners 10.1.0 → 11.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 +27 -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 +5 -4
- 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/list-active-runners.test.ts +8 -2
- package/src/presentation/routes/manual-registration-tokens.test.ts +8 -2
- package/src/presentation/routes/provisioner-tokens.test.ts +8 -2
- 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
|
@@ -15,6 +15,20 @@
|
|
|
15
15
|
"when": 1785161220762,
|
|
16
16
|
"tag": "0001_intended_reservation",
|
|
17
17
|
"breakpoints": true
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"idx": 2,
|
|
21
|
+
"version": "7",
|
|
22
|
+
"when": 1785232451694,
|
|
23
|
+
"tag": "0002_special_shadowcat",
|
|
24
|
+
"breakpoints": true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"idx": 3,
|
|
28
|
+
"version": "7",
|
|
29
|
+
"when": 1785235910167,
|
|
30
|
+
"tag": "0003_flaky_sally_floyd",
|
|
31
|
+
"breakpoints": true
|
|
18
32
|
}
|
|
19
33
|
]
|
|
20
34
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-runners",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "11.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
"@temporalio/workflow": "1.18.1",
|
|
22
22
|
"drizzle-orm": "^0.45.2",
|
|
23
23
|
"zod": "^4.4.3",
|
|
24
|
-
"@shipfox/api-
|
|
25
|
-
"@shipfox/api-auth-
|
|
26
|
-
"@shipfox/api-
|
|
24
|
+
"@shipfox/api-common-dto": "9.2.0",
|
|
25
|
+
"@shipfox/api-auth-context": "11.0.0",
|
|
26
|
+
"@shipfox/api-auth-dto": "10.2.0",
|
|
27
|
+
"@shipfox/api-runners-dto": "10.2.0",
|
|
27
28
|
"@shipfox/api-workflows-dto": "10.0.0",
|
|
28
29
|
"@shipfox/config": "1.2.4",
|
|
29
30
|
"@shipfox/inter-module": "0.2.2",
|
package/src/config.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {RUNNER_ASSIGNMENT_POLL_DEFAULT_WAIT_SECONDS} from '@shipfox/api-runners-dto';
|
|
1
2
|
import {vi} from '@shipfox/vitest/vi';
|
|
2
3
|
|
|
3
4
|
describe('EPHEMERAL_REGISTRATION_TOKEN_TTL_SECONDS validation', () => {
|
|
@@ -36,6 +37,16 @@ describe('runner assignment polling defaults', () => {
|
|
|
36
37
|
vi.resetModules();
|
|
37
38
|
const {config} = await import('#config.js');
|
|
38
39
|
expect(config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS).toBe(250);
|
|
40
|
+
expect(config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS).toBe(
|
|
41
|
+
RUNNER_ASSIGNMENT_POLL_DEFAULT_WAIT_SECONDS,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects a zero-second server-side assignment wait cap', async () => {
|
|
46
|
+
vi.stubEnv('RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS', '0');
|
|
47
|
+
vi.resetModules();
|
|
48
|
+
|
|
49
|
+
await expect(import('#config.js')).rejects.toThrow('RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS');
|
|
39
50
|
});
|
|
40
51
|
});
|
|
41
52
|
describe('REGISTRATION_TOKEN_BATCH_MAX validation', () => {
|
package/src/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {RUNNER_ASSIGNMENT_POLL_DEFAULT_WAIT_SECONDS} from '@shipfox/api-runners-dto';
|
|
1
2
|
import {bool, createConfig, num} from '@shipfox/config';
|
|
2
3
|
import {STUCK_JOB_THRESHOLD_SECONDS} from '#core/maintenance-policy.js';
|
|
3
4
|
|
|
@@ -19,8 +20,8 @@ export const config = createConfig({
|
|
|
19
20
|
default: 300,
|
|
20
21
|
}),
|
|
21
22
|
RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS: num({
|
|
22
|
-
desc: 'Maximum
|
|
23
|
-
default:
|
|
23
|
+
desc: 'Maximum server-side cap for the per-request runner assignment wait, in seconds. Managed runners request their own bounded wait with wait_seconds.',
|
|
24
|
+
default: RUNNER_ASSIGNMENT_POLL_DEFAULT_WAIT_SECONDS,
|
|
24
25
|
}),
|
|
25
26
|
RUNNER_ASSIGNMENT_POLL_INTERVAL_MS: num({
|
|
26
27
|
desc: 'Delay between durable assignment reads while a runner-control session waits for an assignment, in milliseconds.',
|
|
@@ -159,12 +160,22 @@ for (const [name, value] of [
|
|
|
159
160
|
}
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
163
|
+
if (
|
|
164
|
+
!Number.isInteger(config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS) ||
|
|
165
|
+
config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS < 1
|
|
166
|
+
) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS (${config.RUNNER_ASSIGNMENT_POLL_MAX_WAIT_SECONDS}) must be a whole number >= 1.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
!Number.isInteger(config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS) ||
|
|
174
|
+
config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS < 0
|
|
175
|
+
) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`RUNNER_ASSIGNMENT_POLL_INTERVAL_MS (${config.RUNNER_ASSIGNMENT_POLL_INTERVAL_MS}) must be a whole number >= 0.`,
|
|
178
|
+
);
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
if (
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {config} from '#config.js';
|
|
2
|
+
import {
|
|
3
|
+
type AdministratorRunnerInstanceRow,
|
|
4
|
+
type ListAdministratorRunnerInstancesParams,
|
|
5
|
+
listAdministratorRunnerInstances as listAdministratorRunnerInstanceRows,
|
|
6
|
+
} from '#db/admin-runner-instances.js';
|
|
7
|
+
import {isTerminalState} from '#db/runner-instances.js';
|
|
8
|
+
|
|
9
|
+
export type RunnerAdministratorLifecycleState =
|
|
10
|
+
| 'unassigned'
|
|
11
|
+
| 'assigned'
|
|
12
|
+
| 'activated'
|
|
13
|
+
| 'claimed'
|
|
14
|
+
| 'completed';
|
|
15
|
+
export type RunnerAdministratorEnrollmentState = 'pending' | 'enrolled' | 'activated';
|
|
16
|
+
export type RunnerAdministratorReconciliationStatus = 'current' | 'stale' | 'terminal' | 'unknown';
|
|
17
|
+
|
|
18
|
+
export interface RunnerAdministratorInstance {
|
|
19
|
+
id: string;
|
|
20
|
+
lifecycleState: RunnerAdministratorLifecycleState;
|
|
21
|
+
computeState: AdministratorRunnerInstanceRow['state'];
|
|
22
|
+
enrollmentState: RunnerAdministratorEnrollmentState;
|
|
23
|
+
assignmentPresence: 'assigned' | 'unassigned';
|
|
24
|
+
assignedWorkspace: {id: string} | null;
|
|
25
|
+
labels: string[];
|
|
26
|
+
createdAt: Date;
|
|
27
|
+
lastHeartbeatAt: Date;
|
|
28
|
+
closureReason: string | null;
|
|
29
|
+
closedAt: Date | null;
|
|
30
|
+
provisioner: {
|
|
31
|
+
id: string;
|
|
32
|
+
scope: 'installation';
|
|
33
|
+
name: string | null;
|
|
34
|
+
};
|
|
35
|
+
reconciliationStatus: RunnerAdministratorReconciliationStatus;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ListRunnerAdministratorInstancesParams
|
|
39
|
+
extends ListAdministratorRunnerInstancesParams {
|
|
40
|
+
reconciliationStaleAfterSeconds?: number | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ListRunnerAdministratorInstancesResult {
|
|
44
|
+
runners: RunnerAdministratorInstance[];
|
|
45
|
+
nextCursor: Awaited<ReturnType<typeof listAdministratorRunnerInstanceRows>>['nextCursor'];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function listRunnerAdministratorInstances(
|
|
49
|
+
params: ListRunnerAdministratorInstancesParams,
|
|
50
|
+
): Promise<ListRunnerAdministratorInstancesResult> {
|
|
51
|
+
const result = await listAdministratorRunnerInstanceRows(params);
|
|
52
|
+
const staleAfterSeconds =
|
|
53
|
+
params.reconciliationStaleAfterSeconds ??
|
|
54
|
+
config.RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS;
|
|
55
|
+
const now = new Date();
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
runners: result.runners.map((row) =>
|
|
59
|
+
toRunnerAdministratorInstance(row, {now, staleAfterSeconds}),
|
|
60
|
+
),
|
|
61
|
+
nextCursor: result.nextCursor,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toRunnerAdministratorInstance(
|
|
66
|
+
row: AdministratorRunnerInstanceRow,
|
|
67
|
+
params: {now: Date; staleAfterSeconds: number},
|
|
68
|
+
): RunnerAdministratorInstance {
|
|
69
|
+
const isAssigned = row.workspaceId !== null;
|
|
70
|
+
const isActivated = row.runnerSessionId !== null;
|
|
71
|
+
const hasClaim = row.firstClaimedAt !== null;
|
|
72
|
+
const isTerminal = isTerminalState(row.state);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
id: row.id,
|
|
76
|
+
lifecycleState: !isAssigned
|
|
77
|
+
? 'unassigned'
|
|
78
|
+
: isTerminal
|
|
79
|
+
? 'completed'
|
|
80
|
+
: !isActivated
|
|
81
|
+
? 'assigned'
|
|
82
|
+
: !hasClaim
|
|
83
|
+
? 'activated'
|
|
84
|
+
: 'claimed',
|
|
85
|
+
computeState: row.state,
|
|
86
|
+
enrollmentState: isActivated
|
|
87
|
+
? 'activated'
|
|
88
|
+
: row.hasActiveControlSession
|
|
89
|
+
? 'enrolled'
|
|
90
|
+
: 'pending',
|
|
91
|
+
assignmentPresence: isAssigned ? 'assigned' : 'unassigned',
|
|
92
|
+
assignedWorkspace: row.workspaceId ? {id: row.workspaceId} : null,
|
|
93
|
+
labels: row.labels,
|
|
94
|
+
createdAt: row.createdAt,
|
|
95
|
+
lastHeartbeatAt: row.reportedAt,
|
|
96
|
+
closureReason: row.reason,
|
|
97
|
+
closedAt: closedAt(row),
|
|
98
|
+
provisioner: {
|
|
99
|
+
id: row.provisionerId,
|
|
100
|
+
scope: 'installation',
|
|
101
|
+
name: row.provisionerName,
|
|
102
|
+
},
|
|
103
|
+
reconciliationStatus: reconciliationStatus(row, params),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function closedAt(row: AdministratorRunnerInstanceRow): Date | null {
|
|
108
|
+
if (row.state === 'stopped') return row.stoppedAt;
|
|
109
|
+
if (row.state === 'failed') return row.failedAt;
|
|
110
|
+
if (row.state === 'terminated') return row.terminatedAt;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function reconciliationStatus(
|
|
115
|
+
row: AdministratorRunnerInstanceRow,
|
|
116
|
+
params: {now: Date; staleAfterSeconds: number},
|
|
117
|
+
): RunnerAdministratorReconciliationStatus {
|
|
118
|
+
if (isTerminalState(row.state)) return 'terminal';
|
|
119
|
+
if (!row.provisionerLastSeenAt) return 'unknown';
|
|
120
|
+
|
|
121
|
+
const cutoff = params.now.getTime() - params.staleAfterSeconds * 1000;
|
|
122
|
+
const reportIsStale = row.reportedAt.getTime() <= cutoff;
|
|
123
|
+
const provisionerIsStale = row.provisionerLastSeenAt.getTime() <= cutoff;
|
|
124
|
+
return reportIsStale || provisionerIsStale ? 'stale' : 'current';
|
|
125
|
+
}
|
package/src/core/errors.ts
CHANGED
|
@@ -19,6 +19,20 @@ export class ProvisionerTokenNotFoundError extends Error {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
export class ProvisionerAdminIdempotencyKeyReuseError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('Idempotency-Key was already used for a different provisioner token command');
|
|
25
|
+
this.name = 'ProvisionerAdminIdempotencyKeyReuseError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ProvisionerAdminIdempotencyReplayUnavailableError extends Error {
|
|
30
|
+
constructor() {
|
|
31
|
+
super('Idempotency-Key replay cannot reproduce the original provisioner token');
|
|
32
|
+
this.name = 'ProvisionerAdminIdempotencyReplayUnavailableError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
export class EmptyRunnerLabelsError extends Error {
|
|
23
37
|
constructor() {
|
|
24
38
|
super('Runner labels cannot be empty');
|
package/src/core/index.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ListRunnerAdministratorInstancesParams,
|
|
3
|
+
type ListRunnerAdministratorInstancesResult,
|
|
4
|
+
listRunnerAdministratorInstances,
|
|
5
|
+
type RunnerAdministratorInstance,
|
|
6
|
+
} from './admin-runner-instances.js';
|
|
1
7
|
export type {EphemeralRegistrationToken} from './entities/ephemeral-registration-token.js';
|
|
2
8
|
export type {ManualRegistrationToken} from './entities/manual-registration-token.js';
|
|
3
9
|
export type {ProvisionerCapabilitySnapshot} from './entities/provisioner-capability-snapshot.js';
|
|
@@ -14,6 +20,8 @@ export type {RunnerSession} from './entities/runner-session.js';
|
|
|
14
20
|
export {
|
|
15
21
|
EmptyRunnerLabelsError,
|
|
16
22
|
ManualRegistrationTokenNotFoundError,
|
|
23
|
+
ProvisionerAdminIdempotencyKeyReuseError,
|
|
24
|
+
ProvisionerAdminIdempotencyReplayUnavailableError,
|
|
17
25
|
ProvisionerTokenNotFoundError,
|
|
18
26
|
RegistrationTokenConsumedError,
|
|
19
27
|
RegistrationTokenExpiredError,
|
|
@@ -28,10 +36,14 @@ export {
|
|
|
28
36
|
} from './manual-registration-tokens.js';
|
|
29
37
|
export {hasActiveWorkspaceProvisionerCapability} from './provisioner-capability-snapshots.js';
|
|
30
38
|
export {
|
|
39
|
+
createAdministratorInstallationProvisionerToken,
|
|
31
40
|
createInstallationProvisionerToken,
|
|
32
41
|
createWorkspaceProvisionerToken,
|
|
42
|
+
installationProvisionerTokenStatus,
|
|
33
43
|
listActiveProvisioners,
|
|
44
|
+
listAdministratorInstallationProvisionerTokens,
|
|
34
45
|
listUsableProvisionerTokens,
|
|
46
|
+
revokeAdministratorInstallationProvisionerToken,
|
|
35
47
|
revokeInstallationProvisionerToken,
|
|
36
48
|
revokeWorkspaceProvisionerToken,
|
|
37
49
|
} from './provisioner-tokens.js';
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
import {createHmac} from 'node:crypto';
|
|
2
|
+
import type {AdministrationRole} from '@shipfox/api-common-dto';
|
|
3
|
+
import {createAdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
4
|
+
import {runnerSessionTokenKey} from '@shipfox/node-auth-root-key';
|
|
5
|
+
import type {TimestampIdCursor} from '@shipfox/node-drizzle';
|
|
1
6
|
import {extractDisplayPrefix, generateOpaqueToken, hashOpaqueToken} from '@shipfox/node-tokens';
|
|
2
7
|
import {
|
|
8
|
+
createInstallationProvisionerTokenWithAudit,
|
|
3
9
|
createProvisionerToken,
|
|
4
10
|
listActiveProvisionerTokens,
|
|
11
|
+
listInstallationProvisionerTokens,
|
|
5
12
|
listUsableProvisionerTokensByWorkspaceId,
|
|
6
13
|
revokeInstallationProvisionerToken as revokeInstallationProvisionerTokenDb,
|
|
14
|
+
revokeInstallationProvisionerTokenWithAudit,
|
|
7
15
|
revokeProvisionerToken,
|
|
8
16
|
} from '#db/provisioner-tokens.js';
|
|
9
17
|
import {config} from '../config.js';
|
|
10
18
|
import type {ActiveProvisionerToken, ProvisionerToken} from './entities/provisioner-token.js';
|
|
11
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
ProvisionerAdminIdempotencyReplayUnavailableError,
|
|
21
|
+
ProvisionerTokenNotFoundError,
|
|
22
|
+
} from './errors.js';
|
|
12
23
|
|
|
13
24
|
export interface CreateWorkspaceProvisionerTokenParams {
|
|
14
25
|
workspaceId: string;
|
|
@@ -92,3 +103,148 @@ export async function revokeInstallationProvisionerToken(params: {
|
|
|
92
103
|
if (!token) throw new ProvisionerTokenNotFoundError(params.tokenId);
|
|
93
104
|
return token;
|
|
94
105
|
}
|
|
106
|
+
|
|
107
|
+
const ADMIN_OWNER_ROLE: AdministrationRole = 'admin-owner';
|
|
108
|
+
const ADMIN_OPERATOR_ROLE: AdministrationRole = 'admin-operator';
|
|
109
|
+
const CREATE_ADMIN_COMMAND = 'runners.provisioner_token.create';
|
|
110
|
+
const REVOKE_ADMIN_COMMAND = 'runners.provisioner_token.revoke';
|
|
111
|
+
|
|
112
|
+
export interface InstallationProvisionerTokenAdministrationContext {
|
|
113
|
+
actorId: string;
|
|
114
|
+
actorRole: AdministrationRole;
|
|
115
|
+
idempotencyKey: string;
|
|
116
|
+
correlationId: string;
|
|
117
|
+
reason: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function administrationCommandFingerprint(command: string, input: unknown): string {
|
|
121
|
+
return hashOpaqueToken(`${command}:${JSON.stringify(input)}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function idempotentProvisionerToken(seed: string): string {
|
|
125
|
+
const generated = generateOpaqueToken('provisionerToken');
|
|
126
|
+
const tokenPrefix = generated.slice(0, -43);
|
|
127
|
+
const key = createHmac('sha256', runnerSessionTokenKey())
|
|
128
|
+
.update('shipfox/provisioner-token-idempotency/v1')
|
|
129
|
+
.digest();
|
|
130
|
+
const suffix = createHmac('sha256', key).update(seed).digest('base64url');
|
|
131
|
+
return `${tokenPrefix}${suffix}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function administrationEvent(params: {
|
|
135
|
+
actorId: string;
|
|
136
|
+
actorRole: AdministrationRole;
|
|
137
|
+
requiredRole: AdministrationRole;
|
|
138
|
+
command: string;
|
|
139
|
+
targetId: string;
|
|
140
|
+
reason: string;
|
|
141
|
+
correlationId: string;
|
|
142
|
+
idempotencyKeyFingerprint: string;
|
|
143
|
+
}) {
|
|
144
|
+
return createAdministrationActionEvent({
|
|
145
|
+
actorId: params.actorId,
|
|
146
|
+
actorRole: params.actorRole,
|
|
147
|
+
requiredRole: params.requiredRole,
|
|
148
|
+
command: params.command,
|
|
149
|
+
targetType: 'provisioner-token',
|
|
150
|
+
targetId: params.targetId,
|
|
151
|
+
reason: params.reason,
|
|
152
|
+
result: 'succeeded',
|
|
153
|
+
correlationId: params.correlationId,
|
|
154
|
+
idempotencyKeyFingerprint: params.idempotencyKeyFingerprint,
|
|
155
|
+
occurredAt: new Date().toISOString(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type InstallationProvisionerTokenStatus = 'active' | 'expired' | 'revoked';
|
|
160
|
+
|
|
161
|
+
export function installationProvisionerTokenStatus(
|
|
162
|
+
token: ProvisionerToken,
|
|
163
|
+
now = new Date(),
|
|
164
|
+
): InstallationProvisionerTokenStatus {
|
|
165
|
+
if (token.revokedAt) return 'revoked';
|
|
166
|
+
if (token.expiresAt && token.expiresAt <= now) return 'expired';
|
|
167
|
+
return 'active';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function listAdministratorInstallationProvisionerTokens(params: {
|
|
171
|
+
limit: number;
|
|
172
|
+
cursor?: TimestampIdCursor | undefined;
|
|
173
|
+
status?: InstallationProvisionerTokenStatus | undefined;
|
|
174
|
+
}) {
|
|
175
|
+
return await listInstallationProvisionerTokens(params);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function createAdministratorInstallationProvisionerToken(
|
|
179
|
+
params: InstallationProvisionerTokenAdministrationContext & {
|
|
180
|
+
name?: string | undefined;
|
|
181
|
+
ttlSeconds?: number | undefined;
|
|
182
|
+
},
|
|
183
|
+
): Promise<{token: ProvisionerToken; rawToken: string; correlationId: string}> {
|
|
184
|
+
const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
|
|
185
|
+
const requestFingerprint = administrationCommandFingerprint(CREATE_ADMIN_COMMAND, {
|
|
186
|
+
name: params.name,
|
|
187
|
+
ttlSeconds: params.ttlSeconds,
|
|
188
|
+
reason: params.reason,
|
|
189
|
+
});
|
|
190
|
+
const rawToken = idempotentProvisionerToken(
|
|
191
|
+
`${params.actorId}:${params.idempotencyKey}:${requestFingerprint}`,
|
|
192
|
+
);
|
|
193
|
+
const result = await createInstallationProvisionerTokenWithAudit({
|
|
194
|
+
actorId: params.actorId,
|
|
195
|
+
command: CREATE_ADMIN_COMMAND,
|
|
196
|
+
correlationId: params.correlationId,
|
|
197
|
+
idempotencyKeyFingerprint,
|
|
198
|
+
requestFingerprint,
|
|
199
|
+
hashedToken: hashOpaqueToken(rawToken),
|
|
200
|
+
prefix: extractDisplayPrefix(rawToken),
|
|
201
|
+
name: params.name,
|
|
202
|
+
ttlSeconds: params.ttlSeconds,
|
|
203
|
+
event: (tokenId) =>
|
|
204
|
+
administrationEvent({
|
|
205
|
+
actorId: params.actorId,
|
|
206
|
+
actorRole: params.actorRole,
|
|
207
|
+
requiredRole: ADMIN_OWNER_ROLE,
|
|
208
|
+
command: CREATE_ADMIN_COMMAND,
|
|
209
|
+
targetId: tokenId,
|
|
210
|
+
reason: params.reason,
|
|
211
|
+
correlationId: params.correlationId,
|
|
212
|
+
idempotencyKeyFingerprint,
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
if (result.replayed && hashOpaqueToken(rawToken) !== result.token.hashedToken) {
|
|
216
|
+
throw new ProvisionerAdminIdempotencyReplayUnavailableError();
|
|
217
|
+
}
|
|
218
|
+
return {...result, rawToken, correlationId: result.correlationId};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function revokeAdministratorInstallationProvisionerToken(
|
|
222
|
+
params: InstallationProvisionerTokenAdministrationContext & {tokenId: string},
|
|
223
|
+
): Promise<{token: ProvisionerToken; correlationId: string}> {
|
|
224
|
+
const idempotencyKeyFingerprint = hashOpaqueToken(params.idempotencyKey);
|
|
225
|
+
const requestFingerprint = administrationCommandFingerprint(REVOKE_ADMIN_COMMAND, {
|
|
226
|
+
tokenId: params.tokenId,
|
|
227
|
+
reason: params.reason,
|
|
228
|
+
});
|
|
229
|
+
const result = await revokeInstallationProvisionerTokenWithAudit({
|
|
230
|
+
tokenId: params.tokenId,
|
|
231
|
+
actorId: params.actorId,
|
|
232
|
+
command: REVOKE_ADMIN_COMMAND,
|
|
233
|
+
correlationId: params.correlationId,
|
|
234
|
+
idempotencyKeyFingerprint,
|
|
235
|
+
requestFingerprint,
|
|
236
|
+
event: (tokenId) =>
|
|
237
|
+
administrationEvent({
|
|
238
|
+
actorId: params.actorId,
|
|
239
|
+
actorRole: params.actorRole,
|
|
240
|
+
requiredRole: ADMIN_OPERATOR_ROLE,
|
|
241
|
+
command: REVOKE_ADMIN_COMMAND,
|
|
242
|
+
targetId: tokenId,
|
|
243
|
+
reason: params.reason,
|
|
244
|
+
correlationId: params.correlationId,
|
|
245
|
+
idempotencyKeyFingerprint,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
if (!result) throw new ProvisionerTokenNotFoundError(params.tokenId);
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {type TimestampIdCursor, timestampIdCursorWhere} from '@shipfox/node-drizzle';
|
|
2
|
+
import {canonicalizeLabels} from '@shipfox/runner-labels';
|
|
3
|
+
import {
|
|
4
|
+
and,
|
|
5
|
+
arrayContains,
|
|
6
|
+
desc,
|
|
7
|
+
eq,
|
|
8
|
+
exists,
|
|
9
|
+
gt,
|
|
10
|
+
isNotNull,
|
|
11
|
+
isNull,
|
|
12
|
+
type SQL,
|
|
13
|
+
sql,
|
|
14
|
+
} from 'drizzle-orm';
|
|
15
|
+
import type {RunnerInstanceState} from '#core/entities/runner-instance.js';
|
|
16
|
+
import {db} from './db.js';
|
|
17
|
+
import {provisionerTokens} from './schema/provisioner-tokens.js';
|
|
18
|
+
import {runnerControlSessions} from './schema/runner-control-sessions.js';
|
|
19
|
+
import {providerRunners} from './schema/runner-instances.js';
|
|
20
|
+
|
|
21
|
+
export type AdministratorRunnerAssignmentFilter = 'assigned' | 'unassigned';
|
|
22
|
+
|
|
23
|
+
export interface AdministratorRunnerInstanceRow {
|
|
24
|
+
id: string;
|
|
25
|
+
workspaceId: string | null;
|
|
26
|
+
state: RunnerInstanceState;
|
|
27
|
+
labels: string[];
|
|
28
|
+
runnerSessionId: string | null;
|
|
29
|
+
firstClaimedAt: Date | null;
|
|
30
|
+
hasActiveControlSession: boolean;
|
|
31
|
+
reason: string | null;
|
|
32
|
+
reportedAt: Date;
|
|
33
|
+
stoppedAt: Date | null;
|
|
34
|
+
failedAt: Date | null;
|
|
35
|
+
terminatedAt: Date | null;
|
|
36
|
+
createdAt: Date;
|
|
37
|
+
provisionerId: string;
|
|
38
|
+
provisionerName: string | null;
|
|
39
|
+
provisionerLastSeenAt: Date | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ListAdministratorRunnerInstancesParams {
|
|
43
|
+
limit: number;
|
|
44
|
+
cursor?: TimestampIdCursor | undefined;
|
|
45
|
+
state?: RunnerInstanceState | undefined;
|
|
46
|
+
assignment?: AdministratorRunnerAssignmentFilter | undefined;
|
|
47
|
+
label?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ListAdministratorRunnerInstancesResult {
|
|
51
|
+
runners: AdministratorRunnerInstanceRow[];
|
|
52
|
+
nextCursor: TimestampIdCursor | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function listAdministratorRunnerInstances(
|
|
56
|
+
params: ListAdministratorRunnerInstancesParams,
|
|
57
|
+
): Promise<ListAdministratorRunnerInstancesResult> {
|
|
58
|
+
const conditions: SQL[] = [eq(provisionerTokens.scope, 'installation')];
|
|
59
|
+
const cursor = timestampIdCursorWhere({
|
|
60
|
+
timestampColumn: providerRunners.createdAt,
|
|
61
|
+
idColumn: providerRunners.id,
|
|
62
|
+
cursor: params.cursor,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (cursor) conditions.push(cursor);
|
|
66
|
+
if (params.state) conditions.push(eq(providerRunners.state, params.state));
|
|
67
|
+
if (params.assignment === 'assigned') conditions.push(isNotNull(providerRunners.workspaceId));
|
|
68
|
+
if (params.assignment === 'unassigned') conditions.push(isNull(providerRunners.workspaceId));
|
|
69
|
+
if (params.label) {
|
|
70
|
+
const [label] = canonicalizeLabels(params.label);
|
|
71
|
+
if (label) conditions.push(arrayContains(providerRunners.labels, [label]));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rows = await db()
|
|
75
|
+
.select({
|
|
76
|
+
id: providerRunners.id,
|
|
77
|
+
workspaceId: providerRunners.workspaceId,
|
|
78
|
+
state: providerRunners.state,
|
|
79
|
+
labels: providerRunners.labels,
|
|
80
|
+
runnerSessionId: providerRunners.runnerSessionId,
|
|
81
|
+
firstClaimedAt: providerRunners.firstClaimedAt,
|
|
82
|
+
hasActiveControlSession: exists(
|
|
83
|
+
db()
|
|
84
|
+
.select({id: runnerControlSessions.id})
|
|
85
|
+
.from(runnerControlSessions)
|
|
86
|
+
.where(
|
|
87
|
+
and(
|
|
88
|
+
eq(runnerControlSessions.runnerInstanceId, providerRunners.id),
|
|
89
|
+
isNull(runnerControlSessions.closedAt),
|
|
90
|
+
gt(runnerControlSessions.expiresAt, sql`now()`),
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
reason: providerRunners.reason,
|
|
95
|
+
reportedAt: providerRunners.reportedAt,
|
|
96
|
+
stoppedAt: providerRunners.stoppedAt,
|
|
97
|
+
failedAt: providerRunners.failedAt,
|
|
98
|
+
terminatedAt: providerRunners.terminatedAt,
|
|
99
|
+
createdAt: providerRunners.createdAt,
|
|
100
|
+
provisionerId: provisionerTokens.id,
|
|
101
|
+
provisionerName: provisionerTokens.name,
|
|
102
|
+
provisionerLastSeenAt: provisionerTokens.lastSeenAt,
|
|
103
|
+
})
|
|
104
|
+
.from(providerRunners)
|
|
105
|
+
.innerJoin(provisionerTokens, eq(provisionerTokens.id, providerRunners.provisionerId))
|
|
106
|
+
.where(and(...conditions))
|
|
107
|
+
.orderBy(desc(providerRunners.createdAt), desc(providerRunners.id))
|
|
108
|
+
.limit(params.limit + 1);
|
|
109
|
+
|
|
110
|
+
const hasMore = rows.length > params.limit;
|
|
111
|
+
const pageRows = hasMore ? rows.slice(0, params.limit) : rows;
|
|
112
|
+
const last = pageRows.at(-1);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
runners: pageRows.map((row) => ({
|
|
116
|
+
...row,
|
|
117
|
+
hasActiveControlSession: Boolean(row.hasActiveControlSession),
|
|
118
|
+
})),
|
|
119
|
+
nextCursor: hasMore && last ? {createdAt: last.createdAt, id: last.id} : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/db/db.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
2
|
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
+
import {runnersAdminCommandResults} from './schema/admin-command-results.js';
|
|
3
4
|
import {ephemeralRegistrationTokens} from './schema/ephemeral-registration-tokens.js';
|
|
4
5
|
import {manualRegistrationTokens} from './schema/manual-registration-tokens.js';
|
|
5
6
|
import {runnersOutbox} from './schema/outbox.js';
|
|
@@ -15,6 +16,7 @@ import {runnerSessions} from './schema/runner-sessions.js';
|
|
|
15
16
|
import {runningJobExecutions} from './schema/running-job-executions.js';
|
|
16
17
|
|
|
17
18
|
export const schema = {
|
|
19
|
+
runnersAdminCommandResults,
|
|
18
20
|
ephemeralRegistrationTokens,
|
|
19
21
|
pendingJobExecutions,
|
|
20
22
|
providerRunners,
|
package/src/db/index.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import {dirname, resolve} from 'node:path';
|
|
2
2
|
import {fileURLToPath} from 'node:url';
|
|
3
3
|
|
|
4
|
+
export type {
|
|
5
|
+
AdministratorRunnerAssignmentFilter,
|
|
6
|
+
AdministratorRunnerInstanceRow,
|
|
7
|
+
ListAdministratorRunnerInstancesParams,
|
|
8
|
+
ListAdministratorRunnerInstancesResult,
|
|
9
|
+
} from './admin-runner-instances.js';
|
|
10
|
+
export {listAdministratorRunnerInstances} from './admin-runner-instances.js';
|
|
4
11
|
export {closeDb, db, schema} from './db.js';
|
|
5
12
|
export type {
|
|
6
13
|
CreateEphemeralRegistrationTokenParams,
|
|
@@ -44,10 +51,13 @@ export {
|
|
|
44
51
|
} from './provisioner-capability-snapshots.js';
|
|
45
52
|
export type {CreateProvisionerTokenParams} from './provisioner-tokens.js';
|
|
46
53
|
export {
|
|
54
|
+
createInstallationProvisionerTokenWithAudit,
|
|
47
55
|
createProvisionerToken,
|
|
48
56
|
listActiveProvisionerTokens,
|
|
57
|
+
listInstallationProvisionerTokens,
|
|
49
58
|
listUsableProvisionerTokensByWorkspaceId,
|
|
50
59
|
resolveProvisionerTokenByHash,
|
|
60
|
+
revokeInstallationProvisionerTokenWithAudit,
|
|
51
61
|
revokeProvisionerToken,
|
|
52
62
|
touchProvisionerLastSeen,
|
|
53
63
|
} from './provisioner-tokens.js';
|
|
@@ -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 () => {
|