@app-vitals/agent-admin 0.1.2 → 0.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.
@@ -318,6 +318,24 @@ export interface AgentDeploymentOpts {
318
318
  * federation). Omit for an unchanged manifest.
319
319
  */
320
320
  anthropicFederation?: AgentAnthropicFederation;
321
+ /**
322
+ * Per-agent pod ServiceAccount (COS-48.1). When set (and `anthropicFederation`
323
+ * is absent) the pod runs as this ServiceAccount. Mutually exclusive with
324
+ * `anthropicFederation`: federation's projected token subject must stay its
325
+ * own ServiceAccount, so supplying both throws
326
+ * PodServiceAccountConflictError. Omit for an unchanged manifest.
327
+ */
328
+ podServiceAccountName?: string;
329
+ }
330
+
331
+ /** Both a pod ServiceAccount and Anthropic federation were supplied. */
332
+ export class PodServiceAccountConflictError extends Error {
333
+ constructor(agentId: string) {
334
+ super(
335
+ `agent ${agentId}: podServiceAccountName cannot be combined with anthropicFederation (the federation token subject must stay its own ServiceAccount)`,
336
+ );
337
+ this.name = "PodServiceAccountConflictError";
338
+ }
321
339
  }
322
340
 
323
341
  /**
@@ -479,6 +497,9 @@ export function buildAgentDeploymentManifest(
479
497
 
480
498
  const policy = opts.claudePolicy;
481
499
  const federation = opts.anthropicFederation;
500
+ if (federation && opts.podServiceAccountName) {
501
+ throw new PodServiceAccountConflictError(opts.agentId);
502
+ }
482
503
 
483
504
  return {
484
505
  apiVersion: "apps/v1",
@@ -507,7 +528,9 @@ export function buildAgentDeploymentManifest(
507
528
  // Federation: the projected token's subject is this ServiceAccount.
508
529
  ...(federation
509
530
  ? { serviceAccountName: federation.serviceAccountName }
510
- : {}),
531
+ : opts.podServiceAccountName
532
+ ? { serviceAccountName: opts.podServiceAccountName }
533
+ : {}),
511
534
  securityContext: {
512
535
  fsGroup: AGENT_RUN_AS,
513
536
  fsGroupChangePolicy: "OnRootMismatch",
@@ -17,6 +17,7 @@ import {
17
17
  buildAgentSecretManifest,
18
18
  resolveAgentContainerResources,
19
19
  sanitizeAgentName,
20
+ PodServiceAccountConflictError,
20
21
  TENANT_LABEL,
21
22
  } from "./agent-manifest.ts";
22
23
 
@@ -868,3 +869,59 @@ describe("buildAgentDeploymentManifest — anthropic federation", () => {
868
869
  expect(base).not.toContain("ANTHROPIC_");
869
870
  });
870
871
  });
872
+
873
+ describe("buildAgentDeploymentManifest — pod service account", () => {
874
+ const anthropicFederation = {
875
+ serviceAccountName: "shipwright-agent",
876
+ ruleId: "fdrl_123",
877
+ organizationId: "org-uuid",
878
+ serviceAccountId: "svac_456",
879
+ audience: "https://api.anthropic.com",
880
+ expirationSeconds: 600,
881
+ };
882
+
883
+ it("sets spec.template.spec.serviceAccountName from the option", () => {
884
+ const d = buildAgentDeploymentManifest({
885
+ ...deployOpts,
886
+ podServiceAccountName: "agent-sa",
887
+ });
888
+ expect(d.spec.template.spec.serviceAccountName).toBe("agent-sa");
889
+ });
890
+
891
+ it("omits serviceAccountName entirely when undefined", () => {
892
+ const d = buildAgentDeploymentManifest({
893
+ ...deployOpts,
894
+ podServiceAccountName: undefined,
895
+ });
896
+ expect("serviceAccountName" in d.spec.template.spec).toBe(false);
897
+ });
898
+
899
+ it("leaves the base manifest byte-identical when the option is absent", () => {
900
+ expect(
901
+ JSON.stringify(
902
+ buildAgentDeploymentManifest({
903
+ ...deployOpts,
904
+ podServiceAccountName: undefined,
905
+ }),
906
+ ),
907
+ ).toBe(JSON.stringify(buildAgentDeploymentManifest(deployOpts)));
908
+ });
909
+
910
+ it("throws a typed error when federation is also supplied", () => {
911
+ expect(() =>
912
+ buildAgentDeploymentManifest({
913
+ ...deployOpts,
914
+ podServiceAccountName: "agent-sa",
915
+ anthropicFederation,
916
+ }),
917
+ ).toThrow(PodServiceAccountConflictError);
918
+ });
919
+
920
+ it("keeps the federation service account when only federation is set", () => {
921
+ const d = buildAgentDeploymentManifest({
922
+ ...deployOpts,
923
+ anthropicFederation,
924
+ });
925
+ expect(d.spec.template.spec.serviceAccountName).toBe("shipwright-agent");
926
+ });
927
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * admin/src/agent-pod-service-account.integration.test.ts
3
+ * COS-48.1: the Agent.podServiceAccountName column exists, is nullable and is
4
+ * independent of k8sServiceAccountName. Requires DATABASE_URL_ADMIN_TEST;
5
+ * skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import { createAdminPrismaClient } from "./prisma-client.ts";
11
+
12
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
13
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
14
+
15
+ describeOrSkip("Agent.podServiceAccountName (integration)", () => {
16
+ let prisma: PrismaClient;
17
+
18
+ beforeEach(async () => {
19
+ prisma = createAdminPrismaClient(TEST_DB as string);
20
+ await prisma.agentPhaseMethodology.deleteMany();
21
+ await prisma.agentToken.deleteMany();
22
+ await prisma.agentCronJob.deleteMany();
23
+ await prisma.agentTool.deleteMany();
24
+ await prisma.agentEnv.deleteMany();
25
+ await prisma.agent.deleteMany();
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await prisma.$disconnect();
30
+ });
31
+
32
+ it("defaults to null for a new agent", async () => {
33
+ const agent = await prisma.agent.create({ data: { name: "A" } });
34
+ expect(agent.podServiceAccountName).toBeNull();
35
+ });
36
+
37
+ it("round-trips without touching k8sServiceAccountName", async () => {
38
+ const agent = await prisma.agent.create({
39
+ data: { name: "A", podServiceAccountName: "agent-a-sa" },
40
+ });
41
+ const row = await prisma.agent.findUniqueOrThrow({
42
+ where: { id: agent.id },
43
+ });
44
+ expect(row.podServiceAccountName).toBe("agent-a-sa");
45
+ expect(row.k8sServiceAccountName).toBeNull();
46
+ });
47
+ });
@@ -80,6 +80,7 @@ import type {
80
80
  PvcSpec,
81
81
  SecretSpec,
82
82
  } from "./kubernetes-client.ts";
83
+ import type { PodServiceAccountConfig } from "./pod-service-account-config.ts";
83
84
  import {
84
85
  provisionableIntegrations,
85
86
  type ServiceIntegration,
@@ -148,6 +149,8 @@ export interface ReconcileTarget extends Partial<AgentAnthropicIdentity> {
148
149
  id: string;
149
150
  slug?: string;
150
151
  accountId?: string | null;
152
+ /** The Agent row's pod ServiceAccount (COS-48.1); rendered only when the flag is on. */
153
+ podServiceAccountName?: string | null;
151
154
  }
152
155
 
153
156
  export interface AgentProvisioner {
@@ -163,7 +166,11 @@ export interface AgentProvisioner {
163
166
  /** Mint a token and create the agent's Secret + Deployment. Idempotent. */
164
167
  provision(
165
168
  agentId: string,
166
- opts?: { slug?: string; accountId?: string | null },
169
+ opts?: {
170
+ slug?: string;
171
+ accountId?: string | null;
172
+ podServiceAccountName?: string | null;
173
+ },
167
174
  ): Promise<ProvisionResult>;
168
175
  /**
169
176
  * Delete the agent's Deployment, Secret, and PVC. Tolerates already-absent
@@ -304,6 +311,12 @@ export interface KubernetesAgentProvisionerConfig {
304
311
  * exclusive with `anthropicFederation` (shared mode).
305
312
  */
306
313
  perAgentIdentity?: PerAgentIdentityConfig;
314
+ /**
315
+ * Per-agent pod ServiceAccount flag (COS-48.1). Only when `enabled` is the
316
+ * Agent row's podServiceAccountName passed to the manifest; absent or off
317
+ * means the value is never rendered, even if set in the DB.
318
+ */
319
+ podServiceAccount?: PodServiceAccountConfig;
307
320
  }
308
321
 
309
322
  // ─── perAgent identity ──────────────────────────────────────────────────────
@@ -841,6 +854,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
841
854
  slug: string | undefined,
842
855
  accountId: string | null | undefined,
843
856
  federation: AgentAnthropicFederation | undefined,
857
+ podServiceAccountName?: string | null,
844
858
  ) {
845
859
  const resourceName = this.resourceName(agentId);
846
860
  return buildAgentDeploymentManifest({
@@ -862,12 +876,19 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
862
876
  claudePolicy: this.config.claudePolicy,
863
877
  anthropicFederation: federation,
864
878
  accountId,
879
+ ...(this.config.podServiceAccount?.enabled && podServiceAccountName
880
+ ? { podServiceAccountName }
881
+ : {}),
865
882
  });
866
883
  }
867
884
 
868
885
  async provision(
869
886
  agentId: string,
870
- opts?: { slug?: string; accountId?: string | null },
887
+ opts?: {
888
+ slug?: string;
889
+ accountId?: string | null;
890
+ podServiceAccountName?: string | null;
891
+ },
871
892
  ): Promise<ProvisionResult> {
872
893
  const resourceName = this.resourceName(agentId);
873
894
  const secretName = this.secretNameFor(resourceName);
@@ -1074,7 +1095,13 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1074
1095
  /** Steps 2–4: token(s) → Secret → Deployment (the original provision flow). */
1075
1096
  private async provisionSecretAndDeployment(
1076
1097
  agentId: string,
1077
- opts: { slug?: string; accountId?: string | null } | undefined,
1098
+ opts:
1099
+ | {
1100
+ slug?: string;
1101
+ accountId?: string | null;
1102
+ podServiceAccountName?: string | null;
1103
+ }
1104
+ | undefined,
1078
1105
  result: ProvisionResult,
1079
1106
  federation: AgentAnthropicFederation | undefined,
1080
1107
  ): Promise<ProvisionResult> {
@@ -1166,6 +1193,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1166
1193
  opts?.slug,
1167
1194
  opts?.accountId,
1168
1195
  federation,
1196
+ opts?.podServiceAccountName,
1169
1197
  ),
1170
1198
  );
1171
1199
  } catch (err) {
@@ -1312,6 +1340,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1312
1340
  await this.provision(agent.id, {
1313
1341
  slug: agent.slug,
1314
1342
  accountId: agent.accountId,
1343
+ podServiceAccountName: agent.podServiceAccountName,
1315
1344
  });
1316
1345
  }
1317
1346
  recreated.push(agent.id);
@@ -1376,6 +1405,7 @@ export class KubernetesAgentProvisioner implements AgentProvisioner {
1376
1405
  agent.slug,
1377
1406
  agent.accountId,
1378
1407
  desiredFederation,
1408
+ agent.podServiceAccountName,
1379
1409
  );
1380
1410
  const desired = desiredDeployment.spec.template.spec.containers[0];
1381
1411
  const desiredLabels = desiredDeployment.spec.template.metadata.labels;
@@ -1850,3 +1850,51 @@ describe("perAgentFederation()", () => {
1850
1850
  ).toBe(false);
1851
1851
  });
1852
1852
  });
1853
+
1854
+ describe("KubernetesAgentProvisioner — pod service account (COS-48.1)", () => {
1855
+ const agentId = "cmqalfjcm000m4101iharq28k";
1856
+ const resourceName = sanitizeAgentName(agentId);
1857
+
1858
+ async function desiredSa(
1859
+ config: KubernetesAgentProvisionerConfig,
1860
+ target: { podServiceAccountName?: string | null },
1861
+ ) {
1862
+ const k8s = new RecordedKubernetesClient({
1863
+ deployments: {},
1864
+ secrets: {},
1865
+ pvcs: {},
1866
+ });
1867
+ const provisioner = new KubernetesAgentProvisioner(
1868
+ k8s,
1869
+ stubTokens() as AgentTokenService,
1870
+ config,
1871
+ );
1872
+ await provisioner.reconcile([{ id: agentId, ...target }]);
1873
+ const dep = await k8s.getDeployment(NAMESPACE, resourceName);
1874
+ return dep.spec.template.spec.serviceAccountName;
1875
+ }
1876
+
1877
+ it("renders the Agent row value when the flag is on", async () => {
1878
+ expect(
1879
+ await desiredSa(
1880
+ { ...BASE_CONFIG, podServiceAccount: { enabled: true } },
1881
+ { podServiceAccountName: "agent-sa" },
1882
+ ),
1883
+ ).toBe("agent-sa");
1884
+ });
1885
+
1886
+ it("never renders the Agent row value when the flag is off", async () => {
1887
+ expect(
1888
+ await desiredSa(
1889
+ { ...BASE_CONFIG, podServiceAccount: { enabled: false } },
1890
+ { podServiceAccountName: "agent-sa" },
1891
+ ),
1892
+ ).toBeUndefined();
1893
+ });
1894
+
1895
+ it("never renders the value when no flag config is supplied", async () => {
1896
+ expect(
1897
+ await desiredSa(BASE_CONFIG, { podServiceAccountName: "agent-sa" }),
1898
+ ).toBeUndefined();
1899
+ });
1900
+ });
package/src/agents.ts CHANGED
@@ -76,6 +76,8 @@ export interface AgentRecord {
76
76
  * strips them from its response.
77
77
  */
78
78
  export interface AgentSummary extends Partial<AgentAnthropicIdentity> {
79
+ /** COS-48.1 pod ServiceAccount (read by reconcile when the flag is on). */
80
+ podServiceAccountName?: string | null;
79
81
  id: string;
80
82
  name: string;
81
83
  selfHosted: boolean;
@@ -255,6 +257,7 @@ const SUMMARY_SELECT = {
255
257
  anthropicServiceAccountId: true,
256
258
  anthropicFederationRuleId: true,
257
259
  k8sServiceAccountName: true,
260
+ podServiceAccountName: true,
258
261
  } as const;
259
262
 
260
263
  const DETAIL_SELECT = {
@@ -287,6 +290,7 @@ const DETAIL_SELECT = {
287
290
  anthropicServiceAccountId: true,
288
291
  anthropicFederationRuleId: true,
289
292
  k8sServiceAccountName: true,
293
+ podServiceAccountName: true,
290
294
  } as const;
291
295
 
292
296
  // ─── Service ──────────────────────────────────────────────────────────────────
@@ -79,6 +79,25 @@ describe("resolvePublicRepo", () => {
79
79
  // ─── buildProvisioner ───────────────────────────────────────────────────────
80
80
 
81
81
  describe("buildProvisioner", () => {
82
+ it("wires the pod ServiceAccount flag into the provisioner config", () => {
83
+ const on = buildProvisioner(
84
+ {
85
+ SHIPWRIGHT_K8S_PROVISIONING: "enabled",
86
+ SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED: "enabled",
87
+ },
88
+ stubAgentTokenService(),
89
+ );
90
+ expect(configOf(on).podServiceAccount).toEqual({ enabled: true });
91
+ });
92
+
93
+ it("leaves the pod ServiceAccount flag off by default", () => {
94
+ const off = buildProvisioner(
95
+ { SHIPWRIGHT_K8S_PROVISIONING: "enabled" },
96
+ stubAgentTokenService(),
97
+ );
98
+ expect(configOf(off).podServiceAccount).toEqual({ enabled: false });
99
+ });
100
+
82
101
  it("returns a NoopAgentProvisioner when SHIPWRIGHT_K8S_PROVISIONING is unset", () => {
83
102
  const provisioner = buildProvisioner({}, stubAgentTokenService());
84
103
  expect(provisioner).toBeInstanceOf(NoopAgentProvisioner);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * admin/src/pod-service-account-config.ts
3
+ *
4
+ * Pure parser for the per-agent pod ServiceAccount flag (COS-48.1). Takes an
5
+ * injected env object (no process.env reads) so it is deterministically
6
+ * unit-testable; admin-boot-config threads the resolved config into the
7
+ * provisioner. Same "enabled" convention as agent-ask-config.ts.
8
+ */
9
+
10
+ /** Structural env shape — accepts `process.env` or a plain test object. */
11
+ type PodServiceAccountEnv = Readonly<Record<string, string | undefined>>;
12
+
13
+ export interface PodServiceAccountConfig {
14
+ /** True only when SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED === "enabled". */
15
+ enabled: boolean;
16
+ }
17
+
18
+ export function parsePodServiceAccountConfig(
19
+ env: PodServiceAccountEnv,
20
+ ): PodServiceAccountConfig {
21
+ return {
22
+ enabled: env.SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED === "enabled",
23
+ };
24
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * admin/src/pod-service-account-config.unit.test.ts
3
+ * Pure parser tests for the per-agent pod ServiceAccount flag (COS-48.1).
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { parsePodServiceAccountConfig } from "./pod-service-account-config.ts";
8
+
9
+ describe("parsePodServiceAccountConfig", () => {
10
+ it('is on only for the literal "enabled"', () => {
11
+ expect(
12
+ parsePodServiceAccountConfig({
13
+ SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED: "enabled",
14
+ }).enabled,
15
+ ).toBe(true);
16
+ });
17
+
18
+ it('"true", "1", "Enabled", padded, empty and absent are off', () => {
19
+ for (const value of ["true", "1", "Enabled", " enabled", "", undefined]) {
20
+ expect(
21
+ parsePodServiceAccountConfig({
22
+ SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED: value,
23
+ }).enabled,
24
+ ).toBe(false);
25
+ }
26
+ expect(parsePodServiceAccountConfig({}).enabled).toBe(false);
27
+ });
28
+ });
@@ -50,6 +50,7 @@ export function managedReconcileTargets(
50
50
  anthropicServiceAccountId: a.anthropicServiceAccountId,
51
51
  anthropicFederationRuleId: a.anthropicFederationRuleId,
52
52
  k8sServiceAccountName: a.k8sServiceAccountName,
53
+ podServiceAccountName: a.podServiceAccountName,
53
54
  }));
54
55
  }
55
56