@shipfox/api-runners 12.1.0 → 12.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +35 -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 +37 -11
- 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/index.d.ts +1 -1
- package/dist/db/index.d.ts.map +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/index.js.map +1 -1
- package/dist/db/reservations.d.ts.map +1 -1
- package/dist/db/reservations.js +36 -10
- package/dist/db/reservations.js.map +1 -1
- package/dist/db/runner-instances.d.ts +4 -0
- package/dist/db/runner-instances.d.ts.map +1 -1
- package/dist/db/runner-instances.js +16 -5
- 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 +33 -15
- 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/metrics/service.d.ts.map +1 -1
- package/dist/metrics/service.js +20 -4
- package/dist/metrics/service.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 +404 -0
- package/src/core/runner-control-sessions.ts +51 -11
- 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/index.ts +1 -0
- package/src/db/reservations.test.ts +537 -0
- package/src/db/reservations.ts +116 -17
- package/src/db/runner-instances.test.ts +276 -1
- package/src/db/runner-instances.ts +39 -3
- package/src/db/runner-sessions.ts +39 -15
- 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/metrics/service.test.ts +99 -0
- package/src/metrics/service.ts +24 -4
- package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
- package/src/presentation/routes/poll-demand.test.ts +116 -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/test/fixtures/late-runner-enrollment.ts +103 -0
- package/test/index.ts +5 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type {RunnerToolCapabilitiesDto} from '@shipfox/api-runners-dto';
|
|
2
2
|
import {and, asc, eq, gt, inArray, isNull, lt, notExists, or, sql} from 'drizzle-orm';
|
|
3
3
|
import type {RunnerSession} from '#core/entities/runner-session.js';
|
|
4
|
+
import {EmptyRunnerLabelsError} from '#core/errors.js';
|
|
5
|
+
import {sanitizeRunnerLabelsOrThrow} from '#core/runner-labels.js';
|
|
4
6
|
import {db} from './db.js';
|
|
7
|
+
import {provisionerTokens} from './schema/provisioner-tokens.js';
|
|
5
8
|
import {runnerActivationTokens} from './schema/runner-activation-tokens.js';
|
|
6
9
|
import {runnerControlSessions} from './schema/runner-control-sessions.js';
|
|
7
10
|
import {providerRunners} from './schema/runner-instances.js';
|
|
@@ -56,40 +59,61 @@ export async function createRunnerSessionConsumingActivationToken(params: {
|
|
|
56
59
|
toolCapabilities?: RunnerToolCapabilitiesDto | null;
|
|
57
60
|
}) {
|
|
58
61
|
return await db().transaction(async (tx) => {
|
|
59
|
-
const [
|
|
62
|
+
const [runner] = await tx
|
|
60
63
|
.select({
|
|
61
|
-
|
|
62
|
-
runnerInstanceId:
|
|
64
|
+
activationTokenId: runnerActivationTokens.id,
|
|
65
|
+
runnerInstanceId: providerRunners.id,
|
|
63
66
|
workspaceId: providerRunners.workspaceId,
|
|
64
67
|
provisionerId: providerRunners.provisionerId,
|
|
65
68
|
providerRunnerId: providerRunners.providerRunnerId,
|
|
69
|
+
runnerSessionId: providerRunners.runnerSessionId,
|
|
66
70
|
})
|
|
67
71
|
.from(runnerActivationTokens)
|
|
68
72
|
.innerJoin(providerRunners, eq(providerRunners.id, runnerActivationTokens.runnerInstanceId))
|
|
73
|
+
.where(eq(runnerActivationTokens.id, params.activationTokenId))
|
|
74
|
+
.limit(1)
|
|
75
|
+
.for('update', {of: providerRunners});
|
|
76
|
+
if (!runner?.workspaceId || !runner.providerRunnerId || runner.runnerSessionId)
|
|
77
|
+
throw new Error('Runner activation token is invalid, expired, or has already been used');
|
|
78
|
+
|
|
79
|
+
const [activationToken] = await tx
|
|
80
|
+
.select({id: runnerActivationTokens.id})
|
|
81
|
+
.from(runnerActivationTokens)
|
|
69
82
|
.where(
|
|
70
83
|
and(
|
|
71
|
-
eq(runnerActivationTokens.id,
|
|
84
|
+
eq(runnerActivationTokens.id, runner.activationTokenId),
|
|
85
|
+
eq(runnerActivationTokens.runnerInstanceId, runner.runnerInstanceId),
|
|
72
86
|
isNull(runnerActivationTokens.consumedAt),
|
|
73
87
|
isNull(runnerActivationTokens.revokedAt),
|
|
74
88
|
gt(runnerActivationTokens.expiresAt, sql`now()`),
|
|
75
|
-
isNull(providerRunners.runnerSessionId),
|
|
76
89
|
),
|
|
77
90
|
)
|
|
78
91
|
.limit(1)
|
|
79
92
|
.for('update');
|
|
80
|
-
if (!
|
|
93
|
+
if (!activationToken)
|
|
81
94
|
throw new Error('Runner activation token is invalid, expired, or has already been used');
|
|
95
|
+
|
|
96
|
+
const [provisioner] = await tx
|
|
97
|
+
.select({scope: provisionerTokens.scope})
|
|
98
|
+
.from(provisionerTokens)
|
|
99
|
+
.where(eq(provisionerTokens.id, runner.provisionerId))
|
|
100
|
+
.limit(1);
|
|
101
|
+
const labels = sanitizeRunnerLabelsOrThrow(params.labels, {
|
|
102
|
+
scope: provisioner?.scope ?? 'workspace',
|
|
103
|
+
source: 'activation runner registration',
|
|
104
|
+
});
|
|
105
|
+
if (labels.length === 0) throw new EmptyRunnerLabelsError();
|
|
82
106
|
const [session] = await tx
|
|
83
107
|
.insert(runnerSessions)
|
|
84
108
|
.values({
|
|
85
|
-
workspaceId:
|
|
109
|
+
workspaceId: runner.workspaceId,
|
|
86
110
|
scope: 'workspace',
|
|
87
|
-
registrationTokenId:
|
|
111
|
+
registrationTokenId: activationToken.id,
|
|
88
112
|
registrationTokenKind: 'activation',
|
|
89
|
-
runnerInstanceId:
|
|
90
|
-
provisionerId:
|
|
91
|
-
providerRunnerId:
|
|
92
|
-
labels
|
|
113
|
+
runnerInstanceId: runner.runnerInstanceId,
|
|
114
|
+
provisionerId: runner.provisionerId,
|
|
115
|
+
providerRunnerId: runner.providerRunnerId,
|
|
116
|
+
labels,
|
|
93
117
|
toolCapabilities: params.toolCapabilities ?? null,
|
|
94
118
|
toolCapabilitiesReportedAt: params.toolCapabilities ? sql`now()` : null,
|
|
95
119
|
maxClaims: 1,
|
|
@@ -100,13 +124,13 @@ export async function createRunnerSessionConsumingActivationToken(params: {
|
|
|
100
124
|
await tx
|
|
101
125
|
.update(runnerActivationTokens)
|
|
102
126
|
.set({consumedAt: sql`now()`, consumedSessionId: session.id})
|
|
103
|
-
.where(eq(runnerActivationTokens.id,
|
|
127
|
+
.where(eq(runnerActivationTokens.id, activationToken.id));
|
|
104
128
|
await tx
|
|
105
129
|
.update(providerRunners)
|
|
106
130
|
.set({runnerSessionId: session.id, updatedAt: sql`now()`})
|
|
107
131
|
.where(
|
|
108
132
|
and(
|
|
109
|
-
eq(providerRunners.id,
|
|
133
|
+
eq(providerRunners.id, runner.runnerInstanceId),
|
|
110
134
|
isNull(providerRunners.runnerSessionId),
|
|
111
135
|
),
|
|
112
136
|
);
|
|
@@ -115,7 +139,7 @@ export async function createRunnerSessionConsumingActivationToken(params: {
|
|
|
115
139
|
.set({closedAt: sql`now()`, closeReason: 'activated'})
|
|
116
140
|
.where(
|
|
117
141
|
and(
|
|
118
|
-
eq(runnerControlSessions.runnerInstanceId,
|
|
142
|
+
eq(runnerControlSessions.runnerInstanceId, runner.runnerInstanceId),
|
|
119
143
|
isNull(runnerControlSessions.closedAt),
|
|
120
144
|
),
|
|
121
145
|
);
|
|
@@ -60,6 +60,9 @@ export const providerRunners = pgTable(
|
|
|
60
60
|
.where(
|
|
61
61
|
sql`${table.intendedReservationId} is not null and ${table.reservationReleasedAt} is null`,
|
|
62
62
|
),
|
|
63
|
+
index('runners_runner_instances_intended_reservation_idx')
|
|
64
|
+
.on(table.intendedReservationId)
|
|
65
|
+
.where(sql`${table.intendedReservationId} is not null`),
|
|
63
66
|
index('runners_runner_instances_provisioner_reservation_idx')
|
|
64
67
|
.on(table.provisionerId, table.reservationId)
|
|
65
68
|
.where(sql`${table.reservationId} is not null`),
|
package/src/metrics/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type {RunnerReservationPromotionFailureReason} from './instance.js';
|
|
1
2
|
export {
|
|
2
3
|
jobExecutionClaimedCount,
|
|
3
4
|
jobExecutionEnqueuedCount,
|
|
@@ -7,6 +8,7 @@ export {
|
|
|
7
8
|
providerRunnerReconcileCallCount,
|
|
8
9
|
providerRunnerTerminateIntentHonoredCount,
|
|
9
10
|
providerRunnerTerminateIntentIssuedCount,
|
|
11
|
+
recordRunnerReservationPromotionFailure,
|
|
10
12
|
recordRunnersRateLimitCheck,
|
|
11
13
|
recordRunnersRateLimitPruneFailure,
|
|
12
14
|
} from './instance.js';
|
package/src/metrics/instance.ts
CHANGED
|
@@ -83,6 +83,18 @@ export const reservationReleasedCount = meter.createCounter<Record<string, never
|
|
|
83
83
|
{description: 'Reservation units released from terminal provisioned runner reports'},
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
export type RunnerReservationPromotionFailureReason =
|
|
87
|
+
| 'reservation-expired'
|
|
88
|
+
| 'reservation-not-found'
|
|
89
|
+
| 'already-assigned'
|
|
90
|
+
| 'not-assignable';
|
|
91
|
+
|
|
92
|
+
export const runnerReservationPromotionFailureCount = meter.createCounter<{
|
|
93
|
+
reason: RunnerReservationPromotionFailureReason;
|
|
94
|
+
}>('runners_reservation_promotion_failures', {
|
|
95
|
+
description: 'Runner reservation promotion failures during enrollment by reason',
|
|
96
|
+
});
|
|
97
|
+
|
|
86
98
|
export type RunnersRateLimitAction = 'provisioner-mint' | 'ephemeral-register';
|
|
87
99
|
export type RunnersRateLimitScope = 'provisioner' | 'ephemeral-token';
|
|
88
100
|
export type RunnersRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
|
|
@@ -107,6 +119,12 @@ function recordMetric(record: () => void): void {
|
|
|
107
119
|
}
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
export function recordRunnerReservationPromotionFailure(
|
|
123
|
+
reason: RunnerReservationPromotionFailureReason,
|
|
124
|
+
): void {
|
|
125
|
+
recordMetric(() => runnerReservationPromotionFailureCount.add(1, {reason}));
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
export function recordRunnersRateLimitCheck(params: {
|
|
111
129
|
action: RunnersRateLimitAction;
|
|
112
130
|
scope: RunnersRateLimitScope;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const mocks = vi.hoisted(() => {
|
|
2
|
+
const gauges = {
|
|
3
|
+
enrolledRunnersWithoutRecentReport: {},
|
|
4
|
+
pendingJobExecutions: {},
|
|
5
|
+
runningJobExecutions: {},
|
|
6
|
+
};
|
|
7
|
+
const gaugeByName = {
|
|
8
|
+
runners_enrolled_without_recent_report: gauges.enrolledRunnersWithoutRecentReport,
|
|
9
|
+
runners_pending_job_executions: gauges.pendingJobExecutions,
|
|
10
|
+
runners_running_job_executions: gauges.runningJobExecutions,
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
addBatchObservableCallback: vi.fn(),
|
|
14
|
+
countStaleEnrolledRunnerInstances: vi.fn(),
|
|
15
|
+
createObservableGauge: vi.fn((name: string) => gaugeByName[name as keyof typeof gaugeByName]),
|
|
16
|
+
gauges,
|
|
17
|
+
getMeter: vi.fn(),
|
|
18
|
+
getJobExecutionQueueDepth: vi.fn(),
|
|
19
|
+
getServiceMetricsProvider: vi.fn(),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock('@shipfox/node-opentelemetry', () => ({
|
|
24
|
+
getServiceMetricsProvider: mocks.getServiceMetricsProvider,
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('#config.js', () => ({
|
|
27
|
+
config: {RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS: 300},
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('#db/job-executions.js', () => ({
|
|
30
|
+
getJobExecutionQueueDepth: mocks.getJobExecutionQueueDepth,
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('#db/runner-instances.js', () => ({
|
|
33
|
+
countStaleEnrolledRunnerInstances: mocks.countStaleEnrolledRunnerInstances,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
import {registerRunnersServiceMetrics} from './service.js';
|
|
37
|
+
|
|
38
|
+
describe('registerRunnersServiceMetrics', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
mocks.addBatchObservableCallback.mockReset();
|
|
41
|
+
mocks.countStaleEnrolledRunnerInstances.mockReset();
|
|
42
|
+
mocks.createObservableGauge.mockClear();
|
|
43
|
+
mocks.getJobExecutionQueueDepth.mockReset();
|
|
44
|
+
mocks.getMeter.mockReset();
|
|
45
|
+
mocks.getServiceMetricsProvider.mockReset();
|
|
46
|
+
mocks.getJobExecutionQueueDepth.mockResolvedValue({
|
|
47
|
+
pendingJobExecutions: 0,
|
|
48
|
+
runningJobExecutions: 0,
|
|
49
|
+
});
|
|
50
|
+
mocks.getMeter.mockReturnValue({
|
|
51
|
+
createObservableGauge: mocks.createObservableGauge,
|
|
52
|
+
addBatchObservableCallback: mocks.addBatchObservableCallback,
|
|
53
|
+
});
|
|
54
|
+
mocks.getServiceMetricsProvider.mockReturnValue({getMeter: mocks.getMeter});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('observes enrolled runners without recent reports after the grace window', async () => {
|
|
58
|
+
mocks.countStaleEnrolledRunnerInstances.mockResolvedValue(2);
|
|
59
|
+
|
|
60
|
+
registerRunnersServiceMetrics();
|
|
61
|
+
const callback = mocks.addBatchObservableCallback.mock.calls[0]?.[0];
|
|
62
|
+
if (typeof callback !== 'function') throw new Error('Expected metrics callback');
|
|
63
|
+
const observer = {observe: vi.fn()};
|
|
64
|
+
|
|
65
|
+
await callback(observer);
|
|
66
|
+
|
|
67
|
+
expect(mocks.createObservableGauge).toHaveBeenCalledWith(
|
|
68
|
+
'runners_enrolled_without_recent_report',
|
|
69
|
+
{
|
|
70
|
+
description:
|
|
71
|
+
'Running enrolled runners with a live control session, no workspace or runner session, and no recent provisioner report after the stale-runner grace window',
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
expect(mocks.countStaleEnrolledRunnerInstances).toHaveBeenCalledWith({graceSeconds: 300});
|
|
75
|
+
expect(observer.observe).toHaveBeenCalledWith(
|
|
76
|
+
mocks.gauges.enrolledRunnersWithoutRecentReport,
|
|
77
|
+
2,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps queue gauges observable when the enrolled-runner query fails', async () => {
|
|
82
|
+
mocks.getJobExecutionQueueDepth.mockResolvedValue({
|
|
83
|
+
pendingJobExecutions: 3,
|
|
84
|
+
runningJobExecutions: 4,
|
|
85
|
+
});
|
|
86
|
+
mocks.countStaleEnrolledRunnerInstances.mockRejectedValue(new Error('database unavailable'));
|
|
87
|
+
|
|
88
|
+
registerRunnersServiceMetrics();
|
|
89
|
+
const callback = mocks.addBatchObservableCallback.mock.calls[0]?.[0];
|
|
90
|
+
if (typeof callback !== 'function') throw new Error('Expected metrics callback');
|
|
91
|
+
const observer = {observe: vi.fn()};
|
|
92
|
+
|
|
93
|
+
await callback(observer);
|
|
94
|
+
|
|
95
|
+
expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.pendingJobExecutions, 3);
|
|
96
|
+
expect(observer.observe).toHaveBeenCalledWith(mocks.gauges.runningJobExecutions, 4);
|
|
97
|
+
expect(observer.observe).toHaveBeenCalledTimes(2);
|
|
98
|
+
});
|
|
99
|
+
});
|
package/src/metrics/service.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {getServiceMetricsProvider} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import {config} from '#config.js';
|
|
2
3
|
import {getJobExecutionQueueDepth} from '#db/job-executions.js';
|
|
4
|
+
import {countStaleEnrolledRunnerInstances} from '#db/runner-instances.js';
|
|
3
5
|
|
|
4
6
|
export function registerRunnersServiceMetrics(): void {
|
|
5
7
|
const meter = getServiceMetricsProvider().getMeter('runners');
|
|
@@ -10,13 +12,31 @@ export function registerRunnersServiceMetrics(): void {
|
|
|
10
12
|
const runningJobExecutions = meter.createObservableGauge('runners_running_job_executions', {
|
|
11
13
|
description: 'Job executions currently claimed by a runner and in progress',
|
|
12
14
|
});
|
|
15
|
+
const enrolledRunnersWithoutRecentReport = meter.createObservableGauge(
|
|
16
|
+
'runners_enrolled_without_recent_report',
|
|
17
|
+
{
|
|
18
|
+
description:
|
|
19
|
+
'Running enrolled runners with a live control session, no workspace or runner session, and no recent provisioner report after the stale-runner grace window',
|
|
20
|
+
},
|
|
21
|
+
);
|
|
13
22
|
|
|
14
23
|
meter.addBatchObservableCallback(
|
|
15
24
|
async (observer) => {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
25
|
+
const [depthResult, staleEnrolledRunnerCountResult] = await Promise.allSettled([
|
|
26
|
+
getJobExecutionQueueDepth(),
|
|
27
|
+
countStaleEnrolledRunnerInstances({
|
|
28
|
+
graceSeconds: config.RUNNER_STALE_PROVISIONED_RUNNER_THRESHOLD_SECONDS,
|
|
29
|
+
}),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
if (depthResult.status === 'fulfilled') {
|
|
33
|
+
observer.observe(pendingJobExecutions, depthResult.value.pendingJobExecutions);
|
|
34
|
+
observer.observe(runningJobExecutions, depthResult.value.runningJobExecutions);
|
|
35
|
+
}
|
|
36
|
+
if (staleEnrolledRunnerCountResult.status === 'fulfilled') {
|
|
37
|
+
observer.observe(enrolledRunnersWithoutRecentReport, staleEnrolledRunnerCountResult.value);
|
|
38
|
+
}
|
|
19
39
|
},
|
|
20
|
-
[pendingJobExecutions, runningJobExecutions],
|
|
40
|
+
[pendingJobExecutions, runningJobExecutions, enrolledRunnersWithoutRecentReport],
|
|
21
41
|
);
|
|
22
42
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {AUTH_PROVISIONER_TOKEN, AUTH_USER, setProvisionerContext} from '@shipfox/api-auth-context';
|
|
2
|
+
import {
|
|
3
|
+
type AuthMethod,
|
|
4
|
+
ClientError,
|
|
5
|
+
closeApp,
|
|
6
|
+
createApp,
|
|
7
|
+
extractBearerToken,
|
|
8
|
+
} from '@shipfox/node-fastify';
|
|
9
|
+
import {vi} from '@shipfox/vitest/vi';
|
|
10
|
+
import {eq} from 'drizzle-orm';
|
|
11
|
+
import type {FastifyInstance, FastifyRequest} from 'fastify';
|
|
12
|
+
import {db} from '#db/db.js';
|
|
13
|
+
import {providerRunners} from '#db/schema/runner-instances.js';
|
|
14
|
+
import {runnerSessions} from '#db/schema/runner-sessions.js';
|
|
15
|
+
import {runnerReservationPromotionFailureCount} from '#metrics/instance.js';
|
|
16
|
+
import {
|
|
17
|
+
createRunnerControlSessionAuthMethod,
|
|
18
|
+
createRunnerRegistrationTokenAuthMethod,
|
|
19
|
+
} from '#presentation/auth/index.js';
|
|
20
|
+
import {
|
|
21
|
+
arrangeDeletedRunnerEnrollment,
|
|
22
|
+
arrangeExpiredRunnerEnrollment,
|
|
23
|
+
fakeLeaseTokenAuthMethod,
|
|
24
|
+
fakeRunnerSessionAuthMethod,
|
|
25
|
+
pendingJobFactory,
|
|
26
|
+
provisionerTokenFactory,
|
|
27
|
+
runnersTestAuthClient,
|
|
28
|
+
} from '#test/index.js';
|
|
29
|
+
import {createRunnerRoutes} from './index.js';
|
|
30
|
+
|
|
31
|
+
let workspaceToken: string;
|
|
32
|
+
const fakeUserAuth: AuthMethod = {name: AUTH_USER, authenticate: () => Promise.resolve()};
|
|
33
|
+
|
|
34
|
+
describe('late runner enrollment recovery', () => {
|
|
35
|
+
let app: FastifyInstance;
|
|
36
|
+
let provisionerId: string;
|
|
37
|
+
let workspaceId: string;
|
|
38
|
+
|
|
39
|
+
const provisionerAuth: AuthMethod = {
|
|
40
|
+
name: AUTH_PROVISIONER_TOKEN,
|
|
41
|
+
authenticate: (request: FastifyRequest) => {
|
|
42
|
+
if (extractBearerToken(request.headers.authorization) !== workspaceToken)
|
|
43
|
+
throw new ClientError('Invalid provisioner token', 'unauthorized', {status: 401});
|
|
44
|
+
setProvisionerContext(request, {
|
|
45
|
+
scope: 'workspace',
|
|
46
|
+
workspaceId,
|
|
47
|
+
provisionerTokenId: provisionerId,
|
|
48
|
+
});
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
app = await createApp({
|
|
55
|
+
auth: [
|
|
56
|
+
fakeUserAuth,
|
|
57
|
+
provisionerAuth,
|
|
58
|
+
createRunnerRegistrationTokenAuthMethod(),
|
|
59
|
+
createRunnerControlSessionAuthMethod(),
|
|
60
|
+
fakeRunnerSessionAuthMethod,
|
|
61
|
+
fakeLeaseTokenAuthMethod,
|
|
62
|
+
],
|
|
63
|
+
routes: createRunnerRoutes(runnersTestAuthClient),
|
|
64
|
+
swagger: false,
|
|
65
|
+
});
|
|
66
|
+
await app.ready();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterAll(async () => {
|
|
70
|
+
await closeApp();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
workspaceId = crypto.randomUUID();
|
|
75
|
+
workspaceToken = `late-runner-workspace-provisioner-${crypto.randomUUID()}`;
|
|
76
|
+
const provisioner = await provisionerTokenFactory.create(
|
|
77
|
+
{scope: 'workspace', workspaceId},
|
|
78
|
+
{transient: {rawToken: workspaceToken}},
|
|
79
|
+
);
|
|
80
|
+
provisionerId = provisioner.id;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps an enrolled runner recoverable when its reservation expires first', async () => {
|
|
84
|
+
const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
|
|
85
|
+
const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await enrollRunner(arrangement.controlSessionToken);
|
|
89
|
+
|
|
90
|
+
expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-expired'});
|
|
91
|
+
} finally {
|
|
92
|
+
failureSpy.mockRestore();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const [runner] = await db()
|
|
96
|
+
.select()
|
|
97
|
+
.from(providerRunners)
|
|
98
|
+
.where(eq(providerRunners.id, arrangement.runnerInstanceId));
|
|
99
|
+
expect(runner).toMatchObject({
|
|
100
|
+
workspaceId: null,
|
|
101
|
+
reservationId: null,
|
|
102
|
+
intendedReservationId: arrangement.reservationId,
|
|
103
|
+
assignedAt: null,
|
|
104
|
+
runnerSessionId: null,
|
|
105
|
+
state: 'running',
|
|
106
|
+
providerRunnerId: expect.any(String),
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('keeps an enrolled runner recoverable when its reservation was swept away', async () => {
|
|
111
|
+
const arrangement = await arrangeDeletedRunnerEnrollment({provisionerId, workspaceId});
|
|
112
|
+
const failureSpy = vi.spyOn(runnerReservationPromotionFailureCount, 'add');
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await enrollRunner(arrangement.controlSessionToken);
|
|
116
|
+
|
|
117
|
+
expect(failureSpy).toHaveBeenCalledWith(1, {reason: 'reservation-not-found'});
|
|
118
|
+
} finally {
|
|
119
|
+
failureSpy.mockRestore();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const [runner] = await db()
|
|
123
|
+
.select()
|
|
124
|
+
.from(providerRunners)
|
|
125
|
+
.where(eq(providerRunners.id, arrangement.runnerInstanceId));
|
|
126
|
+
expect(runner).toMatchObject({
|
|
127
|
+
workspaceId: null,
|
|
128
|
+
reservationId: null,
|
|
129
|
+
intendedReservationId: arrangement.reservationId,
|
|
130
|
+
assignedAt: null,
|
|
131
|
+
runnerSessionId: null,
|
|
132
|
+
state: 'running',
|
|
133
|
+
providerRunnerId: expect.any(String),
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rebinds a stranded runner through activation and claims its pending job', async () => {
|
|
138
|
+
const arrangement = await arrangeExpiredRunnerEnrollment({provisionerId, workspaceId});
|
|
139
|
+
await enrollRunner(arrangement.controlSessionToken);
|
|
140
|
+
const pendingJob = await pendingJobFactory.create({workspaceId, requiredLabels: ['linux']});
|
|
141
|
+
|
|
142
|
+
const demand = await app.inject({
|
|
143
|
+
method: 'POST',
|
|
144
|
+
url: '/provisioners/demand/poll',
|
|
145
|
+
headers: {authorization: `Bearer ${workspaceToken}`},
|
|
146
|
+
payload: {
|
|
147
|
+
wait_seconds: 0,
|
|
148
|
+
max_reservations: 1,
|
|
149
|
+
reservation_ttl_seconds: 60,
|
|
150
|
+
templates: [
|
|
151
|
+
{
|
|
152
|
+
template_key: 'linux',
|
|
153
|
+
labels: ['linux'],
|
|
154
|
+
available_slots: 1,
|
|
155
|
+
starting: 0,
|
|
156
|
+
running: 1,
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(demand.statusCode).toBe(200);
|
|
163
|
+
const reboundReservationId = demand.json().reservations[0]?.reservation_id;
|
|
164
|
+
expect(reboundReservationId).toEqual(expect.any(String));
|
|
165
|
+
|
|
166
|
+
const [reboundRunner] = await db()
|
|
167
|
+
.select()
|
|
168
|
+
.from(providerRunners)
|
|
169
|
+
.where(eq(providerRunners.id, arrangement.runnerInstanceId));
|
|
170
|
+
expect(reboundRunner).toMatchObject({
|
|
171
|
+
workspaceId,
|
|
172
|
+
reservationId: reboundReservationId,
|
|
173
|
+
intendedReservationId: null,
|
|
174
|
+
assignedAt: expect.any(Date),
|
|
175
|
+
runnerSessionId: null,
|
|
176
|
+
state: 'running',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const assignment = await app.inject({
|
|
180
|
+
method: 'GET',
|
|
181
|
+
url: '/runner-control/assignment?wait_seconds=1',
|
|
182
|
+
headers: {authorization: `Bearer ${arrangement.controlSessionToken}`},
|
|
183
|
+
});
|
|
184
|
+
expect(assignment.statusCode).toBe(200);
|
|
185
|
+
const activationToken = assignment.json().activation_token;
|
|
186
|
+
expect(activationToken).toEqual(expect.any(String));
|
|
187
|
+
|
|
188
|
+
const registered = await app.inject({
|
|
189
|
+
method: 'POST',
|
|
190
|
+
url: '/runners/register',
|
|
191
|
+
headers: {authorization: `Bearer ${activationToken}`},
|
|
192
|
+
payload: {labels: ['linux']},
|
|
193
|
+
});
|
|
194
|
+
expect(registered.statusCode).toBe(200);
|
|
195
|
+
expect(registered.json()).toMatchObject({mode: 'activation', max_claims: 1});
|
|
196
|
+
|
|
197
|
+
const claimed = await app.inject({
|
|
198
|
+
method: 'POST',
|
|
199
|
+
url: '/runners/jobs/request',
|
|
200
|
+
headers: {authorization: `Bearer ${registered.json().session_token}`},
|
|
201
|
+
});
|
|
202
|
+
expect(claimed.statusCode).toBe(200);
|
|
203
|
+
expect(claimed.json()).toMatchObject({job_id: pendingJob.jobId});
|
|
204
|
+
|
|
205
|
+
const [session] = await db()
|
|
206
|
+
.select()
|
|
207
|
+
.from(runnerSessions)
|
|
208
|
+
.where(eq(runnerSessions.id, registered.json().session_id));
|
|
209
|
+
expect(session).toMatchObject({
|
|
210
|
+
registrationTokenKind: 'activation',
|
|
211
|
+
runnerInstanceId: arrangement.runnerInstanceId,
|
|
212
|
+
provisionerId,
|
|
213
|
+
providerRunnerId: reboundRunner?.providerRunnerId,
|
|
214
|
+
maxClaims: 1,
|
|
215
|
+
claimsUsed: 1,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
async function enrollRunner(controlSessionToken: string): Promise<void> {
|
|
220
|
+
const enrolled = await app.inject({
|
|
221
|
+
method: 'POST',
|
|
222
|
+
url: '/runner-control/enrollment',
|
|
223
|
+
headers: {authorization: `Bearer ${controlSessionToken}`},
|
|
224
|
+
payload: {labels: ['linux'], provider_kind: 'docker', protocol_version: '1'},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(enrolled.statusCode).toBe(200);
|
|
228
|
+
expect(enrolled.json()).toEqual({activation_token: null});
|
|
229
|
+
}
|
|
230
|
+
});
|