@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.
Files changed (129) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +35 -0
  3. package/dist/config.d.ts +3 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +24 -2
  6. package/dist/config.js.map +1 -1
  7. package/dist/core/errors.d.ts +4 -0
  8. package/dist/core/errors.d.ts.map +1 -1
  9. package/dist/core/errors.js +6 -0
  10. package/dist/core/errors.js.map +1 -1
  11. package/dist/core/index.d.ts +1 -1
  12. package/dist/core/index.d.ts.map +1 -1
  13. package/dist/core/index.js +1 -1
  14. package/dist/core/index.js.map +1 -1
  15. package/dist/core/rate-limit.d.ts.map +1 -1
  16. package/dist/core/runner-control-sessions.d.ts.map +1 -1
  17. package/dist/core/runner-control-sessions.js +37 -11
  18. package/dist/core/runner-control-sessions.js.map +1 -1
  19. package/dist/core/runner-instances.d.ts +1 -0
  20. package/dist/core/runner-instances.d.ts.map +1 -1
  21. package/dist/core/runner-instances.js.map +1 -1
  22. package/dist/core/runner-labels.d.ts +11 -0
  23. package/dist/core/runner-labels.d.ts.map +1 -0
  24. package/dist/core/runner-labels.js +41 -0
  25. package/dist/core/runner-labels.js.map +1 -0
  26. package/dist/core/runner-sessions.d.ts.map +1 -1
  27. package/dist/core/runner-sessions.js +5 -1
  28. package/dist/core/runner-sessions.js.map +1 -1
  29. package/dist/db/db.d.ts +64 -64
  30. package/dist/db/ephemeral-registration-tokens.d.ts.map +1 -1
  31. package/dist/db/ephemeral-registration-tokens.js +12 -2
  32. package/dist/db/ephemeral-registration-tokens.js.map +1 -1
  33. package/dist/db/index.d.ts +1 -1
  34. package/dist/db/index.d.ts.map +1 -1
  35. package/dist/db/index.js +1 -1
  36. package/dist/db/index.js.map +1 -1
  37. package/dist/db/reservations.d.ts.map +1 -1
  38. package/dist/db/reservations.js +36 -10
  39. package/dist/db/reservations.js.map +1 -1
  40. package/dist/db/runner-instances.d.ts +4 -0
  41. package/dist/db/runner-instances.d.ts.map +1 -1
  42. package/dist/db/runner-instances.js +16 -5
  43. package/dist/db/runner-instances.js.map +1 -1
  44. package/dist/db/runner-sessions.d.ts.map +1 -1
  45. package/dist/db/runner-sessions.js +33 -15
  46. package/dist/db/runner-sessions.js.map +1 -1
  47. package/dist/db/schema/admin-command-results.d.ts +1 -1
  48. package/dist/db/schema/ephemeral-registration-tokens.d.ts +1 -1
  49. package/dist/db/schema/manual-registration-tokens.d.ts +1 -1
  50. package/dist/db/schema/outbox.d.ts +1 -1
  51. package/dist/db/schema/pending-job-executions.d.ts +3 -3
  52. package/dist/db/schema/provisioner-capability-snapshots.d.ts +3 -3
  53. package/dist/db/schema/provisioner-tokens.d.ts +2 -2
  54. package/dist/db/schema/rate-limits.d.ts +1 -1
  55. package/dist/db/schema/reservations.d.ts +3 -3
  56. package/dist/db/schema/runner-activation-tokens.d.ts +1 -1
  57. package/dist/db/schema/runner-control-sessions.d.ts +2 -2
  58. package/dist/db/schema/runner-instances.d.ts +4 -4
  59. package/dist/db/schema/runner-instances.d.ts.map +1 -1
  60. package/dist/db/schema/runner-instances.js +1 -0
  61. package/dist/db/schema/runner-instances.js.map +1 -1
  62. package/dist/db/schema/runner-sessions.d.ts +4 -4
  63. package/dist/db/schema/running-job-executions.d.ts +5 -5
  64. package/dist/metrics/index.d.ts +2 -1
  65. package/dist/metrics/index.d.ts.map +1 -1
  66. package/dist/metrics/index.js +1 -1
  67. package/dist/metrics/index.js.map +1 -1
  68. package/dist/metrics/instance.d.ts +13 -8
  69. package/dist/metrics/instance.d.ts.map +1 -1
  70. package/dist/metrics/instance.js +8 -0
  71. package/dist/metrics/instance.js.map +1 -1
  72. package/dist/metrics/service.d.ts.map +1 -1
  73. package/dist/metrics/service.js +20 -4
  74. package/dist/metrics/service.js.map +1 -1
  75. package/dist/presentation/dto/admin-provisioner-token.d.ts +1 -1
  76. package/dist/presentation/dto/admin-provisioner-token.d.ts.map +1 -1
  77. package/dist/presentation/dto/admin-runner-instances.d.ts +2 -2
  78. package/dist/presentation/dto/admin-runner-instances.d.ts.map +1 -1
  79. package/dist/presentation/routes/poll-demand.d.ts.map +1 -1
  80. package/dist/presentation/routes/poll-demand.js +12 -6
  81. package/dist/presentation/routes/poll-demand.js.map +1 -1
  82. package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
  83. package/dist/presentation/routes/register.d.ts.map +1 -1
  84. package/dist/presentation/routes/register.js +9 -1
  85. package/dist/presentation/routes/register.js.map +1 -1
  86. package/dist/presentation/routes/report-runner-instances.d.ts.map +1 -1
  87. package/dist/presentation/routes/report-runner-instances.js +1 -0
  88. package/dist/presentation/routes/report-runner-instances.js.map +1 -1
  89. package/dist/tsconfig.test.tsbuildinfo +1 -1
  90. package/drizzle/0004_huge_skreet.sql +1 -0
  91. package/drizzle/meta/0004_snapshot.json +2194 -0
  92. package/drizzle/meta/_journal.json +7 -0
  93. package/package.json +9 -9
  94. package/src/config.test.ts +81 -0
  95. package/src/config.ts +39 -2
  96. package/src/core/errors.ts +9 -0
  97. package/src/core/index.ts +1 -0
  98. package/src/core/runner-control-sessions.test.ts +404 -0
  99. package/src/core/runner-control-sessions.ts +51 -11
  100. package/src/core/runner-instances.ts +1 -0
  101. package/src/core/runner-labels.test.ts +53 -0
  102. package/src/core/runner-labels.ts +51 -0
  103. package/src/core/runner-sessions.test.ts +34 -1
  104. package/src/core/runner-sessions.ts +8 -1
  105. package/src/db/ephemeral-registration-tokens.ts +15 -1
  106. package/src/db/index.ts +1 -0
  107. package/src/db/reservations.test.ts +537 -0
  108. package/src/db/reservations.ts +116 -17
  109. package/src/db/runner-instances.test.ts +276 -1
  110. package/src/db/runner-instances.ts +39 -3
  111. package/src/db/runner-sessions.ts +39 -15
  112. package/src/db/schema/runner-instances.ts +3 -0
  113. package/src/metrics/index.ts +2 -0
  114. package/src/metrics/instance.ts +18 -0
  115. package/src/metrics/service.test.ts +99 -0
  116. package/src/metrics/service.ts +24 -4
  117. package/src/presentation/routes/late-runner-enrollment.test.ts +230 -0
  118. package/src/presentation/routes/poll-demand.test.ts +116 -3
  119. package/src/presentation/routes/poll-demand.ts +18 -6
  120. package/src/presentation/routes/register.test.ts +116 -2
  121. package/src/presentation/routes/register.ts +7 -0
  122. package/src/presentation/routes/report-runner-instances.test.ts +33 -0
  123. package/src/presentation/routes/report-runner-instances.ts +1 -0
  124. package/src/presentation/routes/runner-enrollment.test.ts +65 -5
  125. package/src/temporal/activities/maintenance-activities.test.ts +24 -31
  126. package/test/env.ts +1 -0
  127. package/test/fixtures/late-runner-enrollment.ts +103 -0
  128. package/test/index.ts +5 -0
  129. 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 [token] = await tx
62
+ const [runner] = await tx
60
63
  .select({
61
- id: runnerActivationTokens.id,
62
- runnerInstanceId: runnerActivationTokens.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, params.activationTokenId),
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 (!token?.workspaceId || !token.providerRunnerId)
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: token.workspaceId,
109
+ workspaceId: runner.workspaceId,
86
110
  scope: 'workspace',
87
- registrationTokenId: token.id,
111
+ registrationTokenId: activationToken.id,
88
112
  registrationTokenKind: 'activation',
89
- runnerInstanceId: token.runnerInstanceId,
90
- provisionerId: token.provisionerId,
91
- providerRunnerId: token.providerRunnerId,
92
- labels: params.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, token.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, token.runnerInstanceId),
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, token.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`),
@@ -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';
@@ -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
+ });
@@ -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 depth = await getJobExecutionQueueDepth();
17
- observer.observe(pendingJobExecutions, depth.pendingJobExecutions);
18
- observer.observe(runningJobExecutions, depth.runningJobExecutions);
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
+ });