@shipfox/api-runners 13.1.0 → 14.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 +11 -0
- 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/provisioner-tokens.d.ts +8 -0
- package/dist/core/provisioner-tokens.d.ts.map +1 -1
- package/dist/core/provisioner-tokens.js +14 -1
- package/dist/core/provisioner-tokens.js.map +1 -1
- package/dist/presentation/routes/list-active-provisioners.d.ts +4 -0
- package/dist/presentation/routes/list-active-provisioners.d.ts.map +1 -1
- package/dist/presentation/routes/list-active-provisioners.js +36 -24
- package/dist/presentation/routes/list-active-provisioners.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/core/index.ts +2 -0
- package/src/core/provisioner-tokens.test.ts +46 -0
- package/src/core/provisioner-tokens.ts +17 -1
- package/src/presentation/routes/list-active-provisioners.ts +34 -17
- package/src/presentation/routes/provisioner-tokens.test.ts +44 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-runners",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "14.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"@shipfox/api-common-dto": "12.0.0",
|
|
25
25
|
"@shipfox/api-auth-context": "12.2.0",
|
|
26
26
|
"@shipfox/api-auth-dto": "12.0.0",
|
|
27
|
-
"@shipfox/api-runners-dto": "
|
|
28
|
-
"@shipfox/api-workflows-dto": "
|
|
27
|
+
"@shipfox/api-runners-dto": "14.0.0",
|
|
28
|
+
"@shipfox/api-workflows-dto": "14.0.0",
|
|
29
29
|
"@shipfox/config": "1.2.4",
|
|
30
30
|
"@shipfox/inter-module": "0.2.3",
|
|
31
31
|
"@shipfox/node-drizzle": "0.3.5",
|
package/src/core/index.ts
CHANGED
|
@@ -40,7 +40,9 @@ export {
|
|
|
40
40
|
createAdministratorInstallationProvisionerToken,
|
|
41
41
|
createInstallationProvisionerToken,
|
|
42
42
|
createWorkspaceProvisionerToken,
|
|
43
|
+
type InstallationRunnersStatus,
|
|
43
44
|
installationProvisionerTokenStatus,
|
|
45
|
+
installationRunnersStatus,
|
|
44
46
|
listActiveProvisioners,
|
|
45
47
|
listAdministratorInstallationProvisionerTokens,
|
|
46
48
|
listUsableProvisionerTokens,
|
|
@@ -6,6 +6,7 @@ import {provisionerTokenFactory} from '#test/index.js';
|
|
|
6
6
|
import {
|
|
7
7
|
createInstallationProvisionerToken,
|
|
8
8
|
createWorkspaceProvisionerToken,
|
|
9
|
+
installationRunnersStatus,
|
|
9
10
|
listUsableProvisionerTokens,
|
|
10
11
|
revokeInstallationProvisionerToken,
|
|
11
12
|
revokeWorkspaceProvisionerToken,
|
|
@@ -79,3 +80,48 @@ describe('provisioner token core', () => {
|
|
|
79
80
|
await expect(result).rejects.toThrow(`Provisioner token not found: ${token.id}`);
|
|
80
81
|
});
|
|
81
82
|
});
|
|
83
|
+
|
|
84
|
+
describe('installationRunnersStatus', () => {
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
await db().delete(provisionerTokens).where(eq(provisionerTokens.scope, 'installation'));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reports managed when reserved labels are configured, without any token', async () => {
|
|
90
|
+
await expect(installationRunnersStatus(['linux'])).resolves.toBe('managed');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports none without reserved labels and without installation tokens', async () => {
|
|
94
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('reports managed when an installation token is active', async () => {
|
|
98
|
+
await provisionerTokenFactory.create({scope: 'installation'});
|
|
99
|
+
|
|
100
|
+
await expect(installationRunnersStatus([])).resolves.toBe('managed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('reports none when the only installation token has expired', async () => {
|
|
104
|
+
await provisionerTokenFactory.create({
|
|
105
|
+
scope: 'installation',
|
|
106
|
+
expiresAt: new Date(Date.now() - 60_000),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reports none when the only installation token is revoked', async () => {
|
|
113
|
+
const token = await provisionerTokenFactory.create({scope: 'installation'});
|
|
114
|
+
await revokeInstallationProvisionerToken({
|
|
115
|
+
tokenId: token.id,
|
|
116
|
+
revokedByUserId: crypto.randomUUID(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('ignores workspace-scope tokens', async () => {
|
|
123
|
+
await provisionerTokenFactory.create({workspaceId: crypto.randomUUID()});
|
|
124
|
+
|
|
125
|
+
await expect(installationRunnersStatus([])).resolves.toBe('none');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
revokeInstallationProvisionerTokenWithAudit,
|
|
15
15
|
revokeProvisionerToken,
|
|
16
16
|
} from '#db/provisioner-tokens.js';
|
|
17
|
-
import {config} from '../config.js';
|
|
17
|
+
import {config, runnerReservedLabels} from '../config.js';
|
|
18
18
|
import type {ActiveProvisionerToken, ProvisionerToken} from './entities/provisioner-token.js';
|
|
19
19
|
import {
|
|
20
20
|
ProvisionerAdminIdempotencyReplayUnavailableError,
|
|
@@ -167,6 +167,22 @@ export function installationProvisionerTokenStatus(
|
|
|
167
167
|
return 'active';
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
export type InstallationRunnersStatus = 'managed' | 'none';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether the installation itself provides runner capacity. Both signals are
|
|
174
|
+
* declarations of intent, not heartbeats: a quiet hosted fleet must not flip
|
|
175
|
+
* the value. Reserved labels dominate even when every installation token has
|
|
176
|
+
* expired or been revoked.
|
|
177
|
+
*/
|
|
178
|
+
export async function installationRunnersStatus(
|
|
179
|
+
reservedLabels: readonly string[] = runnerReservedLabels,
|
|
180
|
+
): Promise<InstallationRunnersStatus> {
|
|
181
|
+
if (reservedLabels.length > 0) return 'managed';
|
|
182
|
+
const {tokens} = await listInstallationProvisionerTokens({limit: 1, status: 'active'});
|
|
183
|
+
return tokens.length > 0 ? 'managed' : 'none';
|
|
184
|
+
}
|
|
185
|
+
|
|
170
186
|
export async function listAdministratorInstallationProvisionerTokens(params: {
|
|
171
187
|
limit: number;
|
|
172
188
|
cursor?: TimestampIdCursor | undefined;
|
|
@@ -2,24 +2,41 @@ import {requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
|
2
2
|
import {listActiveProvisionersResponseSchema} from '@shipfox/api-runners-dto';
|
|
3
3
|
import {defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
5
|
-
import {listActiveProvisioners} from '#core/index.js';
|
|
5
|
+
import {installationRunnersStatus, listActiveProvisioners} from '#core/index.js';
|
|
6
6
|
import {toActiveProvisionerDto} from '#presentation/dto/index.js';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
type InstallationRunnersStatusReader = () => ReturnType<typeof installationRunnersStatus>;
|
|
9
|
+
|
|
10
|
+
export function createListActiveProvisionersRoute(
|
|
11
|
+
readInstallationRunnersStatus: InstallationRunnersStatusReader = installationRunnersStatus,
|
|
12
|
+
) {
|
|
13
|
+
return defineRoute({
|
|
14
|
+
method: 'GET',
|
|
15
|
+
path: '/',
|
|
16
|
+
description: 'List active provisioners for a workspace',
|
|
17
|
+
schema: {
|
|
18
|
+
params: z.object({workspaceId: z.string().uuid()}),
|
|
19
|
+
response: {
|
|
20
|
+
200: listActiveProvisionersResponseSchema,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
handler: async (request) => {
|
|
24
|
+
const {workspaceId} = request.params;
|
|
25
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
26
|
+
|
|
27
|
+
const [provisioners, installationRunners] = await Promise.all([
|
|
28
|
+
listActiveProvisioners(workspaceId),
|
|
29
|
+
readInstallationRunnersStatus().catch((error: unknown) => {
|
|
30
|
+
request.log.warn({err: error}, 'Could not resolve installation runner status');
|
|
31
|
+
return 'none' as const;
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
return {
|
|
35
|
+
provisioners: provisioners.map(toActiveProvisionerDto),
|
|
36
|
+
installation_runners: installationRunners,
|
|
37
|
+
};
|
|
16
38
|
},
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const {workspaceId} = request.params;
|
|
20
|
-
requireWorkspaceAccess({request, workspaceId});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
21
41
|
|
|
22
|
-
|
|
23
|
-
return {provisioners: provisioners.map(toActiveProvisionerDto)};
|
|
24
|
-
},
|
|
25
|
-
});
|
|
42
|
+
export const listActiveProvisionersRoute = createListActiveProvisionersRoute();
|
|
@@ -15,6 +15,7 @@ import {provisionerTokens} from '#db/schema/provisioner-tokens.js';
|
|
|
15
15
|
import {createProvisionerTokenAuthMethod} from '#presentation/auth/index.js';
|
|
16
16
|
import {provisionerTokenFactory} from '#test/index.js';
|
|
17
17
|
import {provisionerRoutes} from './index.js';
|
|
18
|
+
import {createListActiveProvisionersRoute} from './list-active-provisioners.js';
|
|
18
19
|
|
|
19
20
|
const userId = crypto.randomUUID();
|
|
20
21
|
let authenticatedMemberships: ReadonlyArray<UserContextMembership> = [];
|
|
@@ -141,8 +142,51 @@ describe('provisioner token routes', () => {
|
|
|
141
142
|
last_seen_at: expect.any(String),
|
|
142
143
|
}),
|
|
143
144
|
],
|
|
145
|
+
// The test environment configures RUNNER_RESERVED_LABELS, so the
|
|
146
|
+
// installation always reports managed runner capacity.
|
|
147
|
+
installation_runners: 'managed',
|
|
144
148
|
});
|
|
145
149
|
});
|
|
150
|
+
|
|
151
|
+
it('reports managed installation runners from the reserved labels signal alone', async () => {
|
|
152
|
+
const res = await app.inject({
|
|
153
|
+
method: 'GET',
|
|
154
|
+
url: `/workspaces/${workspaceId}/provisioners/active`,
|
|
155
|
+
headers: {authorization: 'Bearer user'},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(res.statusCode).toBe(200);
|
|
159
|
+
expect(res.json().installation_runners).toBe('managed');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('keeps active provisioners available when installation status is unavailable', async () => {
|
|
163
|
+
await closeApp();
|
|
164
|
+
app = await createApp({
|
|
165
|
+
auth: [fakeUserAuth, createProvisionerTokenAuthMethod()],
|
|
166
|
+
routes: [
|
|
167
|
+
{
|
|
168
|
+
prefix: '/workspaces/:workspaceId/provisioners/active',
|
|
169
|
+
auth: AUTH_USER,
|
|
170
|
+
routes: [
|
|
171
|
+
createListActiveProvisionersRoute(() =>
|
|
172
|
+
Promise.reject(new Error('status lookup unavailable')),
|
|
173
|
+
),
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
swagger: false,
|
|
178
|
+
});
|
|
179
|
+
await app.ready();
|
|
180
|
+
|
|
181
|
+
const res = await app.inject({
|
|
182
|
+
method: 'GET',
|
|
183
|
+
url: `/workspaces/${workspaceId}/provisioners/active`,
|
|
184
|
+
headers: {authorization: 'Bearer user'},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(res.statusCode).toBe(200);
|
|
188
|
+
expect(res.json()).toEqual({provisioners: [], installation_runners: 'none'});
|
|
189
|
+
});
|
|
146
190
|
});
|
|
147
191
|
|
|
148
192
|
describe('POST /workspaces/:workspaceId/provisioners/tokens', () => {
|