@app-vitals/agent-admin 0.0.1 → 0.1.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.
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 +62 -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,1027 @@
1
+ /**
2
+ * admin/src/kubernetes-client.ts
3
+ *
4
+ * A thin first-party Kubernetes API client. Authenticates to the in-cluster API
5
+ * server using the mounted ServiceAccount Bearer token, and supports
6
+ * namespace-scoped create/get/delete of Deployments, Secrets, PVCs and
7
+ * ServiceAccounts.
8
+ *
9
+ * No `@kubernetes/client-node` dependency — native fetch only. The HTTP client
10
+ * takes an injected `fetchFn` (defaulting to the global `fetch`) so tests can
11
+ * feed recorded Responses WITHOUT overriding any global.
12
+ *
13
+ * Exports:
14
+ * - KubernetesClient — interface for DI (used by callers and tests)
15
+ * - HttpKubernetesClient — production impl, talks to the API server
16
+ * - RecordedKubernetesClient — in-memory cassette double for tests
17
+ * - deploymentUrl / secretUrl / deploymentBody / secretBody — pure helpers
18
+ *
19
+ * Non-2xx API responses map to the shared typed errors in ./errors.ts:
20
+ * 401 → UnauthorizedError, 403 → ForbiddenError, 404 → NotFoundError,
21
+ * 409 → ConflictError, other → ApiError (with the status code).
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import {
26
+ ApiError,
27
+ ConflictError,
28
+ ForbiddenError,
29
+ NotFoundError,
30
+ UnauthorizedError,
31
+ } from "./errors.ts";
32
+
33
+ // ─── Resource specs (caller-facing inputs) ──────────────────────────────────
34
+
35
+ export interface DeploymentSpec {
36
+ name: string;
37
+ image: string;
38
+ /** Defaults to 1. */
39
+ replicas?: number;
40
+ /** Defaults to `{ app: <name> }`. */
41
+ labels?: Record<string, string>;
42
+ /** Container environment variables (plain values). */
43
+ env?: Record<string, string>;
44
+ /**
45
+ * Additional env vars expressed as full `KubernetesEnvVar` objects. Supports
46
+ * `valueFrom` entries (e.g. `secretKeyRef`) that cannot be expressed in the
47
+ * plain `env` map. Merged after the `env` entries in the container spec.
48
+ */
49
+ envVars?: KubernetesEnvVar[];
50
+ }
51
+
52
+ export interface SecretSpec {
53
+ name: string;
54
+ /** Plain string values; encoded to base64 `data` on the wire. */
55
+ stringData: Record<string, string>;
56
+ }
57
+
58
+ // ─── Resource bodies (k8s wire shapes) ──────────────────────────────────────
59
+
60
+ /** A single container env var: either a literal value or a `valueFrom` source. */
61
+ export interface KubernetesEnvVar {
62
+ name: string;
63
+ value?: string;
64
+ valueFrom?: {
65
+ secretKeyRef?: { name: string; key: string };
66
+ configMapKeyRef?: { name: string; key: string };
67
+ [key: string]: unknown;
68
+ };
69
+ [key: string]: unknown;
70
+ }
71
+
72
+ export interface KubernetesContainer {
73
+ name: string;
74
+ image: string;
75
+ env?: KubernetesEnvVar[];
76
+ resources?: {
77
+ requests?: Record<string, string>;
78
+ limits?: Record<string, string>;
79
+ };
80
+ volumeMounts?: Array<{
81
+ name: string;
82
+ mountPath: string;
83
+ [key: string]: unknown;
84
+ }>;
85
+ startupProbe?: {
86
+ httpGet?: { path?: string; port: number | string; [key: string]: unknown };
87
+ [key: string]: unknown;
88
+ };
89
+ livenessProbe?: {
90
+ httpGet?: { path?: string; port: number | string; [key: string]: unknown };
91
+ [key: string]: unknown;
92
+ };
93
+ readinessProbe?: {
94
+ httpGet?: { path?: string; port: number | string; [key: string]: unknown };
95
+ [key: string]: unknown;
96
+ };
97
+ securityContext?: Record<string, unknown>;
98
+ [key: string]: unknown;
99
+ }
100
+
101
+ export interface KubernetesDeployment {
102
+ apiVersion: string;
103
+ kind: string;
104
+ metadata: {
105
+ name: string;
106
+ namespace: string;
107
+ labels?: Record<string, string>;
108
+ ownerReferences?: Array<Record<string, unknown>>;
109
+ [key: string]: unknown;
110
+ };
111
+ spec: {
112
+ replicas: number;
113
+ strategy?: { type: string; [key: string]: unknown };
114
+ selector: { matchLabels: Record<string, string> };
115
+ template: {
116
+ metadata: {
117
+ labels: Record<string, string>;
118
+ annotations?: Record<string, string>;
119
+ };
120
+ spec: {
121
+ containers: KubernetesContainer[];
122
+ volumes?: Array<{
123
+ name: string;
124
+ persistentVolumeClaim?: { claimName: string };
125
+ configMap?: {
126
+ name: string;
127
+ items?: Array<{ key: string; path: string }>;
128
+ defaultMode?: number;
129
+ };
130
+ [key: string]: unknown;
131
+ }>;
132
+ securityContext?: Record<string, unknown>;
133
+ [key: string]: unknown;
134
+ };
135
+ };
136
+ };
137
+ [key: string]: unknown;
138
+ }
139
+
140
+ export interface KubernetesSecret {
141
+ apiVersion: string;
142
+ kind: string;
143
+ type: string;
144
+ metadata: {
145
+ name: string;
146
+ namespace: string;
147
+ ownerReferences?: Array<Record<string, unknown>>;
148
+ [key: string]: unknown;
149
+ };
150
+ data: Record<string, string>;
151
+ [key: string]: unknown;
152
+ }
153
+
154
+ export interface ServiceAccountSpec {
155
+ name: string;
156
+ }
157
+
158
+ export interface PvcSpec {
159
+ name: string;
160
+ namespace: string;
161
+ storageGi: number;
162
+ storageClassName?: string;
163
+ }
164
+
165
+ export interface KubernetesPvc {
166
+ apiVersion: string;
167
+ kind: string;
168
+ metadata: {
169
+ name: string;
170
+ namespace: string;
171
+ ownerReferences?: Array<Record<string, unknown>>;
172
+ [key: string]: unknown;
173
+ };
174
+ spec: {
175
+ accessModes: string[];
176
+ resources: { requests: { storage: string } };
177
+ storageClassName?: string;
178
+ [key: string]: unknown;
179
+ };
180
+ [key: string]: unknown;
181
+ }
182
+
183
+ export interface KubernetesServiceAccount {
184
+ apiVersion: string;
185
+ kind: string;
186
+ metadata: {
187
+ name: string;
188
+ namespace: string;
189
+ ownerReferences?: Array<Record<string, unknown>>;
190
+ [key: string]: unknown;
191
+ };
192
+ [key: string]: unknown;
193
+ }
194
+
195
+ // ─── Interface ──────────────────────────────────────────────────────────────
196
+
197
+ export interface KubernetesClient {
198
+ createDeployment(
199
+ namespace: string,
200
+ spec: DeploymentSpec,
201
+ ): Promise<KubernetesDeployment>;
202
+ /**
203
+ * Submit a full KubernetesDeployment manifest to the API server. Unlike
204
+ * createDeployment(), this method POSTs the manifest as-is — volumes,
205
+ * securityContext, ownerReferences, strategy, etc. are all preserved.
206
+ * Use this when the manifest is already fully shaped (e.g. via
207
+ * buildAgentDeploymentManifest()); use createDeployment() for simple
208
+ * ad-hoc deployments expressed as a DeploymentSpec.
209
+ */
210
+ createDeploymentManifest(
211
+ namespace: string,
212
+ manifest: KubernetesDeployment,
213
+ ): Promise<KubernetesDeployment>;
214
+ getDeployment(namespace: string, name: string): Promise<KubernetesDeployment>;
215
+ /** Returns true if the deployment exists, false if 404, throws on other errors. */
216
+ deploymentExists(namespace: string, name: string): Promise<boolean>;
217
+ /**
218
+ * Lists deployments in a namespace, optionally filtered by a label selector.
219
+ * Returns an array of deployment names (metadata.name).
220
+ */
221
+ listDeployments(namespace: string, labelSelector?: string): Promise<string[]>;
222
+ deleteDeployment(namespace: string, name: string): Promise<void>;
223
+ createSecret(namespace: string, spec: SecretSpec): Promise<KubernetesSecret>;
224
+ getSecret(namespace: string, name: string): Promise<KubernetesSecret>;
225
+ deleteSecret(namespace: string, name: string): Promise<void>;
226
+ createPvc(namespace: string, spec: PvcSpec): Promise<KubernetesPvc>;
227
+ getPvc(namespace: string, name: string): Promise<KubernetesPvc>;
228
+ deletePvc(namespace: string, name: string): Promise<void>;
229
+ createServiceAccount(
230
+ namespace: string,
231
+ spec: ServiceAccountSpec,
232
+ ): Promise<KubernetesServiceAccount>;
233
+ getServiceAccount(
234
+ namespace: string,
235
+ name: string,
236
+ ): Promise<KubernetesServiceAccount>;
237
+ deleteServiceAccount(namespace: string, name: string): Promise<void>;
238
+ /**
239
+ * Apply a strategic merge patch to a named Deployment. The patch object is
240
+ * JSON-serialized and sent with Content-Type application/strategic-merge-patch+json.
241
+ * Typical use: update containers[0].image for a rolling restart.
242
+ */
243
+ patchDeployment(
244
+ namespace: string,
245
+ name: string,
246
+ patch: object,
247
+ ): Promise<void>;
248
+ }
249
+
250
+ // ─── Pure URL builders ────────────────────────────────────────────────────────
251
+
252
+ function trimTrailingSlash(s: string): string {
253
+ return s.endsWith("/") ? s.slice(0, -1) : s;
254
+ }
255
+
256
+ /** `/apis/apps/v1/namespaces/{ns}/deployments[/{name}]` */
257
+ export function deploymentUrl(
258
+ apiServer: string,
259
+ namespace: string,
260
+ name?: string,
261
+ ): string {
262
+ const base = `${trimTrailingSlash(apiServer)}/apis/apps/v1/namespaces/${namespace}/deployments`;
263
+ return name ? `${base}/${name}` : base;
264
+ }
265
+
266
+ /** `/api/v1/namespaces/{ns}/secrets[/{name}]` */
267
+ export function secretUrl(
268
+ apiServer: string,
269
+ namespace: string,
270
+ name?: string,
271
+ ): string {
272
+ const base = `${trimTrailingSlash(apiServer)}/api/v1/namespaces/${namespace}/secrets`;
273
+ return name ? `${base}/${name}` : base;
274
+ }
275
+
276
+ /** `/api/v1/namespaces/{ns}/persistentvolumeclaims[/{name}]` */
277
+ export function pvcUrl(
278
+ apiServer: string,
279
+ namespace: string,
280
+ name?: string,
281
+ ): string {
282
+ const base = `${trimTrailingSlash(apiServer)}/api/v1/namespaces/${namespace}/persistentvolumeclaims`;
283
+ return name ? `${base}/${name}` : base;
284
+ }
285
+
286
+ /** `/api/v1/namespaces/{ns}/serviceaccounts[/{name}]` */
287
+ export function serviceAccountUrl(
288
+ apiServer: string,
289
+ namespace: string,
290
+ name?: string,
291
+ ): string {
292
+ const base = `${trimTrailingSlash(apiServer)}/api/v1/namespaces/${namespace}/serviceaccounts`;
293
+ return name ? `${base}/${name}` : base;
294
+ }
295
+
296
+ // ─── Pure body shapers ────────────────────────────────────────────────────────
297
+
298
+ export function deploymentBody(
299
+ namespace: string,
300
+ spec: DeploymentSpec,
301
+ ): KubernetesDeployment {
302
+ const labels = spec.labels ?? { app: spec.name };
303
+ const plainEnv: KubernetesEnvVar[] = spec.env
304
+ ? Object.entries(spec.env).map(([name, value]) => ({ name, value }))
305
+ : [];
306
+ const env: KubernetesEnvVar[] | undefined =
307
+ plainEnv.length > 0 || (spec.envVars && spec.envVars.length > 0)
308
+ ? [...plainEnv, ...(spec.envVars ?? [])]
309
+ : undefined;
310
+
311
+ return {
312
+ apiVersion: "apps/v1",
313
+ kind: "Deployment",
314
+ metadata: {
315
+ name: spec.name,
316
+ namespace,
317
+ labels,
318
+ },
319
+ spec: {
320
+ replicas: spec.replicas ?? 1,
321
+ selector: { matchLabels: labels },
322
+ template: {
323
+ metadata: { labels },
324
+ spec: {
325
+ containers: [
326
+ {
327
+ name: spec.name,
328
+ image: spec.image,
329
+ ...(env ? { env } : {}),
330
+ },
331
+ ],
332
+ },
333
+ },
334
+ },
335
+ };
336
+ }
337
+
338
+ export function secretBody(
339
+ namespace: string,
340
+ spec: SecretSpec,
341
+ ): KubernetesSecret {
342
+ const data: Record<string, string> = {};
343
+ for (const [key, value] of Object.entries(spec.stringData)) {
344
+ data[key] = Buffer.from(value).toString("base64");
345
+ }
346
+
347
+ return {
348
+ apiVersion: "v1",
349
+ kind: "Secret",
350
+ type: "Opaque",
351
+ metadata: { name: spec.name, namespace },
352
+ data,
353
+ };
354
+ }
355
+
356
+ export function pvcBody(namespace: string, spec: PvcSpec): KubernetesPvc {
357
+ const body: KubernetesPvc = {
358
+ apiVersion: "v1",
359
+ kind: "PersistentVolumeClaim",
360
+ metadata: { name: spec.name, namespace },
361
+ spec: {
362
+ accessModes: ["ReadWriteOnce"],
363
+ resources: { requests: { storage: `${spec.storageGi}Gi` } },
364
+ },
365
+ };
366
+ if (spec.storageClassName !== undefined) {
367
+ body.spec.storageClassName = spec.storageClassName;
368
+ }
369
+ return body;
370
+ }
371
+
372
+ export function serviceAccountBody(
373
+ namespace: string,
374
+ spec: ServiceAccountSpec,
375
+ ): KubernetesServiceAccount {
376
+ return {
377
+ apiVersion: "v1",
378
+ kind: "ServiceAccount",
379
+ metadata: { name: spec.name, namespace },
380
+ };
381
+ }
382
+
383
+ const SA_DIR = "/var/run/secrets/kubernetes.io/serviceaccount";
384
+
385
+ /**
386
+ * Read the in-cluster CA cert (PEM). When the caller passed an EXPLICIT
387
+ * `caPath`, a read failure THROWS — a caller who named a path expects it to
388
+ * exist, and silently falling back to system CAs would surface as an opaque
389
+ * TLS verification error later. For the default SA path (derived from
390
+ * `saDir`, injectable for tests), reading is best-effort: a missing file
391
+ * warns and yields `undefined` rather than crashing client construction.
392
+ */
393
+ function readCa(caPath?: string, saDir: string = SA_DIR): string | undefined {
394
+ const explicit = caPath !== undefined;
395
+ const path = caPath ?? `${saDir}/ca.crt`;
396
+ try {
397
+ return readFileSync(path, "utf-8");
398
+ } catch (err) {
399
+ if (explicit) {
400
+ throw new Error(
401
+ `[kubernetes-client] CA file read failed at explicit caPath "${path}": ${String(err)}`,
402
+ );
403
+ }
404
+ console.warn(
405
+ "[kubernetes-client] CA file read failed, falling back to system CAs:",
406
+ err,
407
+ );
408
+ return undefined;
409
+ }
410
+ }
411
+
412
+ function defaultApiServer(): string {
413
+ const host = process.env.KUBERNETES_SERVICE_HOST;
414
+ const port = process.env.KUBERNETES_SERVICE_PORT;
415
+ if (host) {
416
+ return `https://${host}:${port ?? "443"}`;
417
+ }
418
+ return "https://kubernetes.default.svc";
419
+ }
420
+
421
+ // ─── Error mapping ────────────────────────────────────────────────────────────
422
+
423
+ interface K8sStatus {
424
+ message?: string;
425
+ reason?: string;
426
+ code?: number;
427
+ }
428
+
429
+ function mapError(status: number, body: unknown): ApiError {
430
+ const message =
431
+ (body as K8sStatus | undefined)?.message ??
432
+ `Kubernetes API error ${status}`;
433
+ switch (status) {
434
+ case 401:
435
+ return new UnauthorizedError(message);
436
+ case 403:
437
+ return new ForbiddenError(message);
438
+ case 404:
439
+ return new NotFoundError(message);
440
+ case 409:
441
+ return new ConflictError(message);
442
+ default:
443
+ return new ApiError(status, message);
444
+ }
445
+ }
446
+
447
+ // ─── HTTP implementation ──────────────────────────────────────────────────────
448
+
449
+ interface HttpKubernetesClientOpts {
450
+ /** Defaults to KUBERNETES_SERVICE_HOST/PORT or https://kubernetes.default.svc. */
451
+ apiServer?: string;
452
+ /** ServiceAccount Bearer token. If omitted, read from `tokenPath`. */
453
+ token?: string;
454
+ /** Path to the mounted SA token (used only when `token` is omitted). */
455
+ tokenPath?: string;
456
+ /** In-cluster CA cert (PEM). If omitted, read best-effort from `caPath`. */
457
+ caCert?: string;
458
+ /**
459
+ * Path to the mounted CA cert (used only when `caCert` is omitted). The CA is
460
+ * read and applied to outbound TLS so HTTPS to the API server verifies against
461
+ * the cluster's self-signed cert. When `caPath` is provided explicitly, a
462
+ * read failure THROWS (a named path is expected to exist); the default SA path
463
+ * is best-effort and warns + degrades to system CAs on failure.
464
+ */
465
+ caPath?: string;
466
+ /**
467
+ * Directory the DEFAULT `caPath` (`${saDir}/ca.crt`) is derived from when
468
+ * neither `caCert` nor `caPath` is provided. Defaults to the real in-cluster
469
+ * ServiceAccount mount (`/var/run/secrets/kubernetes.io/serviceaccount`).
470
+ * Injectable so tests can point the implicit default at a guaranteed-empty
471
+ * directory instead of depending on whatever happens to be mounted in the
472
+ * sandbox's filesystem.
473
+ */
474
+ saDir?: string;
475
+ /** Injected fetch — defaults to the global `fetch`. Never overrides the global. */
476
+ fetchFn?: typeof fetch;
477
+ }
478
+
479
+ /** RequestInit plus Bun's `tls` option (not in the standard DOM types). */
480
+ type RequestInitWithTls = RequestInit & { tls?: { ca?: string } };
481
+
482
+ export class HttpKubernetesClient implements KubernetesClient {
483
+ private readonly apiServer: string;
484
+ /** Fixed token (test injection). When undefined, read per-request from disk. */
485
+ private readonly token?: string;
486
+ private readonly tokenPath: string;
487
+ private readonly caCert?: string;
488
+ private readonly fetchFn: typeof fetch;
489
+
490
+ constructor(opts?: HttpKubernetesClientOpts) {
491
+ this.apiServer = opts?.apiServer ?? defaultApiServer();
492
+ this.token = opts?.token;
493
+ this.tokenPath = opts?.tokenPath ?? `${SA_DIR}/token`;
494
+ this.caCert = opts?.caCert ?? readCa(opts?.caPath, opts?.saDir ?? SA_DIR);
495
+ this.fetchFn = opts?.fetchFn ?? fetch;
496
+ }
497
+
498
+ /**
499
+ * Resolve the current Bearer token. An explicitly injected `token` is fixed;
500
+ * otherwise the token is read from disk per request so that projected
501
+ * ServiceAccount token rotation (~hourly) is picked up without a pod restart.
502
+ */
503
+ private readToken(): string {
504
+ if (this.token !== undefined) return this.token;
505
+ return readFileSync(this.tokenPath, "utf-8").trim();
506
+ }
507
+
508
+ private authHeaders(extra?: Record<string, string>): HeadersInit {
509
+ return {
510
+ Authorization: `Bearer ${this.readToken()}`,
511
+ ...extra,
512
+ };
513
+ }
514
+
515
+ private async request<T>(url: string, init: RequestInit): Promise<T> {
516
+ const finalInit: RequestInitWithTls = this.caCert
517
+ ? { ...init, tls: { ca: this.caCert } }
518
+ : init;
519
+ const resp = await this.fetchFn(url, finalInit as RequestInit);
520
+ const text = await resp.text();
521
+ // Guard parse: proxy 502s / HTML error pages aren't JSON. A parse failure
522
+ // yields `undefined` so non-2xx still maps to a typed error (mapError has a
523
+ // status fallback) and 2xx non-JSON returns the existing empty-body path.
524
+ let body: unknown;
525
+ try {
526
+ body = text ? JSON.parse(text) : undefined;
527
+ } catch {
528
+ body = undefined;
529
+ }
530
+ if (!resp.ok) {
531
+ throw mapError(resp.status, body);
532
+ }
533
+ return body as T;
534
+ }
535
+
536
+ async createDeployment(
537
+ namespace: string,
538
+ spec: DeploymentSpec,
539
+ ): Promise<KubernetesDeployment> {
540
+ return this.request<KubernetesDeployment>(
541
+ deploymentUrl(this.apiServer, namespace),
542
+ {
543
+ method: "POST",
544
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
545
+ body: JSON.stringify(deploymentBody(namespace, spec)),
546
+ },
547
+ );
548
+ }
549
+
550
+ async createDeploymentManifest(
551
+ namespace: string,
552
+ manifest: KubernetesDeployment,
553
+ ): Promise<KubernetesDeployment> {
554
+ return this.request<KubernetesDeployment>(
555
+ deploymentUrl(this.apiServer, namespace),
556
+ {
557
+ method: "POST",
558
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
559
+ body: JSON.stringify(manifest),
560
+ },
561
+ );
562
+ }
563
+
564
+ async getDeployment(
565
+ namespace: string,
566
+ name: string,
567
+ ): Promise<KubernetesDeployment> {
568
+ return this.request<KubernetesDeployment>(
569
+ deploymentUrl(this.apiServer, namespace, name),
570
+ { method: "GET", headers: this.authHeaders() },
571
+ );
572
+ }
573
+
574
+ async deploymentExists(namespace: string, name: string): Promise<boolean> {
575
+ try {
576
+ await this.getDeployment(namespace, name);
577
+ return true;
578
+ } catch (err) {
579
+ if (err instanceof NotFoundError) return false;
580
+ throw err;
581
+ }
582
+ }
583
+
584
+ async listDeployments(
585
+ namespace: string,
586
+ labelSelector?: string,
587
+ ): Promise<string[]> {
588
+ const base = deploymentUrl(this.apiServer, namespace);
589
+ const url = labelSelector
590
+ ? `${base}?labelSelector=${encodeURIComponent(labelSelector)}`
591
+ : base;
592
+ const response = await this.request<{
593
+ items: Array<{ metadata: { name: string } }>;
594
+ }>(url, { method: "GET", headers: this.authHeaders() });
595
+ return response.items.map((item) => item.metadata.name);
596
+ }
597
+
598
+ async deleteDeployment(namespace: string, name: string): Promise<void> {
599
+ await this.request<unknown>(
600
+ deploymentUrl(this.apiServer, namespace, name),
601
+ { method: "DELETE", headers: this.authHeaders() },
602
+ );
603
+ }
604
+
605
+ async patchDeployment(
606
+ namespace: string,
607
+ name: string,
608
+ patch: object,
609
+ ): Promise<void> {
610
+ await this.request<void>(deploymentUrl(this.apiServer, namespace, name), {
611
+ method: "PATCH",
612
+ headers: this.authHeaders({
613
+ "Content-Type": "application/strategic-merge-patch+json",
614
+ }),
615
+ body: JSON.stringify(patch),
616
+ });
617
+ }
618
+
619
+ async createSecret(
620
+ namespace: string,
621
+ spec: SecretSpec,
622
+ ): Promise<KubernetesSecret> {
623
+ return this.request<KubernetesSecret>(
624
+ secretUrl(this.apiServer, namespace),
625
+ {
626
+ method: "POST",
627
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
628
+ body: JSON.stringify(secretBody(namespace, spec)),
629
+ },
630
+ );
631
+ }
632
+
633
+ async getSecret(namespace: string, name: string): Promise<KubernetesSecret> {
634
+ return this.request<KubernetesSecret>(
635
+ secretUrl(this.apiServer, namespace, name),
636
+ { method: "GET", headers: this.authHeaders() },
637
+ );
638
+ }
639
+
640
+ async deleteSecret(namespace: string, name: string): Promise<void> {
641
+ await this.request<unknown>(secretUrl(this.apiServer, namespace, name), {
642
+ method: "DELETE",
643
+ headers: this.authHeaders(),
644
+ });
645
+ }
646
+
647
+ async createPvc(namespace: string, spec: PvcSpec): Promise<KubernetesPvc> {
648
+ return this.request<KubernetesPvc>(pvcUrl(this.apiServer, namespace), {
649
+ method: "POST",
650
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
651
+ body: JSON.stringify(pvcBody(namespace, spec)),
652
+ });
653
+ }
654
+
655
+ async getPvc(namespace: string, name: string): Promise<KubernetesPvc> {
656
+ return this.request<KubernetesPvc>(
657
+ pvcUrl(this.apiServer, namespace, name),
658
+ {
659
+ method: "GET",
660
+ headers: this.authHeaders(),
661
+ },
662
+ );
663
+ }
664
+
665
+ async deletePvc(namespace: string, name: string): Promise<void> {
666
+ await this.request<unknown>(pvcUrl(this.apiServer, namespace, name), {
667
+ method: "DELETE",
668
+ headers: this.authHeaders(),
669
+ });
670
+ }
671
+
672
+ async createServiceAccount(
673
+ namespace: string,
674
+ spec: ServiceAccountSpec,
675
+ ): Promise<KubernetesServiceAccount> {
676
+ return this.request<KubernetesServiceAccount>(
677
+ serviceAccountUrl(this.apiServer, namespace),
678
+ {
679
+ method: "POST",
680
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
681
+ body: JSON.stringify(serviceAccountBody(namespace, spec)),
682
+ },
683
+ );
684
+ }
685
+
686
+ async getServiceAccount(
687
+ namespace: string,
688
+ name: string,
689
+ ): Promise<KubernetesServiceAccount> {
690
+ return this.request<KubernetesServiceAccount>(
691
+ serviceAccountUrl(this.apiServer, namespace, name),
692
+ { method: "GET", headers: this.authHeaders() },
693
+ );
694
+ }
695
+
696
+ async deleteServiceAccount(namespace: string, name: string): Promise<void> {
697
+ await this.request<unknown>(
698
+ serviceAccountUrl(this.apiServer, namespace, name),
699
+ { method: "DELETE", headers: this.authHeaders() },
700
+ );
701
+ }
702
+ }
703
+
704
+ // ─── RecordedKubernetesClient (in-memory double) ──────────────────────────────
705
+
706
+ interface KubernetesCassette {
707
+ /** Pre-seeded deployments keyed by `${namespace}/${name}`. */
708
+ deployments: Record<string, KubernetesDeployment>;
709
+ /** Pre-seeded secrets keyed by `${namespace}/${name}`. */
710
+ secrets: Record<string, KubernetesSecret>;
711
+ /** Pre-seeded PVCs keyed by `${namespace}/${name}`. */
712
+ pvcs?: Record<string, KubernetesPvc>;
713
+ /** Pre-seeded ServiceAccounts keyed by `${namespace}/${name}`. */
714
+ serviceAccounts?: Record<string, KubernetesServiceAccount>;
715
+ }
716
+
717
+ /**
718
+ * Strategic-merge a list of objects by a merge key: patch entries replace
719
+ * (field-merge into) live entries with the same key, new keys are appended, and
720
+ * an entry carrying `$patch: "delete"` removes the live entry. Order of live
721
+ * entries is preserved.
722
+ */
723
+ function mergeListByKey(
724
+ live: object[],
725
+ patch: object[],
726
+ key: string,
727
+ ): object[] {
728
+ const out = [...live] as Array<Record<string, unknown>>;
729
+ for (const entry of patch as Array<Record<string, unknown>>) {
730
+ const idx = out.findIndex((o) => o[key] === entry[key]);
731
+ if (entry.$patch === "delete") {
732
+ if (idx >= 0) out.splice(idx, 1);
733
+ continue;
734
+ }
735
+ if (idx >= 0) out[idx] = { ...out[idx], ...entry };
736
+ else out.push(entry);
737
+ }
738
+ return out;
739
+ }
740
+
741
+ /**
742
+ * Pure in-memory KubernetesClient for tests. Replays from a seeded cassette and
743
+ * supports create/get/delete with the same typed errors as the HTTP client
744
+ * (404 → NotFoundError, 409 → ConflictError on duplicate create).
745
+ */
746
+ export class RecordedKubernetesClient implements KubernetesClient {
747
+ private readonly deployments: Map<string, KubernetesDeployment>;
748
+ private readonly secrets: Map<string, KubernetesSecret>;
749
+ private readonly pvcs: Map<string, KubernetesPvc>;
750
+ private readonly serviceAccounts: Map<string, KubernetesServiceAccount>;
751
+
752
+ constructor(cassette: KubernetesCassette) {
753
+ this.deployments = new Map(Object.entries(cassette.deployments));
754
+ this.secrets = new Map(Object.entries(cassette.secrets));
755
+ this.pvcs = new Map(Object.entries(cassette.pvcs ?? {}));
756
+ this.serviceAccounts = new Map(
757
+ Object.entries(cassette.serviceAccounts ?? {}),
758
+ );
759
+ }
760
+
761
+ private static key(namespace: string, name: string): string {
762
+ return `${namespace}/${name}`;
763
+ }
764
+
765
+ async createDeployment(
766
+ namespace: string,
767
+ spec: DeploymentSpec,
768
+ ): Promise<KubernetesDeployment> {
769
+ const key = RecordedKubernetesClient.key(namespace, spec.name);
770
+ if (this.deployments.has(key)) {
771
+ throw new ConflictError(`deployments.apps "${spec.name}" already exists`);
772
+ }
773
+ const dep = deploymentBody(namespace, spec);
774
+ this.deployments.set(key, dep);
775
+ return dep;
776
+ }
777
+
778
+ async createDeploymentManifest(
779
+ namespace: string,
780
+ manifest: KubernetesDeployment,
781
+ ): Promise<KubernetesDeployment> {
782
+ const key = RecordedKubernetesClient.key(namespace, manifest.metadata.name);
783
+ if (this.deployments.has(key)) {
784
+ throw new ConflictError(
785
+ `deployments.apps "${manifest.metadata.name}" already exists`,
786
+ );
787
+ }
788
+ this.deployments.set(key, manifest);
789
+ return manifest;
790
+ }
791
+
792
+ async getDeployment(
793
+ namespace: string,
794
+ name: string,
795
+ ): Promise<KubernetesDeployment> {
796
+ const dep = this.deployments.get(
797
+ RecordedKubernetesClient.key(namespace, name),
798
+ );
799
+ if (!dep) {
800
+ throw new NotFoundError(`deployments.apps "${name}" not found`);
801
+ }
802
+ return dep;
803
+ }
804
+
805
+ async deploymentExists(namespace: string, name: string): Promise<boolean> {
806
+ return this.deployments.has(RecordedKubernetesClient.key(namespace, name));
807
+ }
808
+
809
+ // The labelSelector is ignored by the in-memory double: in tests the caller
810
+ // controls which deployments are seeded, so filtering by label is unnecessary.
811
+ async listDeployments(
812
+ namespace: string,
813
+ _labelSelector?: string,
814
+ ): Promise<string[]> {
815
+ const prefix = `${namespace}/`;
816
+ const names: string[] = [];
817
+ for (const [key] of this.deployments) {
818
+ if (key.startsWith(prefix)) {
819
+ names.push(key.slice(prefix.length));
820
+ }
821
+ }
822
+ return names;
823
+ }
824
+
825
+ async deleteDeployment(namespace: string, name: string): Promise<void> {
826
+ const key = RecordedKubernetesClient.key(namespace, name);
827
+ if (!this.deployments.has(key)) {
828
+ throw new NotFoundError(`deployments.apps "${name}" not found`);
829
+ }
830
+ this.deployments.delete(key);
831
+ }
832
+
833
+ async patchDeployment(
834
+ namespace: string,
835
+ name: string,
836
+ patch: object,
837
+ ): Promise<void> {
838
+ const key = RecordedKubernetesClient.key(namespace, name);
839
+ const dep = this.deployments.get(key);
840
+ if (!dep) {
841
+ throw new NotFoundError(`deployments.apps "${name}" not found`);
842
+ }
843
+ const p = patch as {
844
+ spec?: {
845
+ template?: {
846
+ metadata?: {
847
+ labels?: Record<string, string>;
848
+ annotations?: Record<string, string | null>;
849
+ };
850
+ spec?: {
851
+ containers?: KubernetesContainer[];
852
+ volumes?: Array<Record<string, unknown>>;
853
+ serviceAccountName?: string | null;
854
+ serviceAccount?: string | null;
855
+ };
856
+ };
857
+ };
858
+ };
859
+ // Emulate the strategic-merge semantics the real API server applies:
860
+ // container fields are replaced, except env (merges by name) and
861
+ // resources.requests/limits (merges key-by-key) — patched entries win,
862
+ // unmentioned live entries (e.g. Autopilot-injected cpu keys) survive.
863
+ const patchLabels = p.spec?.template?.metadata?.labels;
864
+ if (patchLabels) {
865
+ dep.spec.template.metadata.labels = {
866
+ ...dep.spec.template.metadata.labels,
867
+ ...patchLabels,
868
+ };
869
+ }
870
+ // Annotations merge key-by-key; a null value deletes the key.
871
+ const patchAnnotations = p.spec?.template?.metadata?.annotations;
872
+ if (patchAnnotations) {
873
+ const annotations = { ...dep.spec.template.metadata.annotations };
874
+ for (const [k, v] of Object.entries(patchAnnotations)) {
875
+ if (v === null) delete annotations[k];
876
+ else annotations[k] = v;
877
+ }
878
+ dep.spec.template.metadata.annotations = annotations;
879
+ }
880
+ // Pod volumes merge by name; `$patch: delete` removes the entry.
881
+ const patchVolumes = p.spec?.template?.spec?.volumes;
882
+ if (patchVolumes) {
883
+ dep.spec.template.spec.volumes = mergeListByKey(
884
+ dep.spec.template.spec.volumes ?? [],
885
+ patchVolumes,
886
+ "name",
887
+ ) as NonNullable<typeof dep.spec.template.spec.volumes>;
888
+ }
889
+ // Pod-level ServiceAccount scalars: a string sets, null deletes the key.
890
+ const patchPodSpec = p.spec?.template?.spec;
891
+ for (const field of ["serviceAccountName", "serviceAccount"] as const) {
892
+ if (patchPodSpec && field in patchPodSpec) {
893
+ const v = patchPodSpec[field];
894
+ if (v === null) delete dep.spec.template.spec[field];
895
+ else dep.spec.template.spec[field] = v;
896
+ }
897
+ }
898
+ const patchContainer = p.spec?.template?.spec?.containers?.[0];
899
+ const existing = dep.spec.template.spec.containers[0];
900
+ if (patchContainer !== undefined && existing !== undefined) {
901
+ const merged: KubernetesContainer = { ...existing, ...patchContainer };
902
+ if (patchContainer.volumeMounts) {
903
+ // volumeMounts merge by mountPath (not name).
904
+ merged.volumeMounts = mergeListByKey(
905
+ existing.volumeMounts ?? [],
906
+ patchContainer.volumeMounts,
907
+ "mountPath",
908
+ ) as NonNullable<KubernetesContainer["volumeMounts"]>;
909
+ }
910
+ if (patchContainer.env) {
911
+ // env merges by name; `$patch: delete` removes the entry.
912
+ const byName = new Map((existing.env ?? []).map((e) => [e.name, e]));
913
+ for (const entry of patchContainer.env) {
914
+ if (entry.$patch === "delete") byName.delete(entry.name);
915
+ else byName.set(entry.name, entry);
916
+ }
917
+ merged.env = [...byName.values()];
918
+ }
919
+ if (patchContainer.resources !== undefined) {
920
+ merged.resources = {
921
+ requests: {
922
+ ...existing.resources?.requests,
923
+ ...patchContainer.resources.requests,
924
+ },
925
+ limits: {
926
+ ...existing.resources?.limits,
927
+ ...patchContainer.resources.limits,
928
+ },
929
+ };
930
+ }
931
+ dep.spec.template.spec.containers[0] = merged;
932
+ }
933
+ }
934
+
935
+ async createSecret(
936
+ namespace: string,
937
+ spec: SecretSpec,
938
+ ): Promise<KubernetesSecret> {
939
+ const key = RecordedKubernetesClient.key(namespace, spec.name);
940
+ if (this.secrets.has(key)) {
941
+ throw new ConflictError(`secrets "${spec.name}" already exists`);
942
+ }
943
+ const secret = secretBody(namespace, spec);
944
+ this.secrets.set(key, secret);
945
+ return secret;
946
+ }
947
+
948
+ async getSecret(namespace: string, name: string): Promise<KubernetesSecret> {
949
+ const secret = this.secrets.get(
950
+ RecordedKubernetesClient.key(namespace, name),
951
+ );
952
+ if (!secret) {
953
+ throw new NotFoundError(`secrets "${name}" not found`);
954
+ }
955
+ return secret;
956
+ }
957
+
958
+ async deleteSecret(namespace: string, name: string): Promise<void> {
959
+ const key = RecordedKubernetesClient.key(namespace, name);
960
+ if (!this.secrets.has(key)) {
961
+ throw new NotFoundError(`secrets "${name}" not found`);
962
+ }
963
+ this.secrets.delete(key);
964
+ }
965
+
966
+ async createPvc(namespace: string, spec: PvcSpec): Promise<KubernetesPvc> {
967
+ const key = RecordedKubernetesClient.key(namespace, spec.name);
968
+ if (this.pvcs.has(key)) {
969
+ throw new ConflictError(
970
+ `persistentvolumeclaims "${spec.name}" already exists`,
971
+ );
972
+ }
973
+ const pvc = pvcBody(namespace, spec);
974
+ this.pvcs.set(key, pvc);
975
+ return pvc;
976
+ }
977
+
978
+ async getPvc(namespace: string, name: string): Promise<KubernetesPvc> {
979
+ const pvc = this.pvcs.get(RecordedKubernetesClient.key(namespace, name));
980
+ if (!pvc) {
981
+ throw new NotFoundError(`persistentvolumeclaims "${name}" not found`);
982
+ }
983
+ return pvc;
984
+ }
985
+
986
+ async deletePvc(namespace: string, name: string): Promise<void> {
987
+ const key = RecordedKubernetesClient.key(namespace, name);
988
+ if (!this.pvcs.has(key)) {
989
+ throw new NotFoundError(`persistentvolumeclaims "${name}" not found`);
990
+ }
991
+ this.pvcs.delete(key);
992
+ }
993
+
994
+ async createServiceAccount(
995
+ namespace: string,
996
+ spec: ServiceAccountSpec,
997
+ ): Promise<KubernetesServiceAccount> {
998
+ const key = RecordedKubernetesClient.key(namespace, spec.name);
999
+ if (this.serviceAccounts.has(key)) {
1000
+ throw new ConflictError(`serviceaccounts "${spec.name}" already exists`);
1001
+ }
1002
+ const sa = serviceAccountBody(namespace, spec);
1003
+ this.serviceAccounts.set(key, sa);
1004
+ return sa;
1005
+ }
1006
+
1007
+ async getServiceAccount(
1008
+ namespace: string,
1009
+ name: string,
1010
+ ): Promise<KubernetesServiceAccount> {
1011
+ const sa = this.serviceAccounts.get(
1012
+ RecordedKubernetesClient.key(namespace, name),
1013
+ );
1014
+ if (!sa) {
1015
+ throw new NotFoundError(`serviceaccounts "${name}" not found`);
1016
+ }
1017
+ return sa;
1018
+ }
1019
+
1020
+ async deleteServiceAccount(namespace: string, name: string): Promise<void> {
1021
+ const key = RecordedKubernetesClient.key(namespace, name);
1022
+ if (!this.serviceAccounts.has(key)) {
1023
+ throw new NotFoundError(`serviceaccounts "${name}" not found`);
1024
+ }
1025
+ this.serviceAccounts.delete(key);
1026
+ }
1027
+ }