@app-vitals/agent-admin 0.0.0-stage → 0.1.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.
Files changed (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +64 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,1245 @@
1
+ /**
2
+ * admin/src/agent-provisioner.integration.test.ts
3
+ * Integration tests for KubernetesAgentProvisioner.
4
+ *
5
+ * Composes the REAL AgentTokenService against a real Postgres test DB with an
6
+ * injected in-memory KubernetesClient (RecordedKubernetesClient) standing in
7
+ * for the cluster. No globals are overridden — the fake is injected via the
8
+ * constructor.
9
+ *
10
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise (CI provides it).
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
14
+ import { readFileSync } from "node:fs";
15
+ import type { PrismaClient } from "../prisma/client/client.ts";
16
+ import {
17
+ type DeleteAgentFullyDeps,
18
+ deleteAgentFully,
19
+ } from "./agent-deletion.ts";
20
+ import {
21
+ buildAgentSecretManifest,
22
+ sanitizeAgentName,
23
+ } from "./agent-manifest.ts";
24
+ import {
25
+ type AgentProvisioner,
26
+ KubernetesAgentProvisioner,
27
+ type KubernetesAgentProvisionerConfig,
28
+ NoopAgentProvisioner,
29
+ } from "./agent-provisioner.ts";
30
+ import { AgentTokenService } from "./agent-tokens.ts";
31
+ import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
32
+ import { ConflictError } from "./errors.ts";
33
+ import {
34
+ type DeploymentSpec,
35
+ HttpKubernetesClient,
36
+ type KubernetesClient,
37
+ type KubernetesDeployment,
38
+ type KubernetesSecret,
39
+ type PvcSpec,
40
+ RecordedKubernetesClient,
41
+ type SecretSpec,
42
+ } from "./kubernetes-client.ts";
43
+ import { createAdminPrismaClient } from "./prisma-client.ts";
44
+ import type {
45
+ DeletionServiceIntegration,
46
+ ServiceIntegration,
47
+ } from "./service-integration.ts";
48
+ import type { SlackProvisioningClient } from "./slack-provisioning-client.ts";
49
+
50
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
51
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
52
+
53
+ const NAMESPACE = "shipwright";
54
+
55
+ const CONFIG: KubernetesAgentProvisionerConfig = {
56
+ namespace: NAMESPACE,
57
+ image: "ghcr.io/app-vitals/shipwright-agent",
58
+ imageTag: "v1.2.3",
59
+ apiUrl: "http://shipwright-admin.shipwright.svc:3001",
60
+ };
61
+
62
+ function makePrisma(): PrismaClient {
63
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
64
+ return createAdminPrismaClient(TEST_DB as string);
65
+ }
66
+
67
+ function emptyClient(): RecordedKubernetesClient {
68
+ return new RecordedKubernetesClient({
69
+ deployments: {},
70
+ secrets: {},
71
+ pvcs: {},
72
+ });
73
+ }
74
+
75
+ /** Decode the token value the Secret carries under the default "token" key. */
76
+ function decodeSecretToken(secret: KubernetesSecret): string {
77
+ return Buffer.from(secret.data.token, "base64").toString("utf-8");
78
+ }
79
+
80
+ describeOrSkip("KubernetesAgentProvisioner (integration)", () => {
81
+ let prisma: PrismaClient;
82
+ let tokens: AgentTokenService;
83
+
84
+ beforeEach(async () => {
85
+ prisma = makePrisma();
86
+ await prisma.agentToken.deleteMany();
87
+ await prisma.agentCronJob.deleteMany();
88
+ await prisma.agentTool.deleteMany();
89
+ await prisma.agentEnv.deleteMany();
90
+ await prisma.agent.deleteMany();
91
+ tokens = new AgentTokenService(prisma);
92
+ });
93
+
94
+ afterEach(async () => {
95
+ await prisma.$disconnect();
96
+ });
97
+
98
+ async function createAgent(name = "Test Agent"): Promise<string> {
99
+ const agent = await prisma.agent.create({ data: { name } });
100
+ return agent.id;
101
+ }
102
+
103
+ // ─── provision ──────────────────────────────────────────────────────────────
104
+
105
+ it("provision() mints a token, then creates the Secret and Deployment", async () => {
106
+ const agentId = await createAgent();
107
+ const k8s = emptyClient();
108
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
109
+
110
+ const result = await provisioner.provision(agentId);
111
+
112
+ const name = sanitizeAgentName(agentId);
113
+ expect(result.resourceName).toBe(name);
114
+ expect(result.secretName).toBe(`${name}-token`);
115
+ expect(result.deploymentName).toBe(name);
116
+ expect(result.rawToken).toBeDefined();
117
+
118
+ // Secret carries the minted token, and the token validates via the service.
119
+ const secret = await k8s.getSecret(NAMESPACE, `${name}-token`);
120
+ const carried = decodeSecretToken(secret);
121
+ expect(result.rawToken).toBeDefined();
122
+ expect(carried).toBe(result.rawToken as string);
123
+ const validated = await tokens.validate(carried);
124
+ expect(validated?.agentId).toBe(agentId);
125
+
126
+ // Deployment exists and points at the right image.
127
+ const dep = await k8s.getDeployment(NAMESPACE, name);
128
+ expect(dep.spec.template.spec.containers[0].image).toBe(
129
+ "ghcr.io/app-vitals/shipwright-agent:v1.2.3",
130
+ );
131
+ });
132
+
133
+ it("provision() creates the Secret BEFORE the Deployment (order)", async () => {
134
+ const agentId = await createAgent();
135
+ const order: string[] = [];
136
+ const recorded = emptyClient();
137
+ const ordered: KubernetesClient = {
138
+ createSecret: (ns: string, spec: SecretSpec) => {
139
+ order.push("secret");
140
+ return recorded.createSecret(ns, spec);
141
+ },
142
+ createDeployment: (ns: string, spec: DeploymentSpec) => {
143
+ return recorded.createDeployment(ns, spec);
144
+ },
145
+ createDeploymentManifest: (
146
+ ns: string,
147
+ manifest: KubernetesDeployment,
148
+ ) => {
149
+ order.push("deployment");
150
+ return recorded.createDeploymentManifest(ns, manifest);
151
+ },
152
+ createPvc: (ns: string, spec: PvcSpec) => {
153
+ order.push("pvc");
154
+ return recorded.createPvc(ns, spec);
155
+ },
156
+ getSecret: (ns, n) => recorded.getSecret(ns, n),
157
+ getDeployment: (ns, n) => recorded.getDeployment(ns, n),
158
+ getPvc: (ns, n) => recorded.getPvc(ns, n),
159
+ deleteSecret: (ns, n) => recorded.deleteSecret(ns, n),
160
+ deleteDeployment: (ns, n) => recorded.deleteDeployment(ns, n),
161
+ deletePvc: (ns, n) => recorded.deletePvc(ns, n),
162
+ createServiceAccount: (ns, spec) =>
163
+ recorded.createServiceAccount(ns, spec),
164
+ getServiceAccount: (ns, n) => recorded.getServiceAccount(ns, n),
165
+ deleteServiceAccount: (ns, n) => recorded.deleteServiceAccount(ns, n),
166
+ deploymentExists: (ns, n) => recorded.deploymentExists(ns, n),
167
+ listDeployments: (ns, sel) => recorded.listDeployments(ns, sel),
168
+ patchDeployment: (ns, n, p) => recorded.patchDeployment(ns, n, p),
169
+ };
170
+ const provisioner = new KubernetesAgentProvisioner(ordered, tokens, CONFIG);
171
+
172
+ await provisioner.provision(agentId);
173
+ expect(order).toEqual(["pvc", "secret", "deployment"]);
174
+ });
175
+
176
+ it("deprovision() deletes Deployment, Secret, and PVC", async () => {
177
+ const agentId = await createAgent();
178
+ const k8s = emptyClient();
179
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
180
+
181
+ await provisioner.provision(agentId);
182
+ await provisioner.deprovision(agentId);
183
+
184
+ const name = sanitizeAgentName(agentId);
185
+ // Deployment, Secret, and PVC must all be gone — a deliberate full agent
186
+ // delete (unlike provision()'s rollback path) also removes the PVC so
187
+ // deleted agents don't leak storage indefinitely.
188
+ await expect(k8s.getDeployment(NAMESPACE, name)).rejects.toThrow();
189
+ await expect(k8s.getSecret(NAMESPACE, `${name}-token`)).rejects.toThrow();
190
+ await expect(k8s.getPvc(NAMESPACE, `${name}-home`)).rejects.toThrow();
191
+ });
192
+
193
+ it("provision() is safe to retry (provision twice → no throw)", async () => {
194
+ const agentId = await createAgent();
195
+ const k8s = emptyClient();
196
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
197
+
198
+ await provisioner.provision(agentId);
199
+ // Second call hits ConflictError on both create calls; must not throw.
200
+ await expect(provisioner.provision(agentId)).resolves.toBeDefined();
201
+
202
+ const name = sanitizeAgentName(agentId);
203
+ // Resources still present after the retry.
204
+ await expect(
205
+ k8s.getSecret(NAMESPACE, `${name}-token`),
206
+ ).resolves.toBeDefined();
207
+ await expect(k8s.getDeployment(NAMESPACE, name)).resolves.toBeDefined();
208
+ });
209
+
210
+ // ─── failure / rollback ───────────────────────────────────────────────────────
211
+
212
+ it("provision() rolls back the Secret if Deployment creation fails", async () => {
213
+ const agentId = await createAgent();
214
+ const recorded = emptyClient();
215
+ const failing: KubernetesClient = {
216
+ createPvc: (ns, spec) => recorded.createPvc(ns, spec),
217
+ createSecret: (ns, spec) => recorded.createSecret(ns, spec),
218
+ createDeployment: async () => {
219
+ throw new Error("simulated API server failure");
220
+ },
221
+ createDeploymentManifest: async () => {
222
+ throw new Error("simulated API server failure");
223
+ },
224
+ getSecret: (ns, n) => recorded.getSecret(ns, n),
225
+ getDeployment: (ns, n) => recorded.getDeployment(ns, n),
226
+ getPvc: (ns, n) => recorded.getPvc(ns, n),
227
+ deleteSecret: (ns, n) => recorded.deleteSecret(ns, n),
228
+ deleteDeployment: (ns, n) => recorded.deleteDeployment(ns, n),
229
+ deletePvc: (ns, n) => recorded.deletePvc(ns, n),
230
+ createServiceAccount: (ns, spec) =>
231
+ recorded.createServiceAccount(ns, spec),
232
+ getServiceAccount: (ns, n) => recorded.getServiceAccount(ns, n),
233
+ deleteServiceAccount: (ns, n) => recorded.deleteServiceAccount(ns, n),
234
+ deploymentExists: (ns, n) => recorded.deploymentExists(ns, n),
235
+ listDeployments: (ns, sel) => recorded.listDeployments(ns, sel),
236
+ patchDeployment: (ns, n, p) => recorded.patchDeployment(ns, n, p),
237
+ };
238
+ const provisioner = new KubernetesAgentProvisioner(failing, tokens, CONFIG);
239
+
240
+ await expect(provisioner.provision(agentId)).rejects.toThrow(
241
+ "simulated API server failure",
242
+ );
243
+
244
+ const name = sanitizeAgentName(agentId);
245
+ // Secret must have been cleaned up so a retry starts clean.
246
+ await expect(
247
+ recorded.getSecret(NAMESPACE, `${name}-token`),
248
+ ).rejects.toThrow();
249
+ });
250
+
251
+ it("provision() rolls back the freshly-minted Secret when the Deployment already exists (409)", async () => {
252
+ const agentId = await createAgent();
253
+ const name = sanitizeAgentName(agentId);
254
+ // Seed ONLY the Deployment — the Secret does NOT pre-exist. This call mints
255
+ // a token and creates a new Secret, then hits a 409 on the Deployment. The
256
+ // orphaned Secret (carrying a fresh, unused token) must be rolled back.
257
+ const k8s = emptyClient();
258
+ await k8s.createDeployment(NAMESPACE, {
259
+ name,
260
+ image: `${CONFIG.image}:${CONFIG.imageTag}`,
261
+ });
262
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
263
+
264
+ // (a) Idempotent success — does NOT throw despite the Deployment conflict.
265
+ await expect(provisioner.provision(agentId)).resolves.toBeDefined();
266
+
267
+ // (b) The Secret created during THIS call was deleted, not left behind.
268
+ await expect(k8s.getSecret(NAMESPACE, `${name}-token`)).rejects.toThrow();
269
+ });
270
+
271
+ it("provision() succeeds idempotently when both resources already exist", async () => {
272
+ const agentId = await createAgent();
273
+ const name = sanitizeAgentName(agentId);
274
+ // Pre-seed both resources as if a prior provision had completed.
275
+ const seededSecret = buildAgentSecretManifest({
276
+ name: `${name}-token`,
277
+ namespace: NAMESPACE,
278
+ token: "preexisting",
279
+ });
280
+ const k8s = new RecordedKubernetesClient({
281
+ deployments: {},
282
+ secrets: { [`${NAMESPACE}/${name}-token`]: seededSecret },
283
+ });
284
+ // Pre-create the deployment too.
285
+ await k8s.createDeployment(NAMESPACE, {
286
+ name,
287
+ image: `${CONFIG.image}:${CONFIG.imageTag}`,
288
+ });
289
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
290
+
291
+ await expect(provisioner.provision(agentId)).resolves.toBeDefined();
292
+ });
293
+
294
+ // ─── reconcile ────────────────────────────────────────────────────────────────
295
+
296
+ it("reconcile() returns empty arrays when there are no agents and no k8s deployments", async () => {
297
+ const k8s = emptyClient();
298
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
299
+ const result = await provisioner.reconcile([]);
300
+ expect(result).toEqual({
301
+ recreated: [],
302
+ migrated: [],
303
+ noOwner: [],
304
+ dryRun: false,
305
+ updated: [],
306
+ orphans: [],
307
+ failed: [],
308
+ });
309
+ });
310
+
311
+ it("reconcile() recreates a Deployment for a known agent missing from K8s", async () => {
312
+ const agentId = await createAgent("ReconcileAgent");
313
+ const k8s = emptyClient();
314
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
315
+
316
+ // Agent exists in DB, but no k8s Deployment provisioned yet.
317
+ const result = await provisioner.reconcile([{ id: agentId }]);
318
+
319
+ expect(result.orphans).toEqual([]);
320
+ expect(result.recreated).toEqual([agentId]);
321
+
322
+ // The deployment was actually created in k8s.
323
+ const name = sanitizeAgentName(agentId);
324
+ await expect(k8s.getDeployment(NAMESPACE, name)).resolves.toBeDefined();
325
+ });
326
+
327
+ it("reconcile() reports orphan deployments not in the known agent list", async () => {
328
+ const k8s = emptyClient();
329
+ // Pre-seed an orphaned deployment in k8s (not tied to any DB agent).
330
+ const orphanName = sanitizeAgentName("orphaned-agent-id-xyz");
331
+ await k8s.createDeployment(NAMESPACE, {
332
+ name: orphanName,
333
+ image: `${CONFIG.image}:${CONFIG.imageTag}`,
334
+ labels: {
335
+ "app.kubernetes.io/name": "shipwright-agent",
336
+ "app.kubernetes.io/managed-by": "shipwright-admin",
337
+ },
338
+ });
339
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
340
+
341
+ // No known agents — the k8s deployment is an orphan.
342
+ const result = await provisioner.reconcile([]);
343
+
344
+ expect(result.recreated).toEqual([]);
345
+ expect(result.orphans).toEqual([orphanName]);
346
+ });
347
+
348
+ it("reconcile() returns no recreated/orphans when everything matches", async () => {
349
+ const agentId = await createAgent("MatchingAgent");
350
+ const k8s = emptyClient();
351
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
352
+
353
+ // First provision it properly.
354
+ await provisioner.provision(agentId);
355
+
356
+ // Now reconcile — should be a clean no-op.
357
+ const result = await provisioner.reconcile([{ id: agentId }]);
358
+ expect(result.recreated).toEqual([]);
359
+ expect(result.orphans).toEqual([]);
360
+ });
361
+
362
+ it("reconcile() detects a stale image, patches the deployment, and adds to updated[]", async () => {
363
+ const agentId = await createAgent("StaleImageAgent");
364
+ const resourceName = sanitizeAgentName(agentId);
365
+ const k8s = emptyClient();
366
+
367
+ // Seed a deployment with a stale image directly in the cassette.
368
+ const staleDeployment: KubernetesDeployment = {
369
+ apiVersion: "apps/v1",
370
+ kind: "Deployment",
371
+ metadata: { name: resourceName, namespace: NAMESPACE },
372
+ spec: {
373
+ replicas: 1,
374
+ selector: { matchLabels: { app: resourceName } },
375
+ template: {
376
+ metadata: { labels: { app: resourceName } },
377
+ spec: {
378
+ containers: [
379
+ {
380
+ name: "shipwright-agent",
381
+ image: `${CONFIG.image}:v0.0.1`, // stale — CONFIG.imageTag is v1.2.3
382
+ },
383
+ ],
384
+ },
385
+ },
386
+ },
387
+ };
388
+ // Inject directly via the internal map by provisioning with a fake client,
389
+ // then overwriting through createDeploymentManifest on an empty client.
390
+ await k8s.createDeploymentManifest(NAMESPACE, staleDeployment);
391
+
392
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
393
+
394
+ const result = await provisioner.reconcile([{ id: agentId }]);
395
+
396
+ expect(result.updated).toEqual([agentId]);
397
+ expect(result.recreated).toEqual([]);
398
+ expect(result.failed).toEqual([]);
399
+
400
+ // RecordedKubernetesClient mutates containers[0].image on patchDeployment.
401
+ const dep = await k8s.getDeployment(NAMESPACE, resourceName);
402
+ expect(dep.spec.template.spec.containers[0].image).toBe(
403
+ `${CONFIG.image}:${CONFIG.imageTag}`,
404
+ );
405
+ });
406
+
407
+ // ─── PVC name template ───────────────────────────────────────────────────────
408
+
409
+ it("provision() uses slug for PVC name when pvcName template is configured", async () => {
410
+ const agentId = await createAgent("OkWOW Agent");
411
+ const k8s = emptyClient();
412
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, {
413
+ ...CONFIG,
414
+ pvcName: (name) => `acme-agent-${name}-home`,
415
+ });
416
+
417
+ await provisioner.provision(agentId, { slug: "okwow" });
418
+
419
+ // PVC must use the slug, not the sanitized agentId.
420
+ await expect(
421
+ k8s.getPvc(NAMESPACE, "acme-agent-okwow-home"),
422
+ ).resolves.toBeDefined();
423
+ // Default-named PVC must NOT exist.
424
+ const resourceName = sanitizeAgentName(agentId);
425
+ await expect(
426
+ k8s.getPvc(NAMESPACE, `acme-agent-${resourceName}-home`),
427
+ ).rejects.toThrow();
428
+ });
429
+
430
+ it("provision() falls back to resourceName when slug is absent and pvcName is configured", async () => {
431
+ const agentId = await createAgent("FallbackAgent");
432
+ const k8s = emptyClient();
433
+ const resourceName = sanitizeAgentName(agentId);
434
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, {
435
+ ...CONFIG,
436
+ pvcName: (name) => `acme-agent-${name}-home`,
437
+ });
438
+
439
+ // Call provision() with no opts — slug is absent.
440
+ await provisioner.provision(agentId);
441
+
442
+ // PVC must use the sanitized resourceName (not a slug).
443
+ await expect(
444
+ k8s.getPvc(NAMESPACE, `acme-agent-${resourceName}-home`),
445
+ ).resolves.toBeDefined();
446
+ });
447
+
448
+ it("provision() sanitizes an unsafe slug before passing to pvcName template", async () => {
449
+ const agentId = await createAgent("Uppercase Spaces Agent");
450
+ const k8s = emptyClient();
451
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, {
452
+ ...CONFIG,
453
+ pvcName: (name) => `acme-agent-${name}-home`,
454
+ });
455
+
456
+ // slug contains uppercase and spaces — must be RFC1123 sanitized.
457
+ await provisioner.provision(agentId, { slug: "My Agent" });
458
+
459
+ // sanitizeAgentName("My Agent") → "my-agent-<hash>" (lossy path → hash suffix)
460
+ const sanitized = sanitizeAgentName("My Agent");
461
+ await expect(
462
+ k8s.getPvc(NAMESPACE, `acme-agent-${sanitized}-home`),
463
+ ).resolves.toBeDefined();
464
+ // Raw unsanitized PVC name must NOT exist.
465
+ await expect(
466
+ k8s.getPvc(NAMESPACE, "acme-agent-My Agent-home"),
467
+ ).rejects.toThrow();
468
+ });
469
+
470
+ it("reconcile() uses slug for PVC name when template is configured", async () => {
471
+ const agentId = await createAgent("ReconcileSlugAgent");
472
+ const k8s = emptyClient();
473
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, {
474
+ ...CONFIG,
475
+ pvcName: (name) => `acme-agent-${name}-home`,
476
+ });
477
+
478
+ // Agent exists in DB, no k8s Deployment — reconcile should provision it.
479
+ const result = await provisioner.reconcile([
480
+ { id: agentId, slug: "okwow" },
481
+ ]);
482
+
483
+ expect(result.recreated).toEqual([agentId]);
484
+ // PVC must use the slug passed through reconcile.
485
+ await expect(
486
+ k8s.getPvc(NAMESPACE, "acme-agent-okwow-home"),
487
+ ).resolves.toBeDefined();
488
+ });
489
+
490
+ // ─── deprovision ──────────────────────────────────────────────────────────────
491
+
492
+ it("deprovision() deletes both the Deployment and the Secret", async () => {
493
+ const agentId = await createAgent();
494
+ const k8s = emptyClient();
495
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
496
+
497
+ await provisioner.provision(agentId);
498
+ await provisioner.deprovision(agentId);
499
+
500
+ const name = sanitizeAgentName(agentId);
501
+ await expect(k8s.getSecret(NAMESPACE, `${name}-token`)).rejects.toThrow();
502
+ await expect(k8s.getDeployment(NAMESPACE, name)).rejects.toThrow();
503
+ await expect(k8s.getPvc(NAMESPACE, `${name}-home`)).rejects.toThrow();
504
+ });
505
+
506
+ it("deprovision() tolerates already-absent resources (no throw)", async () => {
507
+ const agentId = await createAgent();
508
+ const k8s = emptyClient();
509
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
510
+
511
+ // Nothing provisioned — deprovision must be a no-op.
512
+ await expect(provisioner.deprovision(agentId)).resolves.toBeUndefined();
513
+ // And a second deprovision after a real one is still a no-op.
514
+ await provisioner.provision(agentId);
515
+ await provisioner.deprovision(agentId);
516
+ await expect(provisioner.deprovision(agentId)).resolves.toBeUndefined();
517
+ });
518
+
519
+ it("deprovision() tolerates an already-absent PVC (idempotent re-delete, no throw)", async () => {
520
+ const agentId = await createAgent();
521
+ const k8s = emptyClient();
522
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, CONFIG);
523
+
524
+ // Provision, then deprovision once — the PVC is deleted along with the
525
+ // Deployment and Secret. A second deprovision call must not throw even
526
+ // though the PVC (and everything else) is already gone (404 swallowed).
527
+ await provisioner.provision(agentId);
528
+ await provisioner.deprovision(agentId);
529
+
530
+ const name = sanitizeAgentName(agentId);
531
+ await expect(k8s.getPvc(NAMESPACE, `${name}-home`)).rejects.toThrow();
532
+ await expect(provisioner.deprovision(agentId)).resolves.toBeUndefined();
533
+ });
534
+
535
+ it("deprovision() deletes the templated PVC name derived from slug", async () => {
536
+ const agentId = await createAgent("OkWOW Agent");
537
+ const k8s = emptyClient();
538
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, {
539
+ ...CONFIG,
540
+ pvcName: (name) => `acme-agent-${name}-home`,
541
+ });
542
+
543
+ await provisioner.provision(agentId, { slug: "okwow" });
544
+ await expect(
545
+ k8s.getPvc(NAMESPACE, "acme-agent-okwow-home"),
546
+ ).resolves.toBeDefined();
547
+
548
+ await provisioner.deprovision(agentId, { slug: "okwow" });
549
+
550
+ await expect(
551
+ k8s.getPvc(NAMESPACE, "acme-agent-okwow-home"),
552
+ ).rejects.toThrow();
553
+ });
554
+
555
+ // ─── task-store token in Secret ──────────────────────────────────────────────
556
+
557
+ it("provision() stores task-store token in the Secret under 'task-store-token' key", async () => {
558
+ const agentId = await createAgent("TaskStoreAgent");
559
+ const k8s = emptyClient();
560
+
561
+ const EXPECTED_RAW_TOKEN = "ts-raw-token-abc123";
562
+
563
+ const fakeTaskStore: ServiceIntegration = {
564
+ name: "task-store",
565
+ tokenEnv: "SHIPWRIGHT_TASK_STORE_TOKEN",
566
+ urlEnv: "SHIPWRIGHT_TASK_STORE_URL",
567
+ secretKey: "task-store-token",
568
+ url: "http://task-store.svc:3002",
569
+ async mintToken(_label: string) {
570
+ return { id: "ts-id-1", rawToken: EXPECTED_RAW_TOKEN };
571
+ },
572
+ async revokeToken(_id: string) {
573
+ // no-op in this test
574
+ },
575
+ async listTokensForAgent(_agentId: string) {
576
+ return [];
577
+ },
578
+ };
579
+
580
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, {
581
+ ...CONFIG,
582
+ serviceIntegrations: [fakeTaskStore],
583
+ });
584
+
585
+ await provisioner.provision(agentId);
586
+
587
+ const name = sanitizeAgentName(agentId);
588
+ const secret = await k8s.getSecret(NAMESPACE, `${name}-token`);
589
+
590
+ // The 'task-store-token' key must be present in the Secret's data.
591
+ const encoded = secret.data["task-store-token"];
592
+ expect(encoded).toBeDefined();
593
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
594
+ expect(decoded).toBe(EXPECTED_RAW_TOKEN);
595
+ });
596
+ });
597
+
598
+ // ─── Composed provisioning → deletion journey (T-006) ──────────────────────────
599
+ //
600
+ // Exercises KubernetesAgentProvisioner.provision() and deleteAgentFully() TOGETHER
601
+ // in one run, across all 5 injected external clients (K8s, task-store, chat
602
+ // service, Slack, DB/Prisma). A single shared `order: string[]` array is wired
603
+ // into every fake so the whole journey's call order can be asserted in one
604
+ // place, proving both documented invariants:
605
+ // - provision(): token → Secret → Deployment (module docstring above).
606
+ // - deleteAgentFully(): the Agent DB row is deleted LAST (agent-deletion.ts).
607
+
608
+ describeOrSkip(
609
+ "agent provisioning journey (provision → delete, integration)",
610
+ () => {
611
+ let prisma: PrismaClient;
612
+ let tokens: AgentTokenService;
613
+
614
+ beforeEach(async () => {
615
+ prisma = makePrisma();
616
+ await prisma.agentToken.deleteMany();
617
+ await prisma.agentCronJob.deleteMany();
618
+ await prisma.agentTool.deleteMany();
619
+ await prisma.agentEnv.deleteMany();
620
+ await prisma.agent.deleteMany();
621
+ tokens = new AgentTokenService(prisma);
622
+ });
623
+
624
+ afterEach(async () => {
625
+ await prisma.$disconnect();
626
+ });
627
+
628
+ async function createAgentWithSlackEnv(
629
+ name = "Journey Agent",
630
+ ): Promise<string> {
631
+ const agent = await prisma.agent.create({ data: { name } });
632
+ // Plaintext SLACK_APP_ID — identity `decrypt` below treats env values as
633
+ // unencrypted in this test, matching agent-deletion.unit.test.ts's FakePrisma
634
+ // convention, so the Slack deletion branch is exercised end-to-end.
635
+ await prisma.agentEnv.create({
636
+ data: {
637
+ agentId: agent.id,
638
+ key: "SLACK_APP_ID",
639
+ value: "A-JOURNEY-APP",
640
+ secret: true,
641
+ },
642
+ });
643
+ return agent.id;
644
+ }
645
+
646
+ /**
647
+ * Wrap a RecordedKubernetesClient so createSecret / createDeploymentManifest
648
+ * push into the shared `order` array — same pattern as the standalone
649
+ * "provision() creates the Secret BEFORE the Deployment (order)" test above,
650
+ * but this instance is reused for BOTH provision() (via the full
651
+ * KubernetesClient interface) and deprovision() (via the narrower
652
+ * Pick<AgentProvisioner, "deprovision"> deps.provisioner in deleteAgentFully).
653
+ */
654
+ function orderedK8s(
655
+ order: string[],
656
+ recorded: RecordedKubernetesClient,
657
+ ): KubernetesClient {
658
+ return {
659
+ createPvc: (ns: string, spec: PvcSpec) => recorded.createPvc(ns, spec),
660
+ createSecret: (ns: string, spec: SecretSpec) => {
661
+ order.push("k8s:secret");
662
+ return recorded.createSecret(ns, spec);
663
+ },
664
+ createDeployment: (ns: string, spec: DeploymentSpec) =>
665
+ recorded.createDeployment(ns, spec),
666
+ createDeploymentManifest: (
667
+ ns: string,
668
+ manifest: KubernetesDeployment,
669
+ ) => {
670
+ order.push("k8s:deployment");
671
+ return recorded.createDeploymentManifest(ns, manifest);
672
+ },
673
+ getSecret: (ns, n) => recorded.getSecret(ns, n),
674
+ getDeployment: (ns, n) => recorded.getDeployment(ns, n),
675
+ getPvc: (ns, n) => recorded.getPvc(ns, n),
676
+ deleteSecret: (ns, n) => recorded.deleteSecret(ns, n),
677
+ deleteDeployment: (ns, n) => recorded.deleteDeployment(ns, n),
678
+ deletePvc: (ns, n) => recorded.deletePvc(ns, n),
679
+ createServiceAccount: (ns, spec) =>
680
+ recorded.createServiceAccount(ns, spec),
681
+ getServiceAccount: (ns, n) => recorded.getServiceAccount(ns, n),
682
+ deleteServiceAccount: (ns, n) => recorded.deleteServiceAccount(ns, n),
683
+ deploymentExists: (ns, n) => recorded.deploymentExists(ns, n),
684
+ listDeployments: (ns, sel) => recorded.listDeployments(ns, sel),
685
+ patchDeployment: (ns, n, p) => recorded.patchDeployment(ns, n, p),
686
+ };
687
+ }
688
+
689
+ /** Fake task-store ServiceIntegration that records into the shared order array. */
690
+ function orderedTaskStore(
691
+ order: string[],
692
+ opts: { revokeError?: Error } = {},
693
+ ): ServiceIntegration {
694
+ return {
695
+ name: "task-store",
696
+ tokenEnv: "SHIPWRIGHT_TASK_STORE_TOKEN",
697
+ urlEnv: "SHIPWRIGHT_TASK_STORE_URL",
698
+ secretKey: "task-store-token",
699
+ url: "http://task-store.svc:3002",
700
+ async mintToken(_label: string, _agentId?: string) {
701
+ order.push("task-store:mintToken");
702
+ return { id: "ts-journey-id", rawToken: "ts-journey-raw" };
703
+ },
704
+ async revokeToken(_id: string) {
705
+ order.push("task-store:revoke");
706
+ if (opts.revokeError) throw opts.revokeError;
707
+ },
708
+ async listTokensForAgent(_agentId: string) {
709
+ return [{ id: "ts-journey-id" }];
710
+ },
711
+ };
712
+ }
713
+
714
+ /** Fake ChatServiceProvisioningClient that records into the shared order array. */
715
+ function orderedChatService(
716
+ order: string[],
717
+ opts: { revokeError?: Error; deleteThreadsError?: Error } = {},
718
+ ): ChatServiceProvisioningClient {
719
+ return {
720
+ async mintToken(_label: string, _agentId?: string) {
721
+ order.push("chat:mintToken");
722
+ return { id: "cs-journey-id", rawToken: "cs-journey-raw" };
723
+ },
724
+ async revokeToken(_id: string) {
725
+ order.push("chat:revoke");
726
+ if (opts.revokeError) throw opts.revokeError;
727
+ },
728
+ async listTokensForAgent(_agentId: string) {
729
+ return [{ id: "cs-journey-id" }];
730
+ },
731
+ async deleteThreadsForAgent(_agentId: string) {
732
+ order.push("chat:deleteThreads");
733
+ if (opts.deleteThreadsError) throw opts.deleteThreadsError;
734
+ return { deleted: 0 };
735
+ },
736
+ };
737
+ }
738
+
739
+ /** Fake SlackProvisioningClient (only deleteApp is used) recording into order. */
740
+ function orderedSlack(
741
+ order: string[],
742
+ opts: { deleteAppError?: Error } = {},
743
+ ): Pick<SlackProvisioningClient, "deleteApp"> {
744
+ return {
745
+ async deleteApp(_xoxpToken: string, _appId: string) {
746
+ order.push("slack:deleteApp");
747
+ if (opts.deleteAppError) throw opts.deleteAppError;
748
+ },
749
+ };
750
+ }
751
+
752
+ /** Build DeleteAgentFullyDeps around a shared order array + real Prisma. */
753
+ function makeDeleteDeps(
754
+ order: string[],
755
+ provisioner: Pick<AgentProvisioner, "deprovision">,
756
+ overrides: {
757
+ taskStore?: DeletionServiceIntegration;
758
+ chatService?: Pick<
759
+ ChatServiceProvisioningClient,
760
+ "listTokensForAgent" | "revokeToken" | "deleteThreadsForAgent"
761
+ >;
762
+ slack?: Pick<SlackProvisioningClient, "deleteApp">;
763
+ } = {},
764
+ ): DeleteAgentFullyDeps {
765
+ return {
766
+ prisma: {
767
+ agent: {
768
+ findUnique: async (args) => {
769
+ const row = await prisma.agent.findUnique({
770
+ where: args.where,
771
+ select: { id: true, name: true },
772
+ });
773
+ return row;
774
+ },
775
+ delete: async (args) => {
776
+ order.push("db:agent-delete");
777
+ return prisma.agent.delete({ where: args.where });
778
+ },
779
+ },
780
+ agentEnv: {
781
+ findMany: async (args) =>
782
+ prisma.agentEnv.findMany({
783
+ where: args.where,
784
+ select: { key: true, value: true, secret: true },
785
+ }),
786
+ },
787
+ },
788
+ provisioner: {
789
+ deprovision: async (agentId, opts) => {
790
+ order.push("k8s:deprovision");
791
+ await provisioner.deprovision(agentId, opts);
792
+ },
793
+ },
794
+ serviceIntegrations: [overrides.taskStore ?? orderedTaskStore(order)],
795
+ chatService: overrides.chatService ?? orderedChatService(order),
796
+ slack: overrides.slack ?? orderedSlack(order),
797
+ // Identity decrypt — SLACK_APP_ID is stored plaintext in this test.
798
+ decrypt: (value) => value,
799
+ };
800
+ }
801
+
802
+ it(
803
+ "composes provision() → deleteAgentFully() across all 5 external clients " +
804
+ "in documented order (token→Secret→Deployment, then DB row deleted LAST)",
805
+ async () => {
806
+ const order: string[] = [];
807
+ const agentId = await createAgentWithSlackEnv();
808
+ const recorded = emptyClient();
809
+ const k8s = orderedK8s(order, recorded);
810
+
811
+ // Wrap AgentTokenService.create so the real DB-backed token mint is
812
+ // captured in the shared order array too (positioned before k8s:secret).
813
+ const orderedTokens = {
814
+ create: async (id: string, label?: string) => {
815
+ const result = await tokens.create(id, label);
816
+ order.push("db:token");
817
+ return result;
818
+ },
819
+ } as AgentTokenService;
820
+
821
+ const provisioner = new KubernetesAgentProvisioner(k8s, orderedTokens, {
822
+ ...CONFIG,
823
+ serviceIntegrations: [orderedTaskStore(order)],
824
+ chatService: orderedChatService(order),
825
+ });
826
+
827
+ // ── Provision ────────────────────────────────────────────────────────
828
+ const provisionResult = await provisioner.provision(agentId);
829
+ expect(provisionResult.rawToken).toBeDefined();
830
+
831
+ // Documented order: token minting (task-store, chat, then the agent's
832
+ // own DB-backed token) happens BEFORE the Secret, which happens BEFORE
833
+ // the Deployment.
834
+ expect(order).toEqual([
835
+ "task-store:mintToken",
836
+ "chat:mintToken",
837
+ "db:token",
838
+ "k8s:secret",
839
+ "k8s:deployment",
840
+ ]);
841
+ const tokenIdx = order.indexOf("db:token");
842
+ const secretIdx = order.indexOf("k8s:secret");
843
+ const deploymentIdx = order.indexOf("k8s:deployment");
844
+ expect(tokenIdx).toBeLessThan(secretIdx);
845
+ expect(secretIdx).toBeLessThan(deploymentIdx);
846
+
847
+ // ── Delete ───────────────────────────────────────────────────────────
848
+ const deleteDeps = makeDeleteDeps(order, provisioner);
849
+ const deleteResult = await deleteAgentFully(agentId, deleteDeps, {
850
+ xoxpToken: "xoxp-journey-token",
851
+ });
852
+
853
+ expect(deleteResult.agentDeleted).toBe(true);
854
+ expect(deleteResult.failed).toEqual([]);
855
+
856
+ // The DB row delete is the LAST entry in the shared order array — proving
857
+ // "DB row deleted LAST" across the whole composed journey, not just
858
+ // within deleteAgentFully() in isolation.
859
+ expect(order[order.length - 1]).toBe("db:agent-delete");
860
+ expect(order).toContain("k8s:deprovision");
861
+ expect(order).toContain("task-store:revoke");
862
+ expect(order).toContain("chat:revoke");
863
+ expect(order).toContain("chat:deleteThreads");
864
+ expect(order).toContain("slack:deleteApp");
865
+
866
+ // The Agent row is actually gone from Postgres.
867
+ const remaining = await prisma.agent.findUnique({
868
+ where: { id: agentId },
869
+ });
870
+ expect(remaining).toBeNull();
871
+ },
872
+ );
873
+
874
+ it(
875
+ "retries deleteAgentFully() after a partial failure: row is preserved on " +
876
+ "failure, then deleted on a healthy re-run",
877
+ async () => {
878
+ const order: string[] = [];
879
+ const agentId = await createAgentWithSlackEnv("Retry Journey Agent");
880
+ const recorded = emptyClient();
881
+ const k8s = orderedK8s(order, recorded);
882
+ const provisioner = new KubernetesAgentProvisioner(k8s, tokens, {
883
+ ...CONFIG,
884
+ serviceIntegrations: [orderedTaskStore(order)],
885
+ chatService: orderedChatService(order),
886
+ });
887
+
888
+ await provisioner.provision(agentId);
889
+
890
+ // First delete call: chat-service revoke fails. The step is recorded as
891
+ // failed but every other step (k8s deprovision, task-store revoke, and
892
+ // the Slack app delete) still runs — only the DB row delete is gated.
893
+ const failingChatService = orderedChatService(order, {
894
+ revokeError: new Error("chat-service unavailable"),
895
+ });
896
+ const firstDeleteDeps = makeDeleteDeps(order, provisioner, {
897
+ chatService: failingChatService,
898
+ });
899
+
900
+ const firstResult = await deleteAgentFully(agentId, firstDeleteDeps, {
901
+ xoxpToken: "xoxp-journey-token",
902
+ });
903
+
904
+ expect(firstResult.agentDeleted).toBe(false);
905
+ expect(firstResult.failed).toEqual([
906
+ {
907
+ step: "chat-service-tokens-and-threads",
908
+ error: "chat-service unavailable",
909
+ },
910
+ ]);
911
+ expect(firstResult.completed).toContain("k8s");
912
+ expect(firstResult.completed).toContain("task-store-tokens");
913
+ expect(firstResult.completed).toContain("slack-app");
914
+ expect(order).not.toContain("db:agent-delete");
915
+
916
+ // Agent row still present in Postgres after the partial failure.
917
+ const stillThere = await prisma.agent.findUnique({
918
+ where: { id: agentId },
919
+ });
920
+ expect(stillThere).not.toBeNull();
921
+
922
+ // Second call: healthy chat-service this time. Underlying steps are all
923
+ // individually idempotent (k8s deprovision swallows 404, revoke on an
924
+ // already-revoked token is a no-op, thread delete tolerates 404), so the
925
+ // retry completes cleanly and the row is deleted last.
926
+ const healthyChatService = orderedChatService(order);
927
+ const secondDeleteDeps = makeDeleteDeps(order, provisioner, {
928
+ chatService: healthyChatService,
929
+ });
930
+
931
+ const secondResult = await deleteAgentFully(agentId, secondDeleteDeps, {
932
+ xoxpToken: "xoxp-journey-token",
933
+ });
934
+
935
+ expect(secondResult.agentDeleted).toBe(true);
936
+ expect(secondResult.failed).toEqual([]);
937
+ expect(order[order.length - 1]).toBe("db:agent-delete");
938
+
939
+ const gone = await prisma.agent.findUnique({ where: { id: agentId } });
940
+ expect(gone).toBeNull();
941
+ },
942
+ );
943
+ },
944
+ );
945
+
946
+ // ─── NoopAgentProvisioner (no DB required) ────────────────────────────────────
947
+
948
+ describe("NoopAgentProvisioner", () => {
949
+ it("provision() returns derived names without touching any cluster", async () => {
950
+ const provisioner = new NoopAgentProvisioner();
951
+ const result = await provisioner.provision("agent_42");
952
+ const name = sanitizeAgentName("agent_42");
953
+ expect(result.resourceName).toBe(name);
954
+ expect(result.secretName).toBe(`${name}-token`);
955
+ expect(result.deploymentName).toBe(name);
956
+ expect(result.rawToken).toBeUndefined();
957
+ });
958
+
959
+ it("deprovision() resolves to nothing", async () => {
960
+ const provisioner = new NoopAgentProvisioner();
961
+ await expect(provisioner.deprovision("agent_42")).resolves.toBeUndefined();
962
+ });
963
+
964
+ it("reconcile() returns empty arrays (no-op)", async () => {
965
+ const provisioner = new NoopAgentProvisioner();
966
+ const result = await provisioner.reconcile([
967
+ { id: "agent_42" },
968
+ { id: "agent_99" },
969
+ ]);
970
+ expect(result).toEqual({
971
+ recreated: [],
972
+ migrated: [],
973
+ noOwner: [],
974
+ dryRun: false,
975
+ updated: [],
976
+ orphans: [],
977
+ failed: [],
978
+ });
979
+ });
980
+
981
+ it("ConflictError remains importable for typed-error narrowing", () => {
982
+ expect(new ConflictError().statusCode).toBe(409);
983
+ });
984
+ });
985
+
986
+ // ─── Claude policy reconcile through the recorded k8s cassette ──────────────
987
+ // Not DB-backed: reconcile() on an existing Deployment never touches the token
988
+ // service, so these run everywhere. The real HttpKubernetesClient is driven by
989
+ // an injected fetchFn replaying canned API-server responses.
990
+
991
+ interface PolicyCassetteEntry {
992
+ status: number;
993
+ body: unknown;
994
+ }
995
+
996
+ const POLICY_CASSETTE: Record<string, PolicyCassetteEntry> = JSON.parse(
997
+ readFileSync(
998
+ new URL("./fixtures/k8s-cassette.json", import.meta.url).pathname,
999
+ "utf-8",
1000
+ ),
1001
+ );
1002
+
1003
+ /** Routes list/get/patch to cassette entries and records PATCH bodies. */
1004
+ function policyCassetteClient(getKey = "getDeployment_policy"): {
1005
+ client: KubernetesClient;
1006
+ patches: Array<{ contentType: string | null; body: unknown }>;
1007
+ } {
1008
+ const patches: Array<{ contentType: string | null; body: unknown }> = [];
1009
+ const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
1010
+ const url = typeof input === "string" ? input : input.toString();
1011
+ const method = init?.method ?? "GET";
1012
+ let key: string;
1013
+ if (method === "PATCH") {
1014
+ key = "patchDeployment_policy_success";
1015
+ patches.push({
1016
+ contentType: new Headers(init?.headers).get("content-type"),
1017
+ body: JSON.parse(String(init?.body)),
1018
+ });
1019
+ } else if (url.includes("/deployments/")) {
1020
+ key = getKey;
1021
+ } else {
1022
+ key = "listDeployments_policy";
1023
+ }
1024
+ const entry = POLICY_CASSETTE[key];
1025
+ if (!entry) throw new Error(`cassette key not found: ${key}`);
1026
+ return new Response(JSON.stringify(entry.body), {
1027
+ status: entry.status,
1028
+ headers: { "Content-Type": "application/json" },
1029
+ });
1030
+ }) as typeof fetch;
1031
+ const client = new HttpKubernetesClient({
1032
+ apiServer: "https://kubernetes.default.svc",
1033
+ token: "test-sa-token",
1034
+ caCert: "test-ca",
1035
+ fetchFn,
1036
+ });
1037
+ return { client, patches };
1038
+ }
1039
+
1040
+ describe("KubernetesAgentProvisioner.reconcile() — claude policy (cassette)", () => {
1041
+ // Never invoked: the Deployment exists, so reconcile only reads and patches.
1042
+ const tokens = {} as unknown as AgentTokenService;
1043
+
1044
+ it("sends a strategic-merge patch that removes the policy when it is disabled", async () => {
1045
+ const { client, patches } = policyCassetteClient();
1046
+ const provisioner = new KubernetesAgentProvisioner(client, tokens, CONFIG);
1047
+
1048
+ const result = await provisioner.reconcile([{ id: "agent-abc" }], {
1049
+ policyOnly: true,
1050
+ });
1051
+
1052
+ expect(result.updated).toEqual(["agent-abc"]);
1053
+ expect(result.failed).toEqual([]);
1054
+ expect(patches).toHaveLength(1);
1055
+ expect(patches[0]?.contentType).toBe(
1056
+ "application/strategic-merge-patch+json",
1057
+ );
1058
+ expect(patches[0]?.body).toEqual({
1059
+ spec: {
1060
+ template: {
1061
+ metadata: {
1062
+ annotations: { "shipwright.dev/claude-policy-hash": null },
1063
+ },
1064
+ spec: {
1065
+ volumes: [{ name: "claude-policy", $patch: "delete" }],
1066
+ containers: [
1067
+ {
1068
+ name: "shipwright-agent",
1069
+ volumeMounts: [
1070
+ { mountPath: "/etc/claude-code", $patch: "delete" },
1071
+ ],
1072
+ },
1073
+ ],
1074
+ },
1075
+ },
1076
+ },
1077
+ });
1078
+ });
1079
+
1080
+ it("updates only the hash annotation when the recorded policy has a stale hash", async () => {
1081
+ const { client, patches } = policyCassetteClient();
1082
+ const provisioner = new KubernetesAgentProvisioner(client, tokens, {
1083
+ ...CONFIG,
1084
+ claudePolicy: { configMapName: "claude-policy-cm", hash: "fresh-hash" },
1085
+ });
1086
+
1087
+ const result = await provisioner.reconcile([{ id: "agent-abc" }], {
1088
+ policyOnly: true,
1089
+ });
1090
+
1091
+ expect(result.updated).toEqual(["agent-abc"]);
1092
+ expect(patches).toHaveLength(1);
1093
+ expect(patches[0]?.body).toEqual({
1094
+ spec: {
1095
+ template: {
1096
+ metadata: {
1097
+ annotations: { "shipwright.dev/claude-policy-hash": "fresh-hash" },
1098
+ },
1099
+ spec: {},
1100
+ },
1101
+ },
1102
+ });
1103
+ });
1104
+ });
1105
+
1106
+ describe("KubernetesAgentProvisioner.reconcile() — anthropic federation (cassette)", () => {
1107
+ // Never invoked: the Deployment exists, so reconcile only reads and patches.
1108
+ const tokens = {} as unknown as AgentTokenService;
1109
+ const FEDERATION = {
1110
+ serviceAccountName: "shipwright-agent",
1111
+ ruleId: "fdrl_1",
1112
+ organizationId: "org-1",
1113
+ serviceAccountId: "svac_1",
1114
+ audience: "https://api.anthropic.com",
1115
+ expirationSeconds: 600,
1116
+ };
1117
+
1118
+ it("sends a strategic-merge patch that adds the federation wiring when enabled", async () => {
1119
+ const { client, patches } = policyCassetteClient(
1120
+ "getDeployment_federation_absent",
1121
+ );
1122
+ const provisioner = new KubernetesAgentProvisioner(client, tokens, {
1123
+ ...CONFIG,
1124
+ anthropicFederation: FEDERATION,
1125
+ });
1126
+
1127
+ const result = await provisioner.reconcile([{ id: "agent-abc" }], {
1128
+ policyOnly: true,
1129
+ });
1130
+
1131
+ expect(result.updated).toEqual(["agent-abc"]);
1132
+ expect(result.failed).toEqual([]);
1133
+ expect(patches).toHaveLength(1);
1134
+ expect(patches[0]?.contentType).toBe(
1135
+ "application/strategic-merge-patch+json",
1136
+ );
1137
+ expect(patches[0]?.body).toEqual({
1138
+ spec: {
1139
+ template: {
1140
+ spec: {
1141
+ serviceAccountName: "shipwright-agent",
1142
+ volumes: [
1143
+ {
1144
+ name: "anthropic-token",
1145
+ projected: {
1146
+ sources: [
1147
+ {
1148
+ serviceAccountToken: {
1149
+ audience: "https://api.anthropic.com",
1150
+ expirationSeconds: 600,
1151
+ path: "token",
1152
+ },
1153
+ },
1154
+ ],
1155
+ },
1156
+ },
1157
+ ],
1158
+ containers: [
1159
+ {
1160
+ name: "shipwright-agent",
1161
+ volumeMounts: [
1162
+ {
1163
+ name: "anthropic-token",
1164
+ mountPath: "/var/run/secrets/anthropic.com",
1165
+ readOnly: true,
1166
+ },
1167
+ ],
1168
+ env: [
1169
+ {
1170
+ name: "ANTHROPIC_IDENTITY_TOKEN_FILE",
1171
+ value: "/var/run/secrets/anthropic.com/token",
1172
+ },
1173
+ { name: "ANTHROPIC_FEDERATION_RULE_ID", value: "fdrl_1" },
1174
+ { name: "ANTHROPIC_ORGANIZATION_ID", value: "org-1" },
1175
+ { name: "ANTHROPIC_SERVICE_ACCOUNT_ID", value: "svac_1" },
1176
+ ],
1177
+ },
1178
+ ],
1179
+ },
1180
+ },
1181
+ },
1182
+ });
1183
+ });
1184
+
1185
+ it("sends a strategic-merge patch that removes the federation wiring when disabled", async () => {
1186
+ const { client, patches } = policyCassetteClient(
1187
+ "getDeployment_federation_present",
1188
+ );
1189
+ const provisioner = new KubernetesAgentProvisioner(client, tokens, CONFIG);
1190
+
1191
+ const result = await provisioner.reconcile([{ id: "agent-abc" }], {
1192
+ policyOnly: true,
1193
+ });
1194
+
1195
+ expect(result.updated).toEqual(["agent-abc"]);
1196
+ expect(patches).toHaveLength(1);
1197
+ expect(patches[0]?.body).toEqual({
1198
+ spec: {
1199
+ template: {
1200
+ spec: {
1201
+ serviceAccountName: null,
1202
+ serviceAccount: null,
1203
+ volumes: [{ name: "anthropic-token", $patch: "delete" }],
1204
+ containers: [
1205
+ {
1206
+ name: "shipwright-agent",
1207
+ volumeMounts: [
1208
+ {
1209
+ mountPath: "/var/run/secrets/anthropic.com",
1210
+ $patch: "delete",
1211
+ },
1212
+ ],
1213
+ env: [
1214
+ { name: "ANTHROPIC_IDENTITY_TOKEN_FILE", $patch: "delete" },
1215
+ { name: "ANTHROPIC_FEDERATION_RULE_ID", $patch: "delete" },
1216
+ { name: "ANTHROPIC_ORGANIZATION_ID", $patch: "delete" },
1217
+ { name: "ANTHROPIC_SERVICE_ACCOUNT_ID", $patch: "delete" },
1218
+ { name: "ANTHROPIC_WORKSPACE_ID", $patch: "delete" },
1219
+ ],
1220
+ },
1221
+ ],
1222
+ },
1223
+ },
1224
+ },
1225
+ });
1226
+ });
1227
+
1228
+ it("does not patch when the recorded federation wiring is in sync", async () => {
1229
+ const { client, patches } = policyCassetteClient(
1230
+ "getDeployment_federation_present",
1231
+ );
1232
+ const provisioner = new KubernetesAgentProvisioner(client, tokens, {
1233
+ ...CONFIG,
1234
+ anthropicFederation: { ...FEDERATION, workspaceId: "wrkspc_1" },
1235
+ });
1236
+
1237
+ const result = await provisioner.reconcile([{ id: "agent-abc" }], {
1238
+ policyOnly: true,
1239
+ });
1240
+
1241
+ expect(result.updated).toEqual([]);
1242
+ expect(result.failed).toEqual([]);
1243
+ expect(patches).toEqual([]);
1244
+ });
1245
+ });