@app-vitals/agent-admin 1.1.0 → 1.2.1

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/Dockerfile CHANGED
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # Build from the repository root: docker build -f admin/Dockerfile .
4
4
 
5
- FROM oven/bun:1-slim AS deps
5
+ FROM oven/bun:1-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4 AS deps
6
6
 
7
7
  WORKDIR /app
8
8
 
@@ -16,7 +16,7 @@ COPY patches/ ./patches/
16
16
 
17
17
  RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
18
18
 
19
- FROM oven/bun:1-slim AS production
19
+ FROM oven/bun:1-slim@sha256:1846adf22496bd711036aae8be147e57bdebbaea2e5264e3e0b5af1db187d9a4 AS production
20
20
 
21
21
  RUN apt-get update && apt-get install -y --no-install-recommends \
22
22
  ca-certificates \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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.1.0",
45
+ "@app-vitals/agent-lib": "1.2.1",
46
46
  "@hono/zod-openapi": "^1.0.0",
47
47
  "@prisma/adapter-pg": "^7.10.0",
48
48
  "@prisma/client": "^7.10.0",
@@ -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 { buildDeletionClients } from "./admin-boot-config.ts";
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
+ });
@@ -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: tenant
517
- ? { ...selectorLabels, [TENANT_LABEL]: "true" }
518
- : selectorLabels,
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
+ });
@@ -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
- const labelsDrifted =
1490
- !policyOnly &&
1491
- desiredLabels[TENANT_LABEL] !== undefined &&
1492
- deployment.spec.template.metadata?.labels?.[TENANT_LABEL] !==
1493
- desiredLabels[TENANT_LABEL];
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
+ });
@@ -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