@app-vitals/agent-admin 1.0.0 → 1.2.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/e2e/agents-page.e2e.ts +9 -9
- package/e2e/test-server.ts +6 -6
- package/package.json +2 -2
- package/src/admin-boot-config.ts +3 -0
- package/src/admin-boot-config.unit.test.ts +16 -1
- package/src/agent-manifest.ts +9 -3
- package/src/agent-manifest.unit.test.ts +29 -0
- package/src/agent-pod-labels.ts +53 -0
- package/src/agent-pod-labels.unit.test.ts +62 -0
- package/src/agent-provisioner.ts +19 -9
- package/src/agent-provisioner.unit.test.ts +72 -0
- package/src/api.ts +1 -1
- package/src/startup-policy-reconcile.ts +3 -1
- /package/e2e/fixtures/specialties/{coding → specialist}/templates/CLAUDE.md.template +0 -0
- /package/e2e/fixtures/specialties/{coding → specialist}/templates/IDENTITY.md.template +0 -0
- /package/e2e/fixtures/specialties/{coding → specialist}/templates/SOUL.md.template +0 -0
package/e2e/agents-page.e2e.ts
CHANGED
|
@@ -239,7 +239,7 @@ test.describe("GET /admin/agents/:id — authenticated", () => {
|
|
|
239
239
|
context,
|
|
240
240
|
}) => {
|
|
241
241
|
await loadAgentDetailPage(page, context);
|
|
242
|
-
await expect(page.locator("body")).toContainText("
|
|
242
|
+
await expect(page.locator("body")).toContainText("specialist");
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
// CFB-3.1: .data-table .btn must stay at its current ~36px height at mobile
|
|
@@ -304,16 +304,16 @@ test.describe("GET /admin/agents/new — type picker", () => {
|
|
|
304
304
|
await expect(typeSelect).toHaveAttribute("required", "");
|
|
305
305
|
});
|
|
306
306
|
|
|
307
|
-
test('the select lists the configured default type "
|
|
307
|
+
test('the select lists the configured default type "specialist"', async ({
|
|
308
308
|
page,
|
|
309
309
|
context,
|
|
310
310
|
}) => {
|
|
311
311
|
await loadNewAgentPage(page, context);
|
|
312
|
-
const
|
|
313
|
-
'select[name="type"] option[value="
|
|
312
|
+
const specialistOption = page.locator(
|
|
313
|
+
'select[name="type"] option[value="specialist"]',
|
|
314
314
|
);
|
|
315
|
-
await expect(
|
|
316
|
-
await expect(
|
|
315
|
+
await expect(specialistOption).toBeAttached();
|
|
316
|
+
await expect(specialistOption).toHaveText("Specialist Agent");
|
|
317
317
|
});
|
|
318
318
|
});
|
|
319
319
|
|
|
@@ -324,8 +324,8 @@ test.describe("POST /admin/agents — create-with-type journey", () => {
|
|
|
324
324
|
}) => {
|
|
325
325
|
await loadNewAgentPage(page, context);
|
|
326
326
|
|
|
327
|
-
await page.fill('input[name="name"]', "My New
|
|
328
|
-
await page.selectOption('select[name="type"]', "
|
|
327
|
+
await page.fill('input[name="name"]', "My New Specialist Agent");
|
|
328
|
+
await page.selectOption('select[name="type"]', "specialist");
|
|
329
329
|
// Scoped to the new-agent form specifically — the page also has a
|
|
330
330
|
// "Sign out" button[type="submit"] in the toolbar, and an unscoped
|
|
331
331
|
// selector matches that one first (it's earlier in the DOM), which
|
|
@@ -333,6 +333,6 @@ test.describe("POST /admin/agents — create-with-type journey", () => {
|
|
|
333
333
|
await page.click('form[action="/admin/agents"] button[type="submit"]');
|
|
334
334
|
|
|
335
335
|
await page.waitForURL(/\/admin\/agents\/[^/]+$/);
|
|
336
|
-
await expect(page.locator("body")).toContainText("
|
|
336
|
+
await expect(page.locator("body")).toContainText("specialist");
|
|
337
337
|
});
|
|
338
338
|
});
|
package/e2e/test-server.ts
CHANGED
|
@@ -30,13 +30,13 @@ import type {
|
|
|
30
30
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
32
|
// The suite runs without a product specialty: point the specialty lookup at the
|
|
33
|
-
// minimal "
|
|
33
|
+
// minimal "specialist" fixture next to this file (values already set win).
|
|
34
34
|
process.env.SHIPWRIGHT_SPECIALTIES_DIR ||= resolve(
|
|
35
35
|
import.meta.dir,
|
|
36
36
|
"fixtures",
|
|
37
37
|
"specialties",
|
|
38
38
|
);
|
|
39
|
-
process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE ||= "
|
|
39
|
+
process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE ||= "specialist";
|
|
40
40
|
|
|
41
41
|
const ADMIN_E2E_PORT = 3490;
|
|
42
42
|
const ADMIN_E2E_SESSION_SECRET =
|
|
@@ -324,7 +324,7 @@ function buildMockDeps(chatClient: ChatClient | undefined): AdminUIDeps {
|
|
|
324
324
|
name: ADMIN_E2E_AGENT.name,
|
|
325
325
|
slackId: ADMIN_E2E_AGENT.slackId,
|
|
326
326
|
selfHosted: false,
|
|
327
|
-
specialty: "
|
|
327
|
+
specialty: "specialist",
|
|
328
328
|
createdAt: new Date("2024-01-01"),
|
|
329
329
|
updatedAt: new Date("2024-01-01"),
|
|
330
330
|
},
|
|
@@ -339,7 +339,7 @@ function buildMockDeps(chatClient: ChatClient | undefined): AdminUIDeps {
|
|
|
339
339
|
name: input.name ?? ADMIN_E2E_AGENT.name,
|
|
340
340
|
slackId: ADMIN_E2E_AGENT.slackId,
|
|
341
341
|
selfHosted: false,
|
|
342
|
-
specialty: input.specialty ?? "
|
|
342
|
+
specialty: input.specialty ?? "specialist",
|
|
343
343
|
createdAt: new Date("2024-01-01"),
|
|
344
344
|
updatedAt: new Date("2024-01-01"),
|
|
345
345
|
}),
|
|
@@ -349,7 +349,7 @@ function buildMockDeps(chatClient: ChatClient | undefined): AdminUIDeps {
|
|
|
349
349
|
name: ADMIN_E2E_AGENT.name,
|
|
350
350
|
slackId: ADMIN_E2E_AGENT.slackId,
|
|
351
351
|
selfHosted: false,
|
|
352
|
-
specialty: "
|
|
352
|
+
specialty: "specialist",
|
|
353
353
|
createdAt: new Date("2024-01-01"),
|
|
354
354
|
updatedAt: new Date("2024-01-01"),
|
|
355
355
|
repos: [],
|
|
@@ -361,7 +361,7 @@ function buildMockDeps(chatClient: ChatClient | undefined): AdminUIDeps {
|
|
|
361
361
|
name: ADMIN_E2E_AGENT.name,
|
|
362
362
|
slackId: ADMIN_E2E_AGENT.slackId,
|
|
363
363
|
selfHosted: false,
|
|
364
|
-
specialty: "
|
|
364
|
+
specialty: "specialist",
|
|
365
365
|
createdAt: new Date("2024-01-01"),
|
|
366
366
|
updatedAt: new Date("2024-01-01"),
|
|
367
367
|
repos: [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@anthropic-ai/sdk": "0.135.0",
|
|
45
|
-
"@app-vitals/agent-lib": "1.
|
|
45
|
+
"@app-vitals/agent-lib": "1.2.0",
|
|
46
46
|
"@hono/zod-openapi": "^1.0.0",
|
|
47
47
|
"@prisma/adapter-pg": "^7.10.0",
|
|
48
48
|
"@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 { parseAgentPodLabels } from "./agent-pod-labels.ts";
|
|
15
16
|
import { parseAgentImageMap } from "./agent-image-map.ts";
|
|
16
17
|
import type { AgentAnthropicFederation } from "./agent-manifest.ts";
|
|
17
18
|
import type {
|
|
@@ -123,6 +124,7 @@ export function buildProvisioner(
|
|
|
123
124
|
const imageTag = env.SHIPWRIGHT_AGENT_IMAGE_TAG ?? "latest";
|
|
124
125
|
// Malformed → throws here, so admin boot fails rather than falling back.
|
|
125
126
|
const images = parseAgentImageMap(env.SHIPWRIGHT_AGENT_IMAGES);
|
|
127
|
+
const podLabels = parseAgentPodLabels(env.SHIPWRIGHT_K8S_AGENT_POD_LABELS);
|
|
126
128
|
const apiUrl = env.SHIPWRIGHT_API_URL ?? "";
|
|
127
129
|
const replicasRaw = env.SHIPWRIGHT_AGENT_REPLICAS;
|
|
128
130
|
const replicas = replicasRaw ? Number(replicasRaw) : undefined;
|
|
@@ -205,6 +207,7 @@ export function buildProvisioner(
|
|
|
205
207
|
image,
|
|
206
208
|
imageTag,
|
|
207
209
|
...(images ? { images } : {}),
|
|
210
|
+
...(podLabels ? { podLabels } : {}),
|
|
208
211
|
apiUrl,
|
|
209
212
|
...(replicas !== undefined && Number.isFinite(replicas)
|
|
210
213
|
? { replicas }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import type { AgentTokenService } from "./agent-tokens.ts";
|
|
3
|
+
import { buildProvisioner, buildDeletionClients } from "./admin-boot-config.ts";
|
|
3
4
|
import {
|
|
4
5
|
HttpChatServiceProvisioningClient,
|
|
5
6
|
NoopChatServiceProvisioningClient,
|
|
@@ -26,3 +27,17 @@ describe("buildDeletionClients", () => {
|
|
|
26
27
|
expect(chatService).toBeInstanceOf(HttpChatServiceProvisioningClient);
|
|
27
28
|
});
|
|
28
29
|
});
|
|
30
|
+
|
|
31
|
+
describe("buildProvisioner pod label configuration", () => {
|
|
32
|
+
it("rejects invalid labels during admin boot when provisioning is enabled", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
buildProvisioner(
|
|
35
|
+
{
|
|
36
|
+
SHIPWRIGHT_K8S_PROVISIONING: "enabled",
|
|
37
|
+
SHIPWRIGHT_K8S_AGENT_POD_LABELS: '{"app.kubernetes.io/name":"wrong"}',
|
|
38
|
+
},
|
|
39
|
+
{} as AgentTokenService,
|
|
40
|
+
),
|
|
41
|
+
).toThrow("reserved key");
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/agent-manifest.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
+
import { validateAgentPodLabels } from "./agent-pod-labels.ts";
|
|
15
16
|
import type {
|
|
16
17
|
KubernetesDeployment,
|
|
17
18
|
KubernetesEnvVar,
|
|
@@ -239,6 +240,8 @@ export function buildAgentPvcManifest(opts: AgentPvcOpts): KubernetesPvc {
|
|
|
239
240
|
// ─── Deployment builder ─────────────────────────────────────────────────────
|
|
240
241
|
|
|
241
242
|
export interface AgentDeploymentOpts {
|
|
243
|
+
/** Additional pod labels; reserved identity labels cannot be overridden. */
|
|
244
|
+
podLabels?: Record<string, string>;
|
|
242
245
|
/** Original agent id (used verbatim in env + label, sanitized for the name). */
|
|
243
246
|
agentId: string;
|
|
244
247
|
namespace: string;
|
|
@@ -471,6 +474,7 @@ export function buildAgentDeploymentManifest(
|
|
|
471
474
|
opts: AgentDeploymentOpts,
|
|
472
475
|
): KubernetesDeployment {
|
|
473
476
|
const name = sanitizeAgentName(opts.agentId);
|
|
477
|
+
const podLabels = validateAgentPodLabels(opts.podLabels ?? {});
|
|
474
478
|
const tokenKey = opts.tokenSecretKey ?? "token";
|
|
475
479
|
const volumeName = "agent-home";
|
|
476
480
|
|
|
@@ -513,9 +517,11 @@ export function buildAgentDeploymentManifest(
|
|
|
513
517
|
selector: { matchLabels: selectorLabels },
|
|
514
518
|
template: {
|
|
515
519
|
metadata: {
|
|
516
|
-
labels:
|
|
517
|
-
|
|
518
|
-
|
|
520
|
+
labels: {
|
|
521
|
+
...selectorLabels,
|
|
522
|
+
...podLabels,
|
|
523
|
+
...(tenant ? { [TENANT_LABEL]: "true" } : {}),
|
|
524
|
+
},
|
|
519
525
|
...(policy?.hash
|
|
520
526
|
? { annotations: { [CLAUDE_POLICY_HASH_ANNOTATION]: policy.hash } }
|
|
521
527
|
: {}),
|
|
@@ -75,6 +75,35 @@ describe("buildAgentDeploymentManifest", () => {
|
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
+
it("adds configured pod labels without changing immutable selectors or identity labels", () => {
|
|
79
|
+
const base = buildAgentDeploymentManifest(deployOpts);
|
|
80
|
+
const podLabels = {
|
|
81
|
+
"vgs.io/service": "shipwright",
|
|
82
|
+
"vgs.io/team": "team-infrastructure",
|
|
83
|
+
};
|
|
84
|
+
const labeled = buildAgentDeploymentManifest({
|
|
85
|
+
...deployOpts,
|
|
86
|
+
podLabels,
|
|
87
|
+
accountId: "account-1",
|
|
88
|
+
});
|
|
89
|
+
expect(labeled.spec.selector).toEqual(base.spec.selector);
|
|
90
|
+
expect(labeled.metadata).toEqual({
|
|
91
|
+
...base.metadata,
|
|
92
|
+
labels: { ...base.metadata.labels, [TENANT_LABEL]: "true" },
|
|
93
|
+
});
|
|
94
|
+
expect(labeled.spec.template.metadata.labels).toEqual({
|
|
95
|
+
...base.spec.template.metadata.labels,
|
|
96
|
+
...podLabels,
|
|
97
|
+
[TENANT_LABEL]: "true",
|
|
98
|
+
});
|
|
99
|
+
expect(() =>
|
|
100
|
+
buildAgentDeploymentManifest({
|
|
101
|
+
...deployOpts,
|
|
102
|
+
podLabels: { "app.kubernetes.io/instance": "other" },
|
|
103
|
+
}),
|
|
104
|
+
).toThrow();
|
|
105
|
+
});
|
|
106
|
+
|
|
78
107
|
it("sets the required env vars including a secretKeyRef token", () => {
|
|
79
108
|
const d = buildAgentDeploymentManifest(deployOpts);
|
|
80
109
|
const env = d.spec.template.spec.containers[0].env ?? [];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Optional labels for agent pod templates; identity selectors stay platform-owned. */
|
|
2
|
+
const RESERVED = new Set([
|
|
3
|
+
"app.kubernetes.io/name",
|
|
4
|
+
"app.kubernetes.io/instance",
|
|
5
|
+
"app.kubernetes.io/managed-by",
|
|
6
|
+
"shipwright.dev/agent-id",
|
|
7
|
+
"shipwright.dev/tenant",
|
|
8
|
+
]);
|
|
9
|
+
const NAME = /^[A-Za-z0-9]([A-Za-z0-9_.-]*[A-Za-z0-9])?$/;
|
|
10
|
+
const DNS_LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
11
|
+
|
|
12
|
+
export function validateAgentPodLabels(value: unknown): Record<string, string> {
|
|
13
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
+
throw new Error("SHIPWRIGHT_K8S_AGENT_POD_LABELS must be a JSON object");
|
|
15
|
+
}
|
|
16
|
+
for (const [key, label] of Object.entries(value)) {
|
|
17
|
+
const parts = key.split("/");
|
|
18
|
+
const name = parts.at(-1) ?? "";
|
|
19
|
+
const prefix = parts.length === 2 ? parts[0] : undefined;
|
|
20
|
+
if (
|
|
21
|
+
parts.length > 2 ||
|
|
22
|
+
name.length > 63 ||
|
|
23
|
+
!NAME.test(name) ||
|
|
24
|
+
(prefix !== undefined &&
|
|
25
|
+
(prefix.length > 253 ||
|
|
26
|
+
prefix
|
|
27
|
+
.split(".")
|
|
28
|
+
.some((part) => part.length > 63 || !DNS_LABEL.test(part)))) ||
|
|
29
|
+
RESERVED.has(key)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`SHIPWRIGHT_K8S_AGENT_POD_LABELS has an invalid or reserved key: ${key}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
typeof label !== "string" ||
|
|
37
|
+
label.length > 63 ||
|
|
38
|
+
(label !== "" && !NAME.test(label))
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`SHIPWRIGHT_K8S_AGENT_POD_LABELS has an invalid value for ${key}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { ...value } as Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseAgentPodLabels(
|
|
49
|
+
raw: string | undefined,
|
|
50
|
+
): Record<string, string> | undefined {
|
|
51
|
+
if (raw === undefined || raw.trim() === "") return undefined;
|
|
52
|
+
return validateAgentPodLabels(JSON.parse(raw));
|
|
53
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseAgentPodLabels,
|
|
4
|
+
validateAgentPodLabels,
|
|
5
|
+
} from "./agent-pod-labels.ts";
|
|
6
|
+
|
|
7
|
+
describe("agent pod label configuration", () => {
|
|
8
|
+
it("leaves unconfigured labels absent", () => {
|
|
9
|
+
expect(parseAgentPodLabels(undefined)).toBeUndefined();
|
|
10
|
+
expect(parseAgentPodLabels(" ")).toBeUndefined();
|
|
11
|
+
expect(parseAgentPodLabels("{}")).toEqual({});
|
|
12
|
+
});
|
|
13
|
+
it("accepts qualified keys, plain keys and Kubernetes label values", () => {
|
|
14
|
+
expect(
|
|
15
|
+
parseAgentPodLabels(
|
|
16
|
+
'{"vgs.io/team":"team-infrastructure","app":"shipwright","empty":""}',
|
|
17
|
+
),
|
|
18
|
+
).toEqual({
|
|
19
|
+
"vgs.io/team": "team-infrastructure",
|
|
20
|
+
app: "shipwright",
|
|
21
|
+
empty: "",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
for (const raw of [
|
|
25
|
+
"null",
|
|
26
|
+
"[]",
|
|
27
|
+
"false",
|
|
28
|
+
"1",
|
|
29
|
+
"invalid",
|
|
30
|
+
'{"app":1}',
|
|
31
|
+
'{"app":null}',
|
|
32
|
+
]) {
|
|
33
|
+
it(`rejects malformed config ${raw}`, () =>
|
|
34
|
+
expect(() => parseAgentPodLabels(raw)).toThrow());
|
|
35
|
+
}
|
|
36
|
+
for (const key of [
|
|
37
|
+
"",
|
|
38
|
+
"/app",
|
|
39
|
+
"UPPER.io/app",
|
|
40
|
+
"bad..io/app",
|
|
41
|
+
"a/b/c",
|
|
42
|
+
"a".repeat(64),
|
|
43
|
+
"app.kubernetes.io/name",
|
|
44
|
+
"app.kubernetes.io/instance",
|
|
45
|
+
"app.kubernetes.io/managed-by",
|
|
46
|
+
"shipwright.dev/agent-id",
|
|
47
|
+
"shipwright.dev/tenant",
|
|
48
|
+
]) {
|
|
49
|
+
it(`rejects invalid or reserved key ${key}`, () =>
|
|
50
|
+
expect(() => validateAgentPodLabels({ [key]: "x" })).toThrow());
|
|
51
|
+
}
|
|
52
|
+
for (const value of [
|
|
53
|
+
"bad/value",
|
|
54
|
+
"bad value",
|
|
55
|
+
"-bad",
|
|
56
|
+
"bad-",
|
|
57
|
+
"x".repeat(64),
|
|
58
|
+
]) {
|
|
59
|
+
it(`rejects invalid value ${value}`, () =>
|
|
60
|
+
expect(() => validateAgentPodLabels({ app: value })).toThrow());
|
|
61
|
+
}
|
|
62
|
+
});
|
package/src/agent-provisioner.ts
CHANGED
|
@@ -239,6 +239,8 @@ export interface AgentProvisioner {
|
|
|
239
239
|
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
240
240
|
|
|
241
241
|
export interface KubernetesAgentProvisionerConfig {
|
|
242
|
+
/** Additional labels managed on new and existing agent pod templates. */
|
|
243
|
+
podLabels?: Record<string, string>;
|
|
242
244
|
/** Namespace the agent resources live in. */
|
|
243
245
|
namespace: string;
|
|
244
246
|
/** Agent container image (without tag). */
|
|
@@ -940,6 +942,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
940
942
|
tokenSecretKey: this.tokenKey,
|
|
941
943
|
replicas: this.config.replicas,
|
|
942
944
|
resources: this.config.resources,
|
|
945
|
+
podLabels: this.config.podLabels,
|
|
943
946
|
voice: this.config.voice,
|
|
944
947
|
serviceIntegrations: provisionableIntegrations(
|
|
945
948
|
this.config.serviceIntegrations,
|
|
@@ -1486,11 +1489,18 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
1486
1489
|
);
|
|
1487
1490
|
const desired = desiredDeployment.spec.template.spec.containers[0];
|
|
1488
1491
|
const desiredLabels = desiredDeployment.spec.template.metadata.labels;
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1492
|
+
// Manage only configured keys; preserve labels owned by other actors.
|
|
1493
|
+
// Startup policy reconciliation also propagates ownership labels.
|
|
1494
|
+
const managedLabels = {
|
|
1495
|
+
...(this.config.podLabels ?? {}),
|
|
1496
|
+
...(!policyOnly && desiredLabels[TENANT_LABEL] !== undefined
|
|
1497
|
+
? { [TENANT_LABEL]: desiredLabels[TENANT_LABEL] }
|
|
1498
|
+
: {}),
|
|
1499
|
+
};
|
|
1500
|
+
const labelsDrifted = Object.entries(managedLabels).some(
|
|
1501
|
+
([key, value]) =>
|
|
1502
|
+
deployment.spec.template.metadata?.labels?.[key] !== value,
|
|
1503
|
+
);
|
|
1494
1504
|
const policy = policyPatch(deployment, desiredDeployment);
|
|
1495
1505
|
// Unmanaged federation (see reconcileFederationFor): no patch, so
|
|
1496
1506
|
// the live ServiceAccount, token volume and env are left alone.
|
|
@@ -1571,9 +1581,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
1571
1581
|
}
|
|
1572
1582
|
: {};
|
|
1573
1583
|
const podMetadata = {
|
|
1574
|
-
...(labelsDrifted
|
|
1575
|
-
? { labels: { [TENANT_LABEL]: desiredLabels[TENANT_LABEL] } }
|
|
1576
|
-
: {}),
|
|
1584
|
+
...(labelsDrifted ? { labels: managedLabels } : {}),
|
|
1577
1585
|
...(policy?.annotations
|
|
1578
1586
|
? { annotations: policy.annotations }
|
|
1579
1587
|
: {}),
|
|
@@ -1583,7 +1591,9 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
|
1583
1591
|
this.config.namespace,
|
|
1584
1592
|
resourceName,
|
|
1585
1593
|
{
|
|
1586
|
-
...(labelsDrifted
|
|
1594
|
+
...(labelsDrifted &&
|
|
1595
|
+
!policyOnly &&
|
|
1596
|
+
desiredLabels[TENANT_LABEL] !== undefined
|
|
1587
1597
|
? {
|
|
1588
1598
|
metadata: {
|
|
1589
1599
|
labels: {
|
|
@@ -2034,3 +2034,75 @@ describe("KubernetesAgentProvisioner.reconcile() — pod ServiceAccount drift (C
|
|
|
2034
2034
|
expect(patches).toHaveLength(0);
|
|
2035
2035
|
});
|
|
2036
2036
|
});
|
|
2037
|
+
|
|
2038
|
+
describe("KubernetesAgentProvisioner configured pod labels", () => {
|
|
2039
|
+
const agentId = "agent-label-example";
|
|
2040
|
+
const podLabels = {
|
|
2041
|
+
"vgs.io/service": "shipwright",
|
|
2042
|
+
"vgs.io/team": "team-infrastructure",
|
|
2043
|
+
};
|
|
2044
|
+
for (const policyOnly of [false, true]) {
|
|
2045
|
+
it(`updates existing templates and preserves selectors/unrelated labels (policyOnly=${policyOnly})`, async () => {
|
|
2046
|
+
const client = emptyClient();
|
|
2047
|
+
const original = new KubernetesAgentProvisioner(
|
|
2048
|
+
client,
|
|
2049
|
+
stubTokens() as AgentTokenService,
|
|
2050
|
+
BASE_CONFIG,
|
|
2051
|
+
);
|
|
2052
|
+
await original.provision(agentId);
|
|
2053
|
+
const name = sanitizeAgentName(agentId);
|
|
2054
|
+
const before = await client.getDeployment(NAMESPACE, name);
|
|
2055
|
+
const selector = structuredClone(before.spec.selector);
|
|
2056
|
+
await client.patchDeployment(NAMESPACE, name, {
|
|
2057
|
+
spec: {
|
|
2058
|
+
template: {
|
|
2059
|
+
metadata: {
|
|
2060
|
+
labels: {
|
|
2061
|
+
"other.io/label": "preserved",
|
|
2062
|
+
"vgs.io/team": "NOT_SET",
|
|
2063
|
+
},
|
|
2064
|
+
},
|
|
2065
|
+
},
|
|
2066
|
+
},
|
|
2067
|
+
});
|
|
2068
|
+
const next = new KubernetesAgentProvisioner(
|
|
2069
|
+
client,
|
|
2070
|
+
stubTokens() as AgentTokenService,
|
|
2071
|
+
{ ...BASE_CONFIG, podLabels },
|
|
2072
|
+
);
|
|
2073
|
+
const preview = await next.reconcile([{ id: agentId }], {
|
|
2074
|
+
policyOnly,
|
|
2075
|
+
dryRun: true,
|
|
2076
|
+
});
|
|
2077
|
+
expect(preview.updated).toEqual([agentId]);
|
|
2078
|
+
expect(
|
|
2079
|
+
(await client.getDeployment(NAMESPACE, name)).spec.template.metadata
|
|
2080
|
+
.labels["vgs.io/team"],
|
|
2081
|
+
).toBe("NOT_SET");
|
|
2082
|
+
expect(
|
|
2083
|
+
(await next.reconcile([{ id: agentId }], { policyOnly })).updated,
|
|
2084
|
+
).toEqual([agentId]);
|
|
2085
|
+
const after = await client.getDeployment(NAMESPACE, name);
|
|
2086
|
+
expect(after.spec.selector).toEqual(selector);
|
|
2087
|
+
expect(after.spec.template.metadata.labels).toMatchObject({
|
|
2088
|
+
...podLabels,
|
|
2089
|
+
"other.io/label": "preserved",
|
|
2090
|
+
});
|
|
2091
|
+
expect(
|
|
2092
|
+
(await next.reconcile([{ id: agentId }], { policyOnly })).updated,
|
|
2093
|
+
).toEqual([]);
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
it("gives newly provisioned agents the configured labels", async () => {
|
|
2097
|
+
const client = emptyClient();
|
|
2098
|
+
await new KubernetesAgentProvisioner(
|
|
2099
|
+
client,
|
|
2100
|
+
stubTokens() as AgentTokenService,
|
|
2101
|
+
{ ...BASE_CONFIG, podLabels },
|
|
2102
|
+
).provision(agentId);
|
|
2103
|
+
expect(
|
|
2104
|
+
(await client.getDeployment(NAMESPACE, sanitizeAgentName(agentId))).spec
|
|
2105
|
+
.template.metadata.labels,
|
|
2106
|
+
).toMatchObject(podLabels);
|
|
2107
|
+
});
|
|
2108
|
+
});
|
package/src/api.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface AgentConfigResponse {
|
|
|
66
66
|
*/
|
|
67
67
|
accountStatus?: string | null;
|
|
68
68
|
/**
|
|
69
|
-
* ARD-6.1: the agent's Specialty name (e.g. "
|
|
69
|
+
* ARD-6.1: the agent's Specialty name (e.g. "specialist"), read by the agent
|
|
70
70
|
* runtime to pick its workspace template directory.
|
|
71
71
|
*/
|
|
72
72
|
specialty: string;
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* agents lose the mount after the policy is disabled. The same pass applies
|
|
12
12
|
* the Anthropic workload identity federation wiring (ServiceAccount, projected
|
|
13
13
|
* token volume, mount, federation env), so turning federation on or off and
|
|
14
|
-
* restarting admin corrects existing agents.
|
|
14
|
+
* restarting admin corrects existing agents. Configured pod labels are also
|
|
15
|
+
* reconciled, preserving selectors and labels owned by other actors; changing
|
|
16
|
+
* a configured label rolls the affected agent through its Recreate strategy.
|
|
15
17
|
*
|
|
16
18
|
* The startup pass never moves agents onto their own per-agent identity: that
|
|
17
19
|
* creates Anthropic and Kubernetes resources, which should not happen as a side
|
|
File without changes
|
|
File without changes
|
|
File without changes
|