@app-vitals/agent-admin 0.13.1 → 0.14.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/package.json +2 -2
- package/src/admin-boot-config.ts +4 -0
- package/src/agent-account-assignment.ts +4 -0
- package/src/agent-image-map.ts +74 -0
- package/src/agent-image-map.unit.test.ts +68 -0
- package/src/agent-provisioner.integration.test.ts +111 -0
- package/src/agent-provisioner.ts +32 -2
- package/src/agents-api.smoke.test.ts +13 -2
- package/src/agents-api.ts +1 -0
- package/src/agents.ts +1 -0
- package/src/main.unit.test.ts +29 -0
- package/src/startup-policy-reconcile.ts +1 -0
- package/src/startup-policy-reconcile.unit.test.ts +17 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.
|
|
46
|
+
"@app-vitals/agent-lib": "0.14.0",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
package/src/admin-boot-config.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
14
14
|
import { AgentAnthropicIdentityService } from "./agent-anthropic-identity.ts";
|
|
15
|
+
import { parseAgentImageMap } from "./agent-image-map.ts";
|
|
15
16
|
import type { AgentAnthropicFederation } from "./agent-manifest.ts";
|
|
16
17
|
import type {
|
|
17
18
|
AgentProvisioner,
|
|
@@ -120,6 +121,8 @@ export function buildProvisioner(
|
|
|
120
121
|
const namespace = env.SHIPWRIGHT_K8S_NAMESPACE ?? "default";
|
|
121
122
|
const image = env.SHIPWRIGHT_AGENT_IMAGE ?? "";
|
|
122
123
|
const imageTag = env.SHIPWRIGHT_AGENT_IMAGE_TAG ?? "latest";
|
|
124
|
+
// Malformed → throws here, so admin boot fails rather than falling back.
|
|
125
|
+
const images = parseAgentImageMap(env.SHIPWRIGHT_AGENT_IMAGES);
|
|
123
126
|
const apiUrl = env.SHIPWRIGHT_API_URL ?? "";
|
|
124
127
|
const replicasRaw = env.SHIPWRIGHT_AGENT_REPLICAS;
|
|
125
128
|
const replicas = replicasRaw ? Number(replicasRaw) : undefined;
|
|
@@ -201,6 +204,7 @@ export function buildProvisioner(
|
|
|
201
204
|
namespace,
|
|
202
205
|
image,
|
|
203
206
|
imageTag,
|
|
207
|
+
...(images ? { images } : {}),
|
|
204
208
|
apiUrl,
|
|
205
209
|
...(replicas !== undefined && Number.isFinite(replicas)
|
|
206
210
|
? { replicas }
|
|
@@ -21,6 +21,7 @@ type ReconcilableAgent = Partial<AgentAnthropicIdentity> & {
|
|
|
21
21
|
selfHosted: boolean;
|
|
22
22
|
accountId?: string | null;
|
|
23
23
|
podServiceAccountName?: string | null;
|
|
24
|
+
specialty?: string;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export function reconcileAgentAfterAccountChange(
|
|
@@ -60,6 +61,9 @@ async function reconcileAgentWorkload(
|
|
|
60
61
|
{
|
|
61
62
|
id: agent.id,
|
|
62
63
|
slug: agent.name,
|
|
64
|
+
...(agent.specialty !== undefined
|
|
65
|
+
? { specialty: agent.specialty }
|
|
66
|
+
: {}),
|
|
63
67
|
accountId: agent.accountId ?? null,
|
|
64
68
|
ownerEmail: agent.ownerEmail,
|
|
65
69
|
anthropicIdentityName: agent.anthropicIdentityName,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/agent-image-map.ts
|
|
3
|
+
*
|
|
4
|
+
* Per-specialty agent image resolution (SPI-1.1). SHIPWRIGHT_AGENT_IMAGES is an
|
|
5
|
+
* optional JSON object keyed by specialty whose values are `{repository, tag}`.
|
|
6
|
+
* An agent whose specialty is unmapped (or has no specialty) runs the
|
|
7
|
+
* single-image default (SHIPWRIGHT_AGENT_IMAGE / _TAG); a `default` key in the
|
|
8
|
+
* map overrides that default. A malformed map throws, so admin boot fails
|
|
9
|
+
* instead of silently falling back to the wrong image.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface AgentImageRef {
|
|
13
|
+
repository: string;
|
|
14
|
+
tag: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AgentImageMap = Readonly<Record<string, AgentImageRef>>;
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_IMAGE_KEY = "default";
|
|
20
|
+
|
|
21
|
+
function fail(reason: string): never {
|
|
22
|
+
throw new Error(`SHIPWRIGHT_AGENT_IMAGES is malformed: ${reason}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse SHIPWRIGHT_AGENT_IMAGES. Unset/blank → undefined (single-image mode). */
|
|
26
|
+
export function parseAgentImageMap(
|
|
27
|
+
raw: string | undefined,
|
|
28
|
+
): AgentImageMap | undefined {
|
|
29
|
+
if (raw === undefined || raw.trim() === "") return undefined;
|
|
30
|
+
let parsed: unknown;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(raw);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
35
|
+
}
|
|
36
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
37
|
+
return fail("expected a JSON object keyed by specialty");
|
|
38
|
+
}
|
|
39
|
+
const map: Record<string, AgentImageRef> = {};
|
|
40
|
+
for (const [specialty, value] of Object.entries(parsed)) {
|
|
41
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
42
|
+
return fail(`"${specialty}" must be an object {repository, tag}`);
|
|
43
|
+
}
|
|
44
|
+
const { repository, tag } = value as Record<string, unknown>;
|
|
45
|
+
if (typeof repository !== "string" || repository.trim() === "") {
|
|
46
|
+
return fail(`"${specialty}".repository must be a non-empty string`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof tag !== "string" || tag.trim() === "") {
|
|
49
|
+
return fail(`"${specialty}".tag must be a non-empty string`);
|
|
50
|
+
}
|
|
51
|
+
map[specialty] = { repository, tag };
|
|
52
|
+
}
|
|
53
|
+
return Object.keys(map).length > 0 ? map : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The image an agent of `specialty` runs: its own map entry, else the map's
|
|
58
|
+
* `default` entry, else the single-image fallback.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveImage(
|
|
61
|
+
fallback: AgentImageRef,
|
|
62
|
+
images: AgentImageMap | undefined,
|
|
63
|
+
specialty?: string | null,
|
|
64
|
+
): AgentImageRef {
|
|
65
|
+
if (!images) return fallback;
|
|
66
|
+
const own =
|
|
67
|
+
specialty && Object.hasOwn(images, specialty)
|
|
68
|
+
? images[specialty]
|
|
69
|
+
: undefined;
|
|
70
|
+
if (own) return own;
|
|
71
|
+
return Object.hasOwn(images, DEFAULT_IMAGE_KEY)
|
|
72
|
+
? (images[DEFAULT_IMAGE_KEY] as AgentImageRef)
|
|
73
|
+
: fallback;
|
|
74
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseAgentImageMap, resolveImage } from "./agent-image-map.ts";
|
|
3
|
+
|
|
4
|
+
const fallback = { repository: "ghcr.io/x/agent", tag: "1.0.0" };
|
|
5
|
+
const json = JSON.stringify({
|
|
6
|
+
reviewer: { repository: "ghcr.io/x/reviewer", tag: "2.0.0" },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("parseAgentImageMap", () => {
|
|
10
|
+
it("returns undefined when unset, empty or blank", () => {
|
|
11
|
+
expect(parseAgentImageMap(undefined)).toBeUndefined();
|
|
12
|
+
expect(parseAgentImageMap("")).toBeUndefined();
|
|
13
|
+
expect(parseAgentImageMap(" ")).toBeUndefined();
|
|
14
|
+
expect(parseAgentImageMap("{}")).toBeUndefined();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("parses a valid map", () => {
|
|
18
|
+
expect(parseAgentImageMap(json)).toEqual({
|
|
19
|
+
reviewer: { repository: "ghcr.io/x/reviewer", tag: "2.0.0" },
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it.each([
|
|
24
|
+
["invalid JSON", "{nope"],
|
|
25
|
+
["an array", "[]"],
|
|
26
|
+
["null", "null"],
|
|
27
|
+
["a string", '"x"'],
|
|
28
|
+
["a non-object entry", '{"a":"img:1"}'],
|
|
29
|
+
["a null entry", '{"a":null}'],
|
|
30
|
+
["a missing tag", '{"a":{"repository":"r"}}'],
|
|
31
|
+
["an empty repository", '{"a":{"repository":"","tag":"t"}}'],
|
|
32
|
+
["a non-string tag", '{"a":{"repository":"r","tag":1}}'],
|
|
33
|
+
])("throws on %s", (_name, raw) => {
|
|
34
|
+
expect(() => parseAgentImageMap(raw)).toThrow(/SHIPWRIGHT_AGENT_IMAGES/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("resolveImage", () => {
|
|
39
|
+
it("uses the fallback when no map is set", () => {
|
|
40
|
+
expect(resolveImage(fallback, undefined, "reviewer")).toEqual(fallback);
|
|
41
|
+
expect(resolveImage(fallback, undefined)).toEqual(fallback);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("uses the mapped entry for a mapped specialty", () => {
|
|
45
|
+
const map = parseAgentImageMap(json);
|
|
46
|
+
expect(resolveImage(fallback, map, "reviewer")).toEqual({
|
|
47
|
+
repository: "ghcr.io/x/reviewer",
|
|
48
|
+
tag: "2.0.0",
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("falls back for an unmapped or absent specialty", () => {
|
|
53
|
+
const map = parseAgentImageMap(json);
|
|
54
|
+
expect(resolveImage(fallback, map, "other")).toEqual(fallback);
|
|
55
|
+
expect(resolveImage(fallback, map, undefined)).toEqual(fallback);
|
|
56
|
+
expect(resolveImage(fallback, map, "toString")).toEqual(fallback);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("uses the default key for unmapped specialties", () => {
|
|
60
|
+
const map = parseAgentImageMap(
|
|
61
|
+
JSON.stringify({ default: { repository: "d", tag: "9" } }),
|
|
62
|
+
);
|
|
63
|
+
expect(resolveImage(fallback, map, "other")).toEqual({
|
|
64
|
+
repository: "d",
|
|
65
|
+
tag: "9",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1243,3 +1243,114 @@ describe("KubernetesAgentProvisioner.reconcile() — anthropic federation (casse
|
|
|
1243
1243
|
expect(patches).toEqual([]);
|
|
1244
1244
|
});
|
|
1245
1245
|
});
|
|
1246
|
+
|
|
1247
|
+
// ─── Per-specialty images (SPI-1.1) ─────────────────────────────────────────
|
|
1248
|
+
// Not DB-backed: a stub token service stands in for the minting path, and the
|
|
1249
|
+
// in-memory RecordedKubernetesClient for the cluster. The single-image tests
|
|
1250
|
+
// above are the unset-map regression guard.
|
|
1251
|
+
|
|
1252
|
+
describe("KubernetesAgentProvisioner — per-specialty images", () => {
|
|
1253
|
+
const stubTokens = {
|
|
1254
|
+
create: async () => ({ rawToken: "raw-token" }),
|
|
1255
|
+
} as unknown as AgentTokenService;
|
|
1256
|
+
const REVIEWER = "ghcr.io/app-vitals/shipwright-agent-reviewer:v2.0.0";
|
|
1257
|
+
const SINGLE = `${CONFIG.image}:${CONFIG.imageTag}`;
|
|
1258
|
+
const MAPPED: KubernetesAgentProvisionerConfig = {
|
|
1259
|
+
...CONFIG,
|
|
1260
|
+
images: {
|
|
1261
|
+
reviewer: {
|
|
1262
|
+
repository: "ghcr.io/app-vitals/shipwright-agent-reviewer",
|
|
1263
|
+
tag: "v2.0.0",
|
|
1264
|
+
},
|
|
1265
|
+
},
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
async function imageOf(
|
|
1269
|
+
k8s: RecordedKubernetesClient,
|
|
1270
|
+
agentId: string,
|
|
1271
|
+
): Promise<string | undefined> {
|
|
1272
|
+
const dep = await k8s.getDeployment(NAMESPACE, sanitizeAgentName(agentId));
|
|
1273
|
+
return dep.spec.template.spec.containers[0]?.image;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
it("provision() applies the mapped image for a mapped specialty and the single image otherwise", async () => {
|
|
1277
|
+
const k8s = emptyClient();
|
|
1278
|
+
const provisioner = new KubernetesAgentProvisioner(k8s, stubTokens, MAPPED);
|
|
1279
|
+
|
|
1280
|
+
await provisioner.provision("agent-rev", { specialty: "reviewer" });
|
|
1281
|
+
await provisioner.provision("agent-dev", { specialty: "developer" });
|
|
1282
|
+
await provisioner.provision("agent-none");
|
|
1283
|
+
|
|
1284
|
+
expect(await imageOf(k8s, "agent-rev")).toBe(REVIEWER);
|
|
1285
|
+
expect(await imageOf(k8s, "agent-dev")).toBe(SINGLE);
|
|
1286
|
+
expect(await imageOf(k8s, "agent-none")).toBe(SINGLE);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
it("provision() ignores specialty when no map is configured", async () => {
|
|
1290
|
+
const k8s = emptyClient();
|
|
1291
|
+
const provisioner = new KubernetesAgentProvisioner(k8s, stubTokens, CONFIG);
|
|
1292
|
+
|
|
1293
|
+
await provisioner.provision("agent-rev", { specialty: "reviewer" });
|
|
1294
|
+
|
|
1295
|
+
expect(await imageOf(k8s, "agent-rev")).toBe(SINGLE);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("reconcile() patches only the Deployments whose resolved image differs", async () => {
|
|
1299
|
+
const k8s = emptyClient();
|
|
1300
|
+
const patched: string[] = [];
|
|
1301
|
+
const spy: KubernetesClient = {
|
|
1302
|
+
createSecret: (ns, s) => k8s.createSecret(ns, s),
|
|
1303
|
+
createDeploymentManifest: (ns, d) => k8s.createDeploymentManifest(ns, d),
|
|
1304
|
+
createPvc: (ns, p) => k8s.createPvc(ns, p),
|
|
1305
|
+
getSecret: (ns, n) => k8s.getSecret(ns, n),
|
|
1306
|
+
getDeployment: (ns, n) => k8s.getDeployment(ns, n),
|
|
1307
|
+
listDeployments: (ns, l) => k8s.listDeployments(ns, l),
|
|
1308
|
+
deleteDeployment: (ns, n) => k8s.deleteDeployment(ns, n),
|
|
1309
|
+
deleteSecret: (ns, n) => k8s.deleteSecret(ns, n),
|
|
1310
|
+
deletePvc: (ns, n) => k8s.deletePvc(ns, n),
|
|
1311
|
+
patchDeployment: (ns, n, p) => {
|
|
1312
|
+
patched.push(n);
|
|
1313
|
+
return k8s.patchDeployment(ns, n, p);
|
|
1314
|
+
},
|
|
1315
|
+
} as KubernetesClient;
|
|
1316
|
+
|
|
1317
|
+
// Both agents were provisioned under the single-image config.
|
|
1318
|
+
const before = new KubernetesAgentProvisioner(spy, stubTokens, CONFIG);
|
|
1319
|
+
await before.provision("agent-rev", { specialty: "reviewer" });
|
|
1320
|
+
await before.provision("agent-dev", { specialty: "developer" });
|
|
1321
|
+
|
|
1322
|
+
// The map is introduced: only the reviewer's resolved image changes.
|
|
1323
|
+
const after = new KubernetesAgentProvisioner(spy, stubTokens, MAPPED);
|
|
1324
|
+
const result = await after.reconcile([
|
|
1325
|
+
{ id: "agent-rev", specialty: "reviewer" },
|
|
1326
|
+
{ id: "agent-dev", specialty: "developer" },
|
|
1327
|
+
]);
|
|
1328
|
+
|
|
1329
|
+
expect(result.updated).toEqual(["agent-rev"]);
|
|
1330
|
+
expect(result.failed).toEqual([]);
|
|
1331
|
+
expect(patched).toEqual([sanitizeAgentName("agent-rev")]);
|
|
1332
|
+
expect(await imageOf(k8s, "agent-rev")).toBe(REVIEWER);
|
|
1333
|
+
expect(await imageOf(k8s, "agent-dev")).toBe(SINGLE);
|
|
1334
|
+
|
|
1335
|
+
// A second pass is a no-op.
|
|
1336
|
+
patched.length = 0;
|
|
1337
|
+
const again = await after.reconcile([
|
|
1338
|
+
{ id: "agent-rev", specialty: "reviewer" },
|
|
1339
|
+
{ id: "agent-dev", specialty: "developer" },
|
|
1340
|
+
]);
|
|
1341
|
+
expect(again.updated).toEqual([]);
|
|
1342
|
+
expect(patched).toEqual([]);
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it("reconcile() recreates a missing Deployment with its specialty's image", async () => {
|
|
1346
|
+
const k8s = emptyClient();
|
|
1347
|
+
const provisioner = new KubernetesAgentProvisioner(k8s, stubTokens, MAPPED);
|
|
1348
|
+
|
|
1349
|
+
const result = await provisioner.reconcile([
|
|
1350
|
+
{ id: "agent-rev", specialty: "reviewer" },
|
|
1351
|
+
]);
|
|
1352
|
+
|
|
1353
|
+
expect(result.recreated).toEqual(["agent-rev"]);
|
|
1354
|
+
expect(await imageOf(k8s, "agent-rev")).toBe(REVIEWER);
|
|
1355
|
+
});
|
|
1356
|
+
});
|
package/src/agent-provisioner.ts
CHANGED
|
@@ -44,6 +44,11 @@ import type {
|
|
|
44
44
|
AgentAnthropicIdentity,
|
|
45
45
|
AgentAnthropicIdentityService,
|
|
46
46
|
} from "./agent-anthropic-identity.ts";
|
|
47
|
+
import {
|
|
48
|
+
type AgentImageMap,
|
|
49
|
+
type AgentImageRef,
|
|
50
|
+
resolveImage,
|
|
51
|
+
} from "./agent-image-map.ts";
|
|
47
52
|
import {
|
|
48
53
|
type AgentAnthropicFederation,
|
|
49
54
|
type AgentContainerResourceOverrides,
|
|
@@ -151,6 +156,8 @@ export interface ReconcileTarget extends Partial<AgentAnthropicIdentity> {
|
|
|
151
156
|
accountId?: string | null;
|
|
152
157
|
/** The Agent row's pod ServiceAccount (COS-48.1); rendered only when the flag is on. */
|
|
153
158
|
podServiceAccountName?: string | null;
|
|
159
|
+
/** The agent's specialty; selects its image (SHIPWRIGHT_AGENT_IMAGES). */
|
|
160
|
+
specialty?: string;
|
|
154
161
|
/**
|
|
155
162
|
* The pod ServiceAccount this agent had before the change being reconciled
|
|
156
163
|
* (COS-49.1). Only used to clear: when `podServiceAccountName` is now unset,
|
|
@@ -179,6 +186,8 @@ export interface AgentProvisioner {
|
|
|
179
186
|
slug?: string;
|
|
180
187
|
accountId?: string | null;
|
|
181
188
|
podServiceAccountName?: string | null;
|
|
189
|
+
/** Selects the agent's image (SHIPWRIGHT_AGENT_IMAGES). */
|
|
190
|
+
specialty?: string;
|
|
182
191
|
},
|
|
183
192
|
): Promise<ProvisionResult>;
|
|
184
193
|
/**
|
|
@@ -236,6 +245,11 @@ export interface KubernetesAgentProvisionerConfig {
|
|
|
236
245
|
image: string;
|
|
237
246
|
/** Image tag, joined as `image:tag`. */
|
|
238
247
|
imageTag: string;
|
|
248
|
+
/**
|
|
249
|
+
* Optional per-specialty image map (SHIPWRIGHT_AGENT_IMAGES). A specialty
|
|
250
|
+
* without an entry (and no `default` key) uses `image`/`imageTag`.
|
|
251
|
+
*/
|
|
252
|
+
images?: AgentImageMap;
|
|
239
253
|
/** In-cluster admin/API base URL the agent calls home to. */
|
|
240
254
|
apiUrl: string;
|
|
241
255
|
/**
|
|
@@ -833,6 +847,15 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
833
847
|
: `${resourceName}-token`;
|
|
834
848
|
}
|
|
835
849
|
|
|
850
|
+
/** The image an agent of `specialty` runs (see resolveImage). */
|
|
851
|
+
private resolveImage(specialty?: string | null): AgentImageRef {
|
|
852
|
+
return resolveImage(
|
|
853
|
+
{ repository: this.config.image, tag: this.config.imageTag },
|
|
854
|
+
this.config.images,
|
|
855
|
+
specialty,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
836
859
|
private pvcNameFor(resourceName: string, slug?: string): string {
|
|
837
860
|
return this.config.pvcName
|
|
838
861
|
? this.config.pvcName(slug ? sanitizeAgentName(slug) : resourceName)
|
|
@@ -902,13 +925,15 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
902
925
|
accountId: string | null | undefined,
|
|
903
926
|
federation: AgentAnthropicFederation | undefined,
|
|
904
927
|
podServiceAccountName?: string | null,
|
|
928
|
+
specialty?: string,
|
|
905
929
|
) {
|
|
906
930
|
const resourceName = this.resourceName(agentId);
|
|
931
|
+
const { repository, tag } = this.resolveImage(specialty);
|
|
907
932
|
return buildAgentDeploymentManifest({
|
|
908
933
|
agentId,
|
|
909
934
|
namespace: this.config.namespace,
|
|
910
|
-
image:
|
|
911
|
-
imageTag:
|
|
935
|
+
image: repository,
|
|
936
|
+
imageTag: tag,
|
|
912
937
|
apiUrl: this.config.apiUrl,
|
|
913
938
|
pvcName: this.pvcNameFor(resourceName, slug),
|
|
914
939
|
secretName: this.secretNameFor(resourceName),
|
|
@@ -935,6 +960,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
935
960
|
slug?: string;
|
|
936
961
|
accountId?: string | null;
|
|
937
962
|
podServiceAccountName?: string | null;
|
|
963
|
+
specialty?: string;
|
|
938
964
|
},
|
|
939
965
|
): Promise<ProvisionResult> {
|
|
940
966
|
const resourceName = this.resourceName(agentId);
|
|
@@ -1147,6 +1173,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
1147
1173
|
slug?: string;
|
|
1148
1174
|
accountId?: string | null;
|
|
1149
1175
|
podServiceAccountName?: string | null;
|
|
1176
|
+
specialty?: string;
|
|
1150
1177
|
}
|
|
1151
1178
|
| undefined,
|
|
1152
1179
|
result: ProvisionResult,
|
|
@@ -1241,6 +1268,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
1241
1268
|
opts?.accountId,
|
|
1242
1269
|
federation,
|
|
1243
1270
|
opts?.podServiceAccountName,
|
|
1271
|
+
opts?.specialty,
|
|
1244
1272
|
),
|
|
1245
1273
|
);
|
|
1246
1274
|
} catch (err) {
|
|
@@ -1388,6 +1416,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
1388
1416
|
slug: agent.slug,
|
|
1389
1417
|
accountId: agent.accountId,
|
|
1390
1418
|
podServiceAccountName: agent.podServiceAccountName,
|
|
1419
|
+
specialty: agent.specialty,
|
|
1391
1420
|
});
|
|
1392
1421
|
}
|
|
1393
1422
|
recreated.push(agent.id);
|
|
@@ -1453,6 +1482,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
1453
1482
|
agent.accountId,
|
|
1454
1483
|
desiredFederation,
|
|
1455
1484
|
agent.podServiceAccountName,
|
|
1485
|
+
agent.specialty,
|
|
1456
1486
|
);
|
|
1457
1487
|
const desired = desiredDeployment.spec.template.spec.containers[0];
|
|
1458
1488
|
const desiredLabels = desiredDeployment.spec.template.metadata.labels;
|
|
@@ -2293,6 +2293,7 @@ describe("admin API — POST /agents/reconcile", () => {
|
|
|
2293
2293
|
anthropicServiceAccountId: null,
|
|
2294
2294
|
anthropicFederationRuleId: null,
|
|
2295
2295
|
k8sServiceAccountName: null,
|
|
2296
|
+
specialty: "coding",
|
|
2296
2297
|
},
|
|
2297
2298
|
]);
|
|
2298
2299
|
});
|
|
@@ -5407,7 +5408,12 @@ describe("admin API — PATCH /agents/:id accountId (SSP-5.3)", () => {
|
|
|
5407
5408
|
"acct-b",
|
|
5408
5409
|
);
|
|
5409
5410
|
expect(provisioner.reconciled).toEqual([
|
|
5410
|
-
{
|
|
5411
|
+
{
|
|
5412
|
+
id: AGENT_ID,
|
|
5413
|
+
slug: "Existing Agent",
|
|
5414
|
+
accountId: "acct-b",
|
|
5415
|
+
specialty: "coding",
|
|
5416
|
+
},
|
|
5411
5417
|
]);
|
|
5412
5418
|
});
|
|
5413
5419
|
|
|
@@ -5423,7 +5429,12 @@ describe("admin API — PATCH /agents/:id accountId (SSP-5.3)", () => {
|
|
|
5423
5429
|
expect(res.status).toBe(200);
|
|
5424
5430
|
expect(((await res.json()) as { accountId: unknown }).accountId).toBeNull();
|
|
5425
5431
|
expect(provisioner.reconciled).toEqual([
|
|
5426
|
-
{
|
|
5432
|
+
{
|
|
5433
|
+
id: AGENT_ID,
|
|
5434
|
+
slug: "Existing Agent",
|
|
5435
|
+
accountId: null,
|
|
5436
|
+
specialty: "coding",
|
|
5437
|
+
},
|
|
5427
5438
|
]);
|
|
5428
5439
|
});
|
|
5429
5440
|
|
package/src/agents-api.ts
CHANGED
|
@@ -1645,6 +1645,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
1645
1645
|
await provisioner.provision(agent.id, {
|
|
1646
1646
|
slug: agent.name,
|
|
1647
1647
|
accountId: agent.accountId,
|
|
1648
|
+
specialty: agent.specialty,
|
|
1648
1649
|
});
|
|
1649
1650
|
return c.json({ resourceName, secretName, deploymentName }, 200);
|
|
1650
1651
|
});
|
package/src/agents.ts
CHANGED
package/src/main.unit.test.ts
CHANGED
|
@@ -98,6 +98,35 @@ describe("buildProvisioner", () => {
|
|
|
98
98
|
expect(configOf(off).podServiceAccount).toEqual({ enabled: false });
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
it("parses SHIPWRIGHT_AGENT_IMAGES into the config, and omits it when unset", () => {
|
|
102
|
+
const env = { SHIPWRIGHT_K8S_PROVISIONING: "enabled" };
|
|
103
|
+
expect(
|
|
104
|
+
configOf(buildProvisioner(env, stubAgentTokenService())).images,
|
|
105
|
+
).toBe(undefined);
|
|
106
|
+
const mapped = buildProvisioner(
|
|
107
|
+
{
|
|
108
|
+
...env,
|
|
109
|
+
SHIPWRIGHT_AGENT_IMAGES: '{"reviewer":{"repository":"r","tag":"1"}}',
|
|
110
|
+
},
|
|
111
|
+
stubAgentTokenService(),
|
|
112
|
+
);
|
|
113
|
+
expect(configOf(mapped).images).toEqual({
|
|
114
|
+
reviewer: { repository: "r", tag: "1" },
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("throws at boot on a malformed SHIPWRIGHT_AGENT_IMAGES", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
buildProvisioner(
|
|
121
|
+
{
|
|
122
|
+
SHIPWRIGHT_K8S_PROVISIONING: "enabled",
|
|
123
|
+
SHIPWRIGHT_AGENT_IMAGES: "{oops",
|
|
124
|
+
},
|
|
125
|
+
stubAgentTokenService(),
|
|
126
|
+
),
|
|
127
|
+
).toThrow(/SHIPWRIGHT_AGENT_IMAGES/);
|
|
128
|
+
});
|
|
129
|
+
|
|
101
130
|
it("returns a NoopAgentProvisioner when SHIPWRIGHT_K8S_PROVISIONING is unset", () => {
|
|
102
131
|
const provisioner = buildProvisioner({}, stubAgentTokenService());
|
|
103
132
|
expect(provisioner).toBeInstanceOf(NoopAgentProvisioner);
|
|
@@ -97,8 +97,8 @@ describe("managedReconcileTargets", () => {
|
|
|
97
97
|
agent({ id: "a3", name: "gamma" }),
|
|
98
98
|
]),
|
|
99
99
|
).toEqual([
|
|
100
|
-
{ id: "a1", slug: "alpha", accountId: "acct-1" },
|
|
101
|
-
{ id: "a3", slug: "gamma", accountId: null },
|
|
100
|
+
{ id: "a1", slug: "alpha", accountId: "acct-1", specialty: "dev" },
|
|
101
|
+
{ id: "a3", slug: "gamma", accountId: null, specialty: "dev" },
|
|
102
102
|
]);
|
|
103
103
|
});
|
|
104
104
|
});
|
|
@@ -128,8 +128,15 @@ describe("managedReconcileTargets identity fields", () => {
|
|
|
128
128
|
anthropicServiceAccountId: "svac_1",
|
|
129
129
|
anthropicFederationRuleId: "fdrl_1",
|
|
130
130
|
k8sServiceAccountName: "a1",
|
|
131
|
+
specialty: "dev",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "a2",
|
|
135
|
+
slug: "beta",
|
|
136
|
+
accountId: null,
|
|
137
|
+
ownerEmail: null,
|
|
138
|
+
specialty: "dev",
|
|
131
139
|
},
|
|
132
|
-
{ id: "a2", slug: "beta", accountId: null, ownerEmail: null },
|
|
133
140
|
]);
|
|
134
141
|
});
|
|
135
142
|
});
|
|
@@ -151,8 +158,13 @@ describe("runStartupPolicyReconcile", () => {
|
|
|
151
158
|
expect(calls).toEqual([
|
|
152
159
|
{
|
|
153
160
|
agents: [
|
|
154
|
-
{ id: "a1", slug: "alpha", accountId: null },
|
|
155
|
-
{
|
|
161
|
+
{ id: "a1", slug: "alpha", accountId: null, specialty: "dev" },
|
|
162
|
+
{
|
|
163
|
+
id: "a3",
|
|
164
|
+
slug: "gamma",
|
|
165
|
+
accountId: "acct-3",
|
|
166
|
+
specialty: "dev",
|
|
167
|
+
},
|
|
156
168
|
],
|
|
157
169
|
opts: { policyOnly: true },
|
|
158
170
|
},
|