@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.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,1650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/agent-provisioner.ts
|
|
3
|
+
*
|
|
4
|
+
* Provisions (and tears down) the Kubernetes resources backing a single
|
|
5
|
+
* Shipwright agent. This module COMPOSES three lower-level pieces:
|
|
6
|
+
*
|
|
7
|
+
* - AgentTokenService (./agent-tokens.ts) — mints the scoped per-agent token.
|
|
8
|
+
* - the pure manifest builders (./agent-manifest.ts) — shape the Secret +
|
|
9
|
+
* Deployment wire objects (RFC1123 name derivation, owner references, env).
|
|
10
|
+
* - a KubernetesClient (./kubernetes-client.ts) — performs the actual
|
|
11
|
+
* namespace-scoped create/get/delete API calls.
|
|
12
|
+
*
|
|
13
|
+
* Provisioning order is token → Secret → Deployment: the Deployment references
|
|
14
|
+
* the Secret by name (`secretKeyRef`), so the Secret must exist first.
|
|
15
|
+
*
|
|
16
|
+
* In perAgent identity mode (config.perAgentIdentity) each agent also gets its
|
|
17
|
+
* own Anthropic identity, created between the PVC and the Secret: Kubernetes
|
|
18
|
+
* ServiceAccount (named like the Deployment) → Anthropic service account
|
|
19
|
+
* (added to the workspace) → federation rule trusting exactly that
|
|
20
|
+
* ServiceAccount's token subject. Each ID is saved on the Agent row as soon as
|
|
21
|
+
* it exists; a retry reuses saved IDs, and find-by-name is only used when a
|
|
22
|
+
* create conflicts because an earlier ID write was lost.
|
|
23
|
+
*
|
|
24
|
+
* Both operations are SAFE TO RETRY:
|
|
25
|
+
* - provision() treats an already-existing Secret/Deployment (ConflictError /
|
|
26
|
+
* 409) as already-provisioned rather than failing, and if Deployment
|
|
27
|
+
* creation fails after the Secret was created it best-effort deletes the
|
|
28
|
+
* Secret so a retry starts from a clean slate. In perAgent mode any later
|
|
29
|
+
* failure also undoes, in reverse, the identity pieces THIS call created
|
|
30
|
+
* (rule, Anthropic service account, ServiceAccount) and clears their IDs.
|
|
31
|
+
* - deprovision() deletes the Deployment, then the Secret, then the PVC
|
|
32
|
+
* (unlike provision()'s rollback path, a deliberate full deprovision DOES
|
|
33
|
+
* remove the PVC) and swallows NotFoundError (404) so tearing down an
|
|
34
|
+
* absent / half-absent agent is a no-op rather than an error.
|
|
35
|
+
* - removeAnthropicIdentity() (perAgent mode, called by agent deletion after
|
|
36
|
+
* deprovision()) archives the federation rule, then the Anthropic service
|
|
37
|
+
* account, then deletes the ServiceAccount — strictly in that order,
|
|
38
|
+
* throwing at the first failure (Anthropic refuses to archive a service
|
|
39
|
+
* account a live rule references) and clearing each saved ID as its piece
|
|
40
|
+
* goes, so a retry only redoes what is left.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type {
|
|
44
|
+
AgentAnthropicIdentity,
|
|
45
|
+
AgentAnthropicIdentityService,
|
|
46
|
+
} from "./agent-anthropic-identity.ts";
|
|
47
|
+
import {
|
|
48
|
+
type AgentAnthropicFederation,
|
|
49
|
+
type AgentContainerResourceOverrides,
|
|
50
|
+
type AgentVoiceEnv,
|
|
51
|
+
ANTHROPIC_FEDERATION_ENV_NAMES,
|
|
52
|
+
ANTHROPIC_TOKEN_MOUNT_PATH,
|
|
53
|
+
ANTHROPIC_TOKEN_VOLUME,
|
|
54
|
+
buildAgentDeploymentManifest,
|
|
55
|
+
buildAgentSecretManifest,
|
|
56
|
+
CLAUDE_POLICY_HASH_ANNOTATION,
|
|
57
|
+
CLAUDE_POLICY_MOUNT_PATH,
|
|
58
|
+
CLAUDE_POLICY_VOLUME,
|
|
59
|
+
sanitizeAgentName,
|
|
60
|
+
TENANT_LABEL,
|
|
61
|
+
} from "./agent-manifest.ts";
|
|
62
|
+
import type { AgentTokenService } from "./agent-tokens.ts";
|
|
63
|
+
import {
|
|
64
|
+
AnthropicApiError,
|
|
65
|
+
AnthropicConflictError,
|
|
66
|
+
type AnthropicIdentityClient,
|
|
67
|
+
} from "./anthropic-identity-client.ts";
|
|
68
|
+
import type { AnthropicIdentityNameAllocator } from "./anthropic-identity-name.ts";
|
|
69
|
+
import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
|
|
70
|
+
import {
|
|
71
|
+
ConflictError,
|
|
72
|
+
NotFoundError,
|
|
73
|
+
UnprocessableEntityError,
|
|
74
|
+
} from "./errors.ts";
|
|
75
|
+
import type {
|
|
76
|
+
KubernetesClient,
|
|
77
|
+
KubernetesContainer,
|
|
78
|
+
KubernetesDeployment,
|
|
79
|
+
KubernetesEnvVar,
|
|
80
|
+
PvcSpec,
|
|
81
|
+
SecretSpec,
|
|
82
|
+
} from "./kubernetes-client.ts";
|
|
83
|
+
import {
|
|
84
|
+
provisionableIntegrations,
|
|
85
|
+
type ServiceIntegration,
|
|
86
|
+
} from "./service-integration.ts";
|
|
87
|
+
|
|
88
|
+
// ─── Interface ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Outcome of a successful (or already-satisfied) provision. */
|
|
91
|
+
export interface ProvisionResult {
|
|
92
|
+
/** RFC1123 Kubernetes object name shared by the Secret + Deployment. */
|
|
93
|
+
resourceName: string;
|
|
94
|
+
/** Name of the per-agent Secret carrying the token. */
|
|
95
|
+
secretName: string;
|
|
96
|
+
/** Name of the per-agent Deployment. */
|
|
97
|
+
deploymentName: string;
|
|
98
|
+
/**
|
|
99
|
+
* The raw token minted for this provision, or `undefined` when the resources
|
|
100
|
+
* already existed (ConflictError) and no new token was issued.
|
|
101
|
+
*/
|
|
102
|
+
rawToken?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Outcome of a reconcile pass. */
|
|
106
|
+
export interface ReconcileResult {
|
|
107
|
+
/** Agent IDs whose Deployments were missing and have been re-provisioned. */
|
|
108
|
+
recreated: string[];
|
|
109
|
+
/** K8s Deployment names that exist but are not tied to any known agent ID. */
|
|
110
|
+
orphans: string[];
|
|
111
|
+
/**
|
|
112
|
+
* Agent IDs whose re-provisioning failed with a transient or permanent error.
|
|
113
|
+
* These are not counted in `recreated`. The caller should retry or alert on
|
|
114
|
+
* non-empty `failed` arrays.
|
|
115
|
+
*/
|
|
116
|
+
failed: Array<{ agentId: string; error: string }>;
|
|
117
|
+
/**
|
|
118
|
+
* Agent IDs whose Deployments had drifted from the desired container spec
|
|
119
|
+
* (image, env, resources), Claude policy wiring or Anthropic federation
|
|
120
|
+
* wiring and have been patched back to it. In a dry run: the ones that
|
|
121
|
+
* would be.
|
|
122
|
+
*/
|
|
123
|
+
updated: string[];
|
|
124
|
+
/**
|
|
125
|
+
* perAgent mode: agent IDs moved off the shared ServiceAccount onto their own
|
|
126
|
+
* newly created identity (also listed in `updated`, as their Deployment was
|
|
127
|
+
* patched). In a dry run: the ones that would be. Always empty in shared mode
|
|
128
|
+
* and under `policyOnly`.
|
|
129
|
+
*/
|
|
130
|
+
migrated: string[];
|
|
131
|
+
/**
|
|
132
|
+
* perAgent mode: agent IDs skipped because they have no owner to name their
|
|
133
|
+
* identity after. Their federation wiring is left as it is, and a missing
|
|
134
|
+
* Deployment is not created. Not counted in `failed`.
|
|
135
|
+
*/
|
|
136
|
+
noOwner: string[];
|
|
137
|
+
/** True when the pass only reported: nothing was created, patched or saved. */
|
|
138
|
+
dryRun: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* An agent as reconcile sees it. The identity fields (the Agent row's owner and
|
|
143
|
+
* per-agent identity IDs) are optional: a target that carries them (`ownerEmail`
|
|
144
|
+
* defined, null when unset) is used as read; a bare `{ id }` target makes
|
|
145
|
+
* reconcile read them from the identity store itself.
|
|
146
|
+
*/
|
|
147
|
+
export interface ReconcileTarget extends Partial<AgentAnthropicIdentity> {
|
|
148
|
+
id: string;
|
|
149
|
+
slug?: string;
|
|
150
|
+
accountId?: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface AgentProvisioner {
|
|
154
|
+
/**
|
|
155
|
+
* Whether this provisioner actually creates cluster resources. `false` on the
|
|
156
|
+
* no-op implementation (Kubernetes provisioning disabled), where provision()
|
|
157
|
+
* returns plausible names without touching a cluster. Callers that offer an
|
|
158
|
+
* "in-cluster" runtime choice must check this first — otherwise an operator
|
|
159
|
+
* picks it, provision() silently succeeds, and they get an agent row with no
|
|
160
|
+
* pod behind it.
|
|
161
|
+
*/
|
|
162
|
+
readonly canProvision: boolean;
|
|
163
|
+
/** Mint a token and create the agent's Secret + Deployment. Idempotent. */
|
|
164
|
+
provision(
|
|
165
|
+
agentId: string,
|
|
166
|
+
opts?: { slug?: string; accountId?: string | null },
|
|
167
|
+
): Promise<ProvisionResult>;
|
|
168
|
+
/**
|
|
169
|
+
* Delete the agent's Deployment, Secret, and PVC. Tolerates already-absent
|
|
170
|
+
* resources (idempotent). `opts.slug` mirrors provision()'s shape and is
|
|
171
|
+
* used to resolve the PVC name when a `pvcName` template is configured.
|
|
172
|
+
*/
|
|
173
|
+
deprovision(agentId: string, opts?: { slug?: string }): Promise<void>;
|
|
174
|
+
/**
|
|
175
|
+
* Remove the agent's per-agent Anthropic identity: archive the federation
|
|
176
|
+
* rule, then the Anthropic service account, then delete the Kubernetes
|
|
177
|
+
* ServiceAccount, clearing each saved ID as soon as its piece is gone.
|
|
178
|
+
* Strictly in that order and stopping at the first failure (it throws):
|
|
179
|
+
* Anthropic refuses to archive a service account while a live rule
|
|
180
|
+
* references it. Already-absent pieces count as removed, so a retry only
|
|
181
|
+
* redoes what is left. Optional: provisioners without per-agent identities
|
|
182
|
+
* (the no-op one, test doubles) need not implement it.
|
|
183
|
+
*/
|
|
184
|
+
removeAnthropicIdentity?(agentId: string): Promise<void>;
|
|
185
|
+
/**
|
|
186
|
+
* Reconcile K8s Deployment state against a list of known agent IDs.
|
|
187
|
+
* Re-provisions agents whose Deployments are missing; surfaces orphaned
|
|
188
|
+
* Deployments that have no corresponding agent.
|
|
189
|
+
*
|
|
190
|
+
* `opts.policyOnly` restricts drift correction of existing Deployments to the
|
|
191
|
+
* Claude policy wiring (volume, mount, hash annotation) and the Anthropic
|
|
192
|
+
* federation wiring (ServiceAccount, token volume, mount, federation env
|
|
193
|
+
* entries); image, other env and resource drift is left untouched. Missing
|
|
194
|
+
* Deployments are still recreated.
|
|
195
|
+
*
|
|
196
|
+
* In perAgent identity mode a full pass (not `policyOnly`) also moves each
|
|
197
|
+
* existing agent that has an owner but no identity of its own onto a new one:
|
|
198
|
+
* it creates the identity and patches the Deployment off the shared
|
|
199
|
+
* ServiceAccount. The startup pass is `policyOnly` and so never creates
|
|
200
|
+
* Anthropic resources on a boot; an operator runs the full pass (first with
|
|
201
|
+
* `dryRun`) through POST /agents/reconcile. Agents without an owner are
|
|
202
|
+
* reported in `noOwner` and left alone.
|
|
203
|
+
*
|
|
204
|
+
* `opts.dryRun` makes the pass report only: nothing is created in Anthropic or
|
|
205
|
+
* Kubernetes, nothing is saved and no Deployment is patched; the result lists
|
|
206
|
+
* what would be recreated, updated and migrated.
|
|
207
|
+
*/
|
|
208
|
+
reconcile(
|
|
209
|
+
agents: ReconcileTarget[],
|
|
210
|
+
opts?: { policyOnly?: boolean; dryRun?: boolean },
|
|
211
|
+
): Promise<ReconcileResult>;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Configuration ──────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
export interface KubernetesAgentProvisionerConfig {
|
|
217
|
+
/** Namespace the agent resources live in. */
|
|
218
|
+
namespace: string;
|
|
219
|
+
/** Agent container image (without tag). */
|
|
220
|
+
image: string;
|
|
221
|
+
/** Image tag, joined as `image:tag`. */
|
|
222
|
+
imageTag: string;
|
|
223
|
+
/** In-cluster admin/API base URL the agent calls home to. */
|
|
224
|
+
apiUrl: string;
|
|
225
|
+
/**
|
|
226
|
+
* Build the PVC name for an agent from a single pre-resolved, RFC1123-safe
|
|
227
|
+
* name string. When a slug is provided (via `provision(agentId, { slug })`),
|
|
228
|
+
* `pvcNameFor` sanitizes it via `sanitizeAgentName` and passes the result as
|
|
229
|
+
* the sole argument; otherwise the sanitized `resourceName` (derived from
|
|
230
|
+
* `agentId`) is passed. Defaults to `<resourceName>-home`.
|
|
231
|
+
*/
|
|
232
|
+
pvcName?: (resourceName: string) => string;
|
|
233
|
+
/**
|
|
234
|
+
* Build the Secret name from the sanitized resource name.
|
|
235
|
+
* Defaults to `<name>-token`.
|
|
236
|
+
*/
|
|
237
|
+
secretName?: (resourceName: string) => string;
|
|
238
|
+
/** Key under which the token is stored in the Secret. Defaults to "token". */
|
|
239
|
+
tokenSecretKey?: string;
|
|
240
|
+
/** Replica count for the agent Deployment. Defaults to 1. */
|
|
241
|
+
replicas?: number;
|
|
242
|
+
/** Storage size in Gi for the agent home PVC. Defaults to 40. */
|
|
243
|
+
pvcStorageGi?: number;
|
|
244
|
+
/**
|
|
245
|
+
* Optional container resource overrides (cpuRequest, memoryRequest,
|
|
246
|
+
* memoryLimit, ephemeralStorage), merged field-by-field over the defaults in
|
|
247
|
+
* `resolveAgentContainerResources` (agent-manifest.ts). Sourced from the
|
|
248
|
+
* SHIPWRIGHT_K8S_AGENT_* env vars in main.ts's buildProvisioner. Omit (or
|
|
249
|
+
* leave individual fields unset) to keep today's defaults for those fields.
|
|
250
|
+
*/
|
|
251
|
+
resources?: AgentContainerResourceOverrides;
|
|
252
|
+
/**
|
|
253
|
+
* Optional agent-voice env flowed into provisioned agent pods. The admin reads
|
|
254
|
+
* these from its OWN env (sourced from the chart's voice Secret + the in-cluster
|
|
255
|
+
* Whisper Service URL). Omitted/empty → voice disabled (no voice env injected).
|
|
256
|
+
*/
|
|
257
|
+
voice?: AgentVoiceEnv;
|
|
258
|
+
/**
|
|
259
|
+
* Optional service integrations (see service-integration.ts), supplied by the
|
|
260
|
+
* composition root (e.g. task-store, registered by the coding extension).
|
|
261
|
+
* For each integration with a `url`, provision() mints a per-agent token
|
|
262
|
+
* (in list order, before the built-in chat-service token), stores it in the
|
|
263
|
+
* agent Secret under the integration's `secretKey`, and the Deployment
|
|
264
|
+
* manifest injects its token/URL env. On rollback each minted token is
|
|
265
|
+
* revoked via revokeToken(). Integrations without a `url` are skipped.
|
|
266
|
+
*/
|
|
267
|
+
serviceIntegrations?: readonly ServiceIntegration[];
|
|
268
|
+
/**
|
|
269
|
+
* When set, the provisioner will mint a per-agent chat-service token on
|
|
270
|
+
* provision() and store it in the agent Secret under "chat-service-token".
|
|
271
|
+
* The Deployment manifest will include SHIPWRIGHT_CHAT_SERVICE_TOKEN (from the
|
|
272
|
+
* Secret) and SHIPWRIGHT_CHAT_SERVICE_URL (from chatServiceUrl). On rollback,
|
|
273
|
+
* the minted token is revoked via revokeToken().
|
|
274
|
+
*
|
|
275
|
+
* Omit to skip chat-service wiring (for agents that don't need it or when
|
|
276
|
+
* SHIPWRIGHT_CHAT_SERVICE_URL / SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN are not set).
|
|
277
|
+
*/
|
|
278
|
+
chatService?: ChatServiceProvisioningClient;
|
|
279
|
+
/**
|
|
280
|
+
* In-cluster base URL of the chat service. Injected into the agent
|
|
281
|
+
* Deployment as SHIPWRIGHT_CHAT_SERVICE_URL. Required when chatService is set.
|
|
282
|
+
*/
|
|
283
|
+
chatServiceUrl?: string;
|
|
284
|
+
/**
|
|
285
|
+
* Claude Code managed-policy ConfigMap to mount into provisioned agent pods
|
|
286
|
+
* (see AgentDeploymentOpts.claudePolicy). Sourced from
|
|
287
|
+
* SHIPWRIGHT_K8S_AGENT_CLAUDE_POLICY_CONFIGMAP / _HASH in main.ts. Omit to
|
|
288
|
+
* leave the manifest unchanged.
|
|
289
|
+
*/
|
|
290
|
+
claudePolicy?: { configMapName: string; hash?: string };
|
|
291
|
+
/**
|
|
292
|
+
* Anthropic Workload Identity Federation wiring for provisioned agent pods
|
|
293
|
+
* (see AgentDeploymentOpts.anthropicFederation). Sourced from
|
|
294
|
+
* SHIPWRIGHT_K8S_AGENT_SERVICE_ACCOUNT and
|
|
295
|
+
* SHIPWRIGHT_K8S_AGENT_ANTHROPIC_FEDERATION_* in main.ts. Omit to leave the
|
|
296
|
+
* manifest unchanged.
|
|
297
|
+
*/
|
|
298
|
+
anthropicFederation?: AgentAnthropicFederation;
|
|
299
|
+
/**
|
|
300
|
+
* perAgent identity mode: give every agent its own Kubernetes ServiceAccount,
|
|
301
|
+
* Anthropic service account and federation rule at provision time, and build
|
|
302
|
+
* its Deployment from those IDs. Wired in main.ts's buildProvisioner when
|
|
303
|
+
* SHIPWRIGHT_K8S_AGENT_ANTHROPIC_FEDERATION_IDENTITY_MODE=perAgent; mutually
|
|
304
|
+
* exclusive with `anthropicFederation` (shared mode).
|
|
305
|
+
*/
|
|
306
|
+
perAgentIdentity?: PerAgentIdentityConfig;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── perAgent identity ──────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
/** Read/save of an agent's owner and identity IDs (AgentAnthropicIdentityService). */
|
|
312
|
+
export type AgentIdentityStore = Pick<
|
|
313
|
+
AgentAnthropicIdentityService,
|
|
314
|
+
"get" | "set"
|
|
315
|
+
>;
|
|
316
|
+
|
|
317
|
+
/** Idempotent identity-name allocation (AnthropicIdentityNameAllocator). */
|
|
318
|
+
export type AgentIdentityNameSource = Pick<
|
|
319
|
+
AnthropicIdentityNameAllocator,
|
|
320
|
+
"allocate"
|
|
321
|
+
>;
|
|
322
|
+
|
|
323
|
+
export interface PerAgentIdentityConfig {
|
|
324
|
+
/** Anthropic Admin API client (the admin pod's own federated sign-in). */
|
|
325
|
+
client: AnthropicIdentityClient;
|
|
326
|
+
identities: AgentIdentityStore;
|
|
327
|
+
names: AgentIdentityNameSource;
|
|
328
|
+
/** → ANTHROPIC_ORGANIZATION_ID */
|
|
329
|
+
organizationId: string;
|
|
330
|
+
/**
|
|
331
|
+
* Workspace each service account is added to and each rule is scoped to
|
|
332
|
+
* (→ ANTHROPIC_WORKSPACE_ID). Unset: no workspace step, and the rule and pod
|
|
333
|
+
* carry no workspace.
|
|
334
|
+
*/
|
|
335
|
+
workspaceId?: string;
|
|
336
|
+
/** This cluster's Anthropic federation issuer (the rules' issuer). */
|
|
337
|
+
issuerId: string;
|
|
338
|
+
/** Audience of the agent pods' projected token (also matched by the rule). */
|
|
339
|
+
audience: string;
|
|
340
|
+
/** Lifetime of the agent pods' projected token. */
|
|
341
|
+
expirationSeconds: number;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** OAuth scope of every per-agent federation rule. */
|
|
345
|
+
export const PER_AGENT_RULE_SCOPE = "workspace:inference";
|
|
346
|
+
/** Lifetime of the Anthropic access tokens a per-agent rule mints. */
|
|
347
|
+
export const PER_AGENT_RULE_TOKEN_LIFETIME_SECONDS = 600;
|
|
348
|
+
|
|
349
|
+
/** The token subject Kubernetes stamps on a pod's projected ServiceAccount token. */
|
|
350
|
+
export function serviceAccountSubject(
|
|
351
|
+
namespace: string,
|
|
352
|
+
serviceAccountName: string,
|
|
353
|
+
): string {
|
|
354
|
+
return `system:serviceaccount:${namespace}:${serviceAccountName}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** True when the agent has an owner and every per-agent identity ID saved. */
|
|
358
|
+
export function hasPerAgentIdentity(
|
|
359
|
+
identity: AgentAnthropicIdentity | null,
|
|
360
|
+
): boolean {
|
|
361
|
+
return Boolean(
|
|
362
|
+
identity?.ownerEmail &&
|
|
363
|
+
identity.k8sServiceAccountName &&
|
|
364
|
+
identity.anthropicServiceAccountId &&
|
|
365
|
+
identity.anthropicFederationRuleId,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The Deployment federation wiring for an agent's OWN identity.
|
|
371
|
+
* @throws NotFoundError when the agent row is missing
|
|
372
|
+
* @throws UnprocessableEntityError when the agent has no owner, or an ID is
|
|
373
|
+
* not provisioned yet
|
|
374
|
+
*/
|
|
375
|
+
export function perAgentFederation(
|
|
376
|
+
config: Pick<
|
|
377
|
+
PerAgentIdentityConfig,
|
|
378
|
+
"organizationId" | "workspaceId" | "audience" | "expirationSeconds"
|
|
379
|
+
>,
|
|
380
|
+
agentId: string,
|
|
381
|
+
identity: AgentAnthropicIdentity | null,
|
|
382
|
+
): AgentAnthropicFederation {
|
|
383
|
+
const ids = requireOwner(agentId, identity);
|
|
384
|
+
const {
|
|
385
|
+
k8sServiceAccountName,
|
|
386
|
+
anthropicServiceAccountId,
|
|
387
|
+
anthropicFederationRuleId,
|
|
388
|
+
} = ids;
|
|
389
|
+
if (
|
|
390
|
+
!k8sServiceAccountName ||
|
|
391
|
+
!anthropicServiceAccountId ||
|
|
392
|
+
!anthropicFederationRuleId
|
|
393
|
+
) {
|
|
394
|
+
const missing = (
|
|
395
|
+
[
|
|
396
|
+
"k8sServiceAccountName",
|
|
397
|
+
"anthropicServiceAccountId",
|
|
398
|
+
"anthropicFederationRuleId",
|
|
399
|
+
] as const
|
|
400
|
+
).filter((key) => !ids[key]);
|
|
401
|
+
throw new UnprocessableEntityError(
|
|
402
|
+
`Agent ${agentId} has no per-agent Anthropic identity yet (missing ${missing.join(", ")}); provision it first`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
serviceAccountName: k8sServiceAccountName,
|
|
407
|
+
ruleId: anthropicFederationRuleId,
|
|
408
|
+
organizationId: config.organizationId,
|
|
409
|
+
serviceAccountId: anthropicServiceAccountId,
|
|
410
|
+
...(config.workspaceId ? { workspaceId: config.workspaceId } : {}),
|
|
411
|
+
audience: config.audience,
|
|
412
|
+
expirationSeconds: config.expirationSeconds,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function requireOwner(
|
|
417
|
+
agentId: string,
|
|
418
|
+
identity: AgentAnthropicIdentity | null,
|
|
419
|
+
): AgentAnthropicIdentity & { ownerEmail: string } {
|
|
420
|
+
if (!identity) throw new NotFoundError(`Agent ${agentId} not found`);
|
|
421
|
+
const { ownerEmail } = identity;
|
|
422
|
+
if (!ownerEmail) {
|
|
423
|
+
throw new UnprocessableEntityError(
|
|
424
|
+
`Agent ${agentId} has no owner; perAgent identity mode needs an owner email to name the agent's Anthropic identity`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
return { ...identity, ownerEmail };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Identity pieces created by THIS provision() call — the rollback set. */
|
|
431
|
+
interface CreatedIdentity {
|
|
432
|
+
k8sServiceAccount?: string;
|
|
433
|
+
serviceAccountId?: string;
|
|
434
|
+
ruleId?: string;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function envEntryEqual(a: KubernetesEnvVar, b: KubernetesEnvVar): boolean {
|
|
438
|
+
return (
|
|
439
|
+
a.value === b.value &&
|
|
440
|
+
a.valueFrom?.secretKeyRef?.name === b.valueFrom?.secretKeyRef?.name &&
|
|
441
|
+
a.valueFrom?.secretKeyRef?.key === b.valueFrom?.secretKeyRef?.key
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* True when the live container is missing anything the desired container
|
|
447
|
+
* specifies (image, env entries, resource values).
|
|
448
|
+
*
|
|
449
|
+
* Subset semantics: extra live env vars (added manually via kubectl) and extra
|
|
450
|
+
* resource keys (injected by cluster autoscalers like GKE Autopilot) are NOT
|
|
451
|
+
* drift — reconcile must not fight other actors. Only a desired value that is
|
|
452
|
+
* absent or different counts.
|
|
453
|
+
*/
|
|
454
|
+
export function containerDrifted(
|
|
455
|
+
current: KubernetesContainer,
|
|
456
|
+
desired: KubernetesContainer,
|
|
457
|
+
): boolean {
|
|
458
|
+
if (current.image !== desired.image) return true;
|
|
459
|
+
|
|
460
|
+
const currentEnv = new Map((current.env ?? []).map((e) => [e.name, e]));
|
|
461
|
+
for (const entry of desired.env ?? []) {
|
|
462
|
+
const live = currentEnv.get(entry.name);
|
|
463
|
+
if (!live || !envEntryEqual(live, entry)) return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const cur = current.resources ?? {};
|
|
467
|
+
for (const bucket of ["requests", "limits"] as const) {
|
|
468
|
+
for (const [key, value] of Object.entries(
|
|
469
|
+
desired.resources?.[bucket] ?? {},
|
|
470
|
+
)) {
|
|
471
|
+
if (cur[bucket]?.[key] !== value) return true;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
type PolicyVolume = NonNullable<
|
|
479
|
+
KubernetesDeployment["spec"]["template"]["spec"]["volumes"]
|
|
480
|
+
>[number];
|
|
481
|
+
type PolicyMount = NonNullable<KubernetesContainer["volumeMounts"]>[number];
|
|
482
|
+
|
|
483
|
+
/** The policy-relevant slice of a Deployment; parts are undefined when absent. */
|
|
484
|
+
function policyParts(dep: KubernetesDeployment): {
|
|
485
|
+
volume?: PolicyVolume;
|
|
486
|
+
mount?: PolicyMount;
|
|
487
|
+
hash?: string;
|
|
488
|
+
} {
|
|
489
|
+
return {
|
|
490
|
+
volume: dep.spec.template.spec.volumes?.find(
|
|
491
|
+
(v) => v.name === CLAUDE_POLICY_VOLUME,
|
|
492
|
+
),
|
|
493
|
+
// Keyed by mountPath, the strategic-merge key the patch operates on, so a
|
|
494
|
+
// stray same-named mount elsewhere can't make every pass look drifted.
|
|
495
|
+
mount: dep.spec.template.spec.containers[0]?.volumeMounts?.find(
|
|
496
|
+
(m) => m.mountPath === CLAUDE_POLICY_MOUNT_PATH,
|
|
497
|
+
),
|
|
498
|
+
hash: dep.spec.template.metadata?.annotations?.[
|
|
499
|
+
CLAUDE_POLICY_HASH_ANNOTATION
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Strategic-merge fragments that bring the live Claude policy wiring (pod-level
|
|
506
|
+
* volume, container mount, hash annotation) to the desired state. A field is
|
|
507
|
+
* omitted when that part is already in sync.
|
|
508
|
+
*
|
|
509
|
+
* Removal needs explicit directives: strategic merge keys volumes by `name` and
|
|
510
|
+
* volumeMounts by `mountPath`, so omitting an entry would leave it in place.
|
|
511
|
+
* `$patch: delete` removes it, and a null annotation value deletes the key.
|
|
512
|
+
*/
|
|
513
|
+
interface PolicyPatch {
|
|
514
|
+
volumes?: object[];
|
|
515
|
+
volumeMounts?: object[];
|
|
516
|
+
annotations?: Record<string, string | null>;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The policy patch for a live Deployment, or undefined when already in sync. */
|
|
520
|
+
function policyPatch(
|
|
521
|
+
live: KubernetesDeployment,
|
|
522
|
+
desired: KubernetesDeployment,
|
|
523
|
+
): PolicyPatch | undefined {
|
|
524
|
+
const cur = policyParts(live);
|
|
525
|
+
const want = policyParts(desired);
|
|
526
|
+
const patch: PolicyPatch = {};
|
|
527
|
+
|
|
528
|
+
if (want.volume) {
|
|
529
|
+
if (cur.volume?.configMap?.name !== want.volume.configMap?.name) {
|
|
530
|
+
patch.volumes = [want.volume];
|
|
531
|
+
}
|
|
532
|
+
} else if (cur.volume) {
|
|
533
|
+
patch.volumes = [{ name: CLAUDE_POLICY_VOLUME, $patch: "delete" }];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (want.mount) {
|
|
537
|
+
if (
|
|
538
|
+
cur.mount?.name !== want.mount.name ||
|
|
539
|
+
cur.mount.readOnly !== want.mount.readOnly
|
|
540
|
+
) {
|
|
541
|
+
patch.volumeMounts = [want.mount];
|
|
542
|
+
}
|
|
543
|
+
} else if (cur.mount) {
|
|
544
|
+
patch.volumeMounts = [{ mountPath: cur.mount.mountPath, $patch: "delete" }];
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (cur.hash !== want.hash) {
|
|
548
|
+
patch.annotations = { [CLAUDE_POLICY_HASH_ANNOTATION]: want.hash ?? null };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return Object.keys(patch).length > 0 ? patch : undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* True when the live Deployment's Claude policy wiring differs from the desired
|
|
556
|
+
* one: a missing, extra or differently-pointed volume, a missing or extra
|
|
557
|
+
* mount, or a differing hash annotation. Covers "policy disabled but still
|
|
558
|
+
* present" too.
|
|
559
|
+
*/
|
|
560
|
+
export function policyDrifted(
|
|
561
|
+
live: KubernetesDeployment,
|
|
562
|
+
desired: KubernetesDeployment,
|
|
563
|
+
): boolean {
|
|
564
|
+
return policyPatch(live, desired) !== undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** The federation-relevant slice of a Deployment; parts are undefined when absent. */
|
|
568
|
+
function federationParts(dep: KubernetesDeployment): {
|
|
569
|
+
serviceAccountName?: string;
|
|
570
|
+
volume?: PolicyVolume;
|
|
571
|
+
mount?: PolicyMount;
|
|
572
|
+
env: Map<string, KubernetesEnvVar>;
|
|
573
|
+
} {
|
|
574
|
+
const podSpec = dep.spec.template.spec;
|
|
575
|
+
const container = podSpec.containers[0];
|
|
576
|
+
const names = new Set<string>(ANTHROPIC_FEDERATION_ENV_NAMES);
|
|
577
|
+
return {
|
|
578
|
+
serviceAccountName:
|
|
579
|
+
typeof podSpec.serviceAccountName === "string"
|
|
580
|
+
? podSpec.serviceAccountName
|
|
581
|
+
: undefined,
|
|
582
|
+
volume: podSpec.volumes?.find((v) => v.name === ANTHROPIC_TOKEN_VOLUME),
|
|
583
|
+
mount: container?.volumeMounts?.find(
|
|
584
|
+
(m) => m.mountPath === ANTHROPIC_TOKEN_MOUNT_PATH,
|
|
585
|
+
),
|
|
586
|
+
env: new Map(
|
|
587
|
+
(container?.env ?? [])
|
|
588
|
+
.filter((e) => names.has(e.name))
|
|
589
|
+
.map((e) => [e.name, e]),
|
|
590
|
+
),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
interface TokenSource {
|
|
595
|
+
audience?: string;
|
|
596
|
+
expirationSeconds?: number;
|
|
597
|
+
path?: string;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* True when two projected token volumes request the same token. Compares only
|
|
602
|
+
* the fields the builder sets, so server-defaulted fields (projected
|
|
603
|
+
* defaultMode) on the live volume are not drift.
|
|
604
|
+
*/
|
|
605
|
+
function tokenSourceEqual(a: PolicyVolume, b: PolicyVolume): boolean {
|
|
606
|
+
const src = (v: PolicyVolume): TokenSource | undefined =>
|
|
607
|
+
(v.projected as { sources?: Array<{ serviceAccountToken?: TokenSource }> })
|
|
608
|
+
?.sources?.[0]?.serviceAccountToken;
|
|
609
|
+
const x = src(a);
|
|
610
|
+
const y = src(b);
|
|
611
|
+
return (
|
|
612
|
+
x?.audience === y?.audience &&
|
|
613
|
+
x?.expirationSeconds === y?.expirationSeconds &&
|
|
614
|
+
x?.path === y?.path
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Strategic-merge fragments that bring the live federation wiring (pod
|
|
620
|
+
* ServiceAccount, projected token volume, container mount, federation env) to
|
|
621
|
+
* the desired state. A field is omitted when that part is already in sync.
|
|
622
|
+
*
|
|
623
|
+
* Removal uses explicit directives like policyPatch: `$patch: delete` on the
|
|
624
|
+
* volume (by name), mount (by mountPath) and each env entry (by name), and a
|
|
625
|
+
* null serviceAccountName. The deprecated `serviceAccount` alias is nulled too:
|
|
626
|
+
* the API server mirrors serviceAccountName into it and falls back to it when
|
|
627
|
+
* serviceAccountName is empty, so leaving it would resurrect the old name.
|
|
628
|
+
*/
|
|
629
|
+
interface FederationPatch {
|
|
630
|
+
serviceAccountName?: string | null;
|
|
631
|
+
serviceAccount?: null;
|
|
632
|
+
volumes?: object[];
|
|
633
|
+
volumeMounts?: object[];
|
|
634
|
+
env?: object[];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** The federation patch for a live Deployment, or undefined when already in sync. */
|
|
638
|
+
export function federationPatch(
|
|
639
|
+
live: KubernetesDeployment,
|
|
640
|
+
desired: KubernetesDeployment,
|
|
641
|
+
): FederationPatch | undefined {
|
|
642
|
+
const cur = federationParts(live);
|
|
643
|
+
const want = federationParts(desired);
|
|
644
|
+
const patch: FederationPatch = {};
|
|
645
|
+
|
|
646
|
+
if (want.serviceAccountName) {
|
|
647
|
+
if (cur.serviceAccountName !== want.serviceAccountName) {
|
|
648
|
+
patch.serviceAccountName = want.serviceAccountName;
|
|
649
|
+
}
|
|
650
|
+
} else if (cur.serviceAccountName && (cur.volume || cur.env.size > 0)) {
|
|
651
|
+
// Only unset a ServiceAccount that came with federation wiring; one set on
|
|
652
|
+
// the Deployment by another actor is left alone.
|
|
653
|
+
patch.serviceAccountName = null;
|
|
654
|
+
patch.serviceAccount = null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (want.volume) {
|
|
658
|
+
if (!cur.volume || !tokenSourceEqual(cur.volume, want.volume)) {
|
|
659
|
+
patch.volumes = [want.volume];
|
|
660
|
+
}
|
|
661
|
+
} else if (cur.volume) {
|
|
662
|
+
patch.volumes = [{ name: ANTHROPIC_TOKEN_VOLUME, $patch: "delete" }];
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (want.mount) {
|
|
666
|
+
if (
|
|
667
|
+
cur.mount?.name !== want.mount.name ||
|
|
668
|
+
cur.mount.readOnly !== want.mount.readOnly
|
|
669
|
+
) {
|
|
670
|
+
patch.volumeMounts = [want.mount];
|
|
671
|
+
}
|
|
672
|
+
} else if (cur.mount) {
|
|
673
|
+
patch.volumeMounts = [{ mountPath: cur.mount.mountPath, $patch: "delete" }];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const env: object[] = [];
|
|
677
|
+
for (const name of ANTHROPIC_FEDERATION_ENV_NAMES) {
|
|
678
|
+
const wantEntry = want.env.get(name);
|
|
679
|
+
const curEntry = cur.env.get(name);
|
|
680
|
+
if (wantEntry) {
|
|
681
|
+
if (!curEntry || !envEntryEqual(curEntry, wantEntry)) env.push(wantEntry);
|
|
682
|
+
} else if (curEntry) {
|
|
683
|
+
env.push({ name, $patch: "delete" });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (env.length > 0) patch.env = env;
|
|
687
|
+
|
|
688
|
+
return Object.keys(patch).length > 0 ? patch : undefined;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function isConflict(err: unknown): boolean {
|
|
692
|
+
return err instanceof ConflictError;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function isNotFound(err: unknown): boolean {
|
|
696
|
+
return err instanceof NotFoundError;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** An Anthropic archive that 404s means the resource is already gone. */
|
|
700
|
+
function isAnthropicNotFound(err: unknown): boolean {
|
|
701
|
+
return err instanceof AnthropicApiError && err.status === 404;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Create an Anthropic resource and return its ID (reporting it via
|
|
706
|
+
* `onCreated`). A name conflict means an earlier call created it but its ID
|
|
707
|
+
* write was lost: recover the live resource by name. If nothing by that name
|
|
708
|
+
* is live, the conflict is rethrown.
|
|
709
|
+
*/
|
|
710
|
+
async function createOrRecover(
|
|
711
|
+
create: () => Promise<{ id: string }>,
|
|
712
|
+
findByName: () => Promise<{ id: string } | null>,
|
|
713
|
+
onCreated: (id: string) => void,
|
|
714
|
+
): Promise<string> {
|
|
715
|
+
try {
|
|
716
|
+
const { id } = await create();
|
|
717
|
+
onCreated(id);
|
|
718
|
+
return id;
|
|
719
|
+
} catch (err) {
|
|
720
|
+
if (!(err instanceof AnthropicConflictError)) throw err;
|
|
721
|
+
const found = await findByName();
|
|
722
|
+
if (!found) throw err;
|
|
723
|
+
return found.id;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function tolerateAnthropicNotFound(
|
|
728
|
+
step: () => Promise<unknown>,
|
|
729
|
+
): Promise<void> {
|
|
730
|
+
try {
|
|
731
|
+
await step();
|
|
732
|
+
} catch (err) {
|
|
733
|
+
if (!isAnthropicNotFound(err)) throw err;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** Run a rollback step; log (never throw) on failure so the original error surfaces. */
|
|
738
|
+
async function bestEffort(
|
|
739
|
+
what: string,
|
|
740
|
+
step: () => Promise<unknown>,
|
|
741
|
+
): Promise<void> {
|
|
742
|
+
try {
|
|
743
|
+
await step();
|
|
744
|
+
} catch (err) {
|
|
745
|
+
console.warn(
|
|
746
|
+
`[provisioner] rollback: failed to ${what}: ${err instanceof Error ? err.message : String(err)}`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ─── Kubernetes implementation ──────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
export class KubernetesAgentProvisioner implements AgentProvisioner {
|
|
754
|
+
readonly canProvision = true;
|
|
755
|
+
|
|
756
|
+
private readonly tokenKey: string;
|
|
757
|
+
|
|
758
|
+
constructor(
|
|
759
|
+
private readonly k8s: KubernetesClient,
|
|
760
|
+
private readonly tokens: AgentTokenService,
|
|
761
|
+
private readonly config: KubernetesAgentProvisionerConfig,
|
|
762
|
+
) {
|
|
763
|
+
this.tokenKey = config.tokenSecretKey ?? "token";
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private resourceName(agentId: string): string {
|
|
767
|
+
return sanitizeAgentName(agentId);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private secretNameFor(resourceName: string): string {
|
|
771
|
+
return this.config.secretName
|
|
772
|
+
? this.config.secretName(resourceName)
|
|
773
|
+
: `${resourceName}-token`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private pvcNameFor(resourceName: string, slug?: string): string {
|
|
777
|
+
return this.config.pvcName
|
|
778
|
+
? this.config.pvcName(slug ? sanitizeAgentName(slug) : resourceName)
|
|
779
|
+
: `${resourceName}-home`;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* The agent's owner and identity IDs for reconcile: the target's own fields
|
|
784
|
+
* when it carries them, else the identity store's row (null: no such agent).
|
|
785
|
+
*/
|
|
786
|
+
private async identityOf(
|
|
787
|
+
agent: ReconcileTarget,
|
|
788
|
+
): Promise<AgentAnthropicIdentity | null> {
|
|
789
|
+
if (agent.ownerEmail !== undefined) {
|
|
790
|
+
return {
|
|
791
|
+
ownerEmail: agent.ownerEmail,
|
|
792
|
+
anthropicIdentityName: agent.anthropicIdentityName ?? null,
|
|
793
|
+
anthropicServiceAccountId: agent.anthropicServiceAccountId ?? null,
|
|
794
|
+
anthropicFederationRuleId: agent.anthropicFederationRuleId ?? null,
|
|
795
|
+
k8sServiceAccountName: agent.k8sServiceAccountName ?? null,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
const perAgent = this.config.perAgentIdentity;
|
|
799
|
+
return perAgent ? perAgent.identities.get(agent.id) : null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* The federation wiring reconcile drift-corrects an existing Deployment to.
|
|
804
|
+
* `manage: false` means leave the live federation wiring as it is: in
|
|
805
|
+
* perAgent mode an agent without its own identity keeps whatever it runs with
|
|
806
|
+
* until it is migrated (`move` set: it has an owner, so a full pass can
|
|
807
|
+
* create the identity) or for good (`noOwner` set: it has none).
|
|
808
|
+
*/
|
|
809
|
+
private async reconcileFederationFor(agent: ReconcileTarget): Promise<{
|
|
810
|
+
federation?: AgentAnthropicFederation;
|
|
811
|
+
manage: boolean;
|
|
812
|
+
move?: AgentAnthropicIdentity & { ownerEmail: string };
|
|
813
|
+
noOwner?: boolean;
|
|
814
|
+
}> {
|
|
815
|
+
const perAgent = this.config.perAgentIdentity;
|
|
816
|
+
if (!perAgent) {
|
|
817
|
+
return { federation: this.config.anthropicFederation, manage: true };
|
|
818
|
+
}
|
|
819
|
+
const identity = await this.identityOf(agent);
|
|
820
|
+
if (!identity) return { manage: false };
|
|
821
|
+
if (hasPerAgentIdentity(identity)) {
|
|
822
|
+
return {
|
|
823
|
+
federation: perAgentFederation(perAgent, agent.id, identity),
|
|
824
|
+
manage: true,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
const { ownerEmail } = identity;
|
|
828
|
+
return ownerEmail
|
|
829
|
+
? { manage: false, move: { ...identity, ownerEmail } }
|
|
830
|
+
: { manage: false, noOwner: true };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The desired Deployment manifest for an agent — the single source of truth
|
|
835
|
+
* used both to create (provision) and to drift-correct (reconcile).
|
|
836
|
+
* `federation` is the agent's resolved wiring: the shared identity, its own
|
|
837
|
+
* per-agent identity (provision / reconcileFederationFor), or none.
|
|
838
|
+
*/
|
|
839
|
+
private deploymentManifestFor(
|
|
840
|
+
agentId: string,
|
|
841
|
+
slug: string | undefined,
|
|
842
|
+
accountId: string | null | undefined,
|
|
843
|
+
federation: AgentAnthropicFederation | undefined,
|
|
844
|
+
) {
|
|
845
|
+
const resourceName = this.resourceName(agentId);
|
|
846
|
+
return buildAgentDeploymentManifest({
|
|
847
|
+
agentId,
|
|
848
|
+
namespace: this.config.namespace,
|
|
849
|
+
image: this.config.image,
|
|
850
|
+
imageTag: this.config.imageTag,
|
|
851
|
+
apiUrl: this.config.apiUrl,
|
|
852
|
+
pvcName: this.pvcNameFor(resourceName, slug),
|
|
853
|
+
secretName: this.secretNameFor(resourceName),
|
|
854
|
+
tokenSecretKey: this.tokenKey,
|
|
855
|
+
replicas: this.config.replicas,
|
|
856
|
+
resources: this.config.resources,
|
|
857
|
+
voice: this.config.voice,
|
|
858
|
+
serviceIntegrations: provisionableIntegrations(
|
|
859
|
+
this.config.serviceIntegrations,
|
|
860
|
+
),
|
|
861
|
+
chatServiceUrl: this.config.chatServiceUrl,
|
|
862
|
+
claudePolicy: this.config.claudePolicy,
|
|
863
|
+
anthropicFederation: federation,
|
|
864
|
+
accountId,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
async provision(
|
|
869
|
+
agentId: string,
|
|
870
|
+
opts?: { slug?: string; accountId?: string | null },
|
|
871
|
+
): Promise<ProvisionResult> {
|
|
872
|
+
const resourceName = this.resourceName(agentId);
|
|
873
|
+
const secretName = this.secretNameFor(resourceName);
|
|
874
|
+
const pvcName = this.pvcNameFor(resourceName, opts?.slug);
|
|
875
|
+
const result: ProvisionResult = {
|
|
876
|
+
resourceName,
|
|
877
|
+
secretName,
|
|
878
|
+
deploymentName: resourceName,
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// perAgent mode needs an owner to name the identity: check it BEFORE
|
|
882
|
+
// creating anything, so an ownerless agent fails cleanly.
|
|
883
|
+
const perAgent = this.config.perAgentIdentity;
|
|
884
|
+
const identity = perAgent
|
|
885
|
+
? requireOwner(agentId, await perAgent.identities.get(agentId))
|
|
886
|
+
: undefined;
|
|
887
|
+
|
|
888
|
+
// 1. PVC — must exist before the Deployment that mounts it. A 409 means the
|
|
889
|
+
// PVC already exists from a prior provision; treat as idempotent success.
|
|
890
|
+
// On any subsequent failure, do NOT delete the PVC (data safety policy).
|
|
891
|
+
try {
|
|
892
|
+
await this.k8s.createPvc(this.config.namespace, this.pvcSpec(pvcName));
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (!isConflict(err)) throw err;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (!perAgent || !identity) {
|
|
898
|
+
return this.provisionSecretAndDeployment(
|
|
899
|
+
agentId,
|
|
900
|
+
opts,
|
|
901
|
+
result,
|
|
902
|
+
this.config.anthropicFederation,
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// perAgent: the identity steps, then Secret + Deployment with the agent's
|
|
907
|
+
// own IDs. Any failure undoes (best-effort, in reverse) the identity pieces
|
|
908
|
+
// THIS call created; the Secret step rolls itself back first.
|
|
909
|
+
const created: CreatedIdentity = {};
|
|
910
|
+
try {
|
|
911
|
+
const federation = perAgentFederation(
|
|
912
|
+
perAgent,
|
|
913
|
+
agentId,
|
|
914
|
+
await this.provisionIdentity(
|
|
915
|
+
perAgent,
|
|
916
|
+
agentId,
|
|
917
|
+
resourceName,
|
|
918
|
+
identity,
|
|
919
|
+
created,
|
|
920
|
+
),
|
|
921
|
+
);
|
|
922
|
+
return await this.provisionSecretAndDeployment(
|
|
923
|
+
agentId,
|
|
924
|
+
opts,
|
|
925
|
+
result,
|
|
926
|
+
federation,
|
|
927
|
+
);
|
|
928
|
+
} catch (err) {
|
|
929
|
+
await this.rollbackIdentity(perAgent, agentId, created);
|
|
930
|
+
throw err;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Steps 1a–1d of perAgent provisioning. Each ID already saved on the agent is
|
|
936
|
+
* reused; each new one is saved right after its create call. Returns the
|
|
937
|
+
* agent's identity with every ID set. Records what it creates in `created`.
|
|
938
|
+
*/
|
|
939
|
+
private async provisionIdentity(
|
|
940
|
+
perAgent: PerAgentIdentityConfig,
|
|
941
|
+
agentId: string,
|
|
942
|
+
resourceName: string,
|
|
943
|
+
initial: AgentAnthropicIdentity & { ownerEmail: string },
|
|
944
|
+
created: CreatedIdentity,
|
|
945
|
+
): Promise<AgentAnthropicIdentity> {
|
|
946
|
+
const { client, identities, workspaceId } = perAgent;
|
|
947
|
+
let identity: AgentAnthropicIdentity = initial;
|
|
948
|
+
|
|
949
|
+
// 1a. Kubernetes ServiceAccount, named like the Deployment. 409 = it already
|
|
950
|
+
// exists (an earlier call created it), so it is not ours to roll back.
|
|
951
|
+
try {
|
|
952
|
+
await this.k8s.createServiceAccount(this.config.namespace, {
|
|
953
|
+
name: resourceName,
|
|
954
|
+
});
|
|
955
|
+
created.k8sServiceAccount = resourceName;
|
|
956
|
+
} catch (err) {
|
|
957
|
+
if (!isConflict(err)) throw err;
|
|
958
|
+
}
|
|
959
|
+
if (identity.k8sServiceAccountName !== resourceName) {
|
|
960
|
+
identity = await identities.set(agentId, {
|
|
961
|
+
k8sServiceAccountName: resourceName,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// The Anthropic service account and rule share the agent's identity name.
|
|
966
|
+
const name = await perAgent.names.allocate(agentId, initial.ownerEmail);
|
|
967
|
+
const description = `Shipwright agent ${agentId}`;
|
|
968
|
+
|
|
969
|
+
// 1b. Anthropic service account.
|
|
970
|
+
let serviceAccountId = identity.anthropicServiceAccountId;
|
|
971
|
+
if (!serviceAccountId) {
|
|
972
|
+
serviceAccountId = await createOrRecover(
|
|
973
|
+
() => client.createServiceAccount({ name, description }),
|
|
974
|
+
() => client.findServiceAccountByName(name),
|
|
975
|
+
(id) => {
|
|
976
|
+
created.serviceAccountId = id;
|
|
977
|
+
},
|
|
978
|
+
);
|
|
979
|
+
identity = await identities.set(agentId, {
|
|
980
|
+
anthropicServiceAccountId: serviceAccountId,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// 1c. Workspace membership. 409 = already a member (an earlier call).
|
|
985
|
+
if (workspaceId) {
|
|
986
|
+
try {
|
|
987
|
+
await client.addServiceAccountToWorkspace({
|
|
988
|
+
serviceAccountId,
|
|
989
|
+
workspaceId,
|
|
990
|
+
});
|
|
991
|
+
} catch (err) {
|
|
992
|
+
if (!(err instanceof AnthropicConflictError)) throw err;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// 1d. Federation rule: trusts exactly this pod's ServiceAccount token
|
|
997
|
+
// (no trailing `*`, so subject_prefix is an exact match).
|
|
998
|
+
if (!identity.anthropicFederationRuleId) {
|
|
999
|
+
const ruleId = await createOrRecover(
|
|
1000
|
+
() =>
|
|
1001
|
+
client.createFederationRule({
|
|
1002
|
+
name,
|
|
1003
|
+
description,
|
|
1004
|
+
issuerId: perAgent.issuerId,
|
|
1005
|
+
serviceAccountId,
|
|
1006
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
1007
|
+
oauthScope: PER_AGENT_RULE_SCOPE,
|
|
1008
|
+
match: {
|
|
1009
|
+
subjectPrefix: serviceAccountSubject(
|
|
1010
|
+
this.config.namespace,
|
|
1011
|
+
resourceName,
|
|
1012
|
+
),
|
|
1013
|
+
audience: perAgent.audience,
|
|
1014
|
+
},
|
|
1015
|
+
tokenLifetimeSeconds: PER_AGENT_RULE_TOKEN_LIFETIME_SECONDS,
|
|
1016
|
+
}),
|
|
1017
|
+
() => client.findFederationRuleByName(name),
|
|
1018
|
+
(id) => {
|
|
1019
|
+
created.ruleId = id;
|
|
1020
|
+
},
|
|
1021
|
+
);
|
|
1022
|
+
identity = await identities.set(agentId, {
|
|
1023
|
+
anthropicFederationRuleId: ruleId,
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return identity;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Best-effort undo of the identity pieces one provision() call created, in
|
|
1032
|
+
* reverse creation order, then clear their saved IDs. Never throws: the
|
|
1033
|
+
* caller's original error is what surfaces. A piece whose undo fails is left
|
|
1034
|
+
* live under the agent's identity name, so a retry recovers it by name.
|
|
1035
|
+
*/
|
|
1036
|
+
private async rollbackIdentity(
|
|
1037
|
+
perAgent: PerAgentIdentityConfig,
|
|
1038
|
+
agentId: string,
|
|
1039
|
+
created: CreatedIdentity,
|
|
1040
|
+
): Promise<void> {
|
|
1041
|
+
const { client, identities } = perAgent;
|
|
1042
|
+
const clear: Partial<AgentAnthropicIdentity> = {};
|
|
1043
|
+
if (created.ruleId) {
|
|
1044
|
+
await bestEffort(`archive federation rule ${created.ruleId}`, () =>
|
|
1045
|
+
client.archiveFederationRule(created.ruleId as string),
|
|
1046
|
+
);
|
|
1047
|
+
clear.anthropicFederationRuleId = null;
|
|
1048
|
+
}
|
|
1049
|
+
if (created.serviceAccountId) {
|
|
1050
|
+
await bestEffort(
|
|
1051
|
+
`archive Anthropic service account ${created.serviceAccountId}`,
|
|
1052
|
+
() => client.archiveServiceAccount(created.serviceAccountId as string),
|
|
1053
|
+
);
|
|
1054
|
+
clear.anthropicServiceAccountId = null;
|
|
1055
|
+
}
|
|
1056
|
+
if (created.k8sServiceAccount) {
|
|
1057
|
+
await bestEffort(
|
|
1058
|
+
`delete ServiceAccount ${created.k8sServiceAccount}`,
|
|
1059
|
+
() =>
|
|
1060
|
+
this.k8s.deleteServiceAccount(
|
|
1061
|
+
this.config.namespace,
|
|
1062
|
+
created.k8sServiceAccount as string,
|
|
1063
|
+
),
|
|
1064
|
+
);
|
|
1065
|
+
clear.k8sServiceAccountName = null;
|
|
1066
|
+
}
|
|
1067
|
+
if (Object.keys(clear).length > 0) {
|
|
1068
|
+
await bestEffort(`clear identity IDs of agent ${agentId}`, () =>
|
|
1069
|
+
identities.set(agentId, clear),
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Steps 2–4: token(s) → Secret → Deployment (the original provision flow). */
|
|
1075
|
+
private async provisionSecretAndDeployment(
|
|
1076
|
+
agentId: string,
|
|
1077
|
+
opts: { slug?: string; accountId?: string | null } | undefined,
|
|
1078
|
+
result: ProvisionResult,
|
|
1079
|
+
federation: AgentAnthropicFederation | undefined,
|
|
1080
|
+
): Promise<ProvisionResult> {
|
|
1081
|
+
const { secretName } = result;
|
|
1082
|
+
|
|
1083
|
+
// 2. Token — mint only when the Secret does NOT already exist. If the Secret
|
|
1084
|
+
// is already present (a prior provision completed or partially ran), we
|
|
1085
|
+
// skip minting so no orphaned token rows accumulate in the DB and the
|
|
1086
|
+
// returned rawToken correctly signals "use the existing credential" via
|
|
1087
|
+
// undefined. Only mint a fresh token when we're about to write a new Secret.
|
|
1088
|
+
let secretCreated = false;
|
|
1089
|
+
let secretAlreadyExists = false;
|
|
1090
|
+
try {
|
|
1091
|
+
await this.k8s.getSecret(this.config.namespace, secretName);
|
|
1092
|
+
secretAlreadyExists = true;
|
|
1093
|
+
} catch (err) {
|
|
1094
|
+
if (!isNotFound(err)) throw err;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Track the integration and chat-service tokens minted in THIS call so we
|
|
1098
|
+
// can revoke them on rollback. Only set when the respective config is provided
|
|
1099
|
+
// and we actually minted.
|
|
1100
|
+
const integrations = provisionableIntegrations(
|
|
1101
|
+
this.config.serviceIntegrations,
|
|
1102
|
+
);
|
|
1103
|
+
const integrationTokens: {
|
|
1104
|
+
integration: ServiceIntegration;
|
|
1105
|
+
id: string;
|
|
1106
|
+
rawToken: string;
|
|
1107
|
+
}[] = [];
|
|
1108
|
+
let csTokenId: string | undefined;
|
|
1109
|
+
|
|
1110
|
+
if (!secretAlreadyExists) {
|
|
1111
|
+
// Optionally mint a token per service integration (list order, before
|
|
1112
|
+
// chat) to co-locate in the same Secret write.
|
|
1113
|
+
for (const integration of integrations) {
|
|
1114
|
+
const { id, rawToken } = await integration.mintToken(
|
|
1115
|
+
`agent:${agentId}`,
|
|
1116
|
+
agentId,
|
|
1117
|
+
);
|
|
1118
|
+
integrationTokens.push({ integration, id, rawToken });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Optionally mint a chat-service token to co-locate in the same Secret write.
|
|
1122
|
+
let csRawToken: string | undefined;
|
|
1123
|
+
if (this.config.chatService) {
|
|
1124
|
+
const { id, rawToken } = await this.config.chatService.mintToken(
|
|
1125
|
+
`agent:${agentId}`,
|
|
1126
|
+
agentId,
|
|
1127
|
+
);
|
|
1128
|
+
csTokenId = id;
|
|
1129
|
+
csRawToken = rawToken;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const { rawToken } = await this.tokens.create(agentId, "k8s-provision");
|
|
1133
|
+
result.rawToken = rawToken;
|
|
1134
|
+
|
|
1135
|
+
// 3. Secret — must exist before the Deployment that references it.
|
|
1136
|
+
// A 409 here is unexpected (we just confirmed it was absent), but treat
|
|
1137
|
+
// it as already-present and continue to the Deployment step.
|
|
1138
|
+
try {
|
|
1139
|
+
await this.k8s.createSecret(
|
|
1140
|
+
this.config.namespace,
|
|
1141
|
+
this.secretSpec(
|
|
1142
|
+
secretName,
|
|
1143
|
+
rawToken,
|
|
1144
|
+
integrationTokens.map((t) => ({
|
|
1145
|
+
secretKey: t.integration.secretKey,
|
|
1146
|
+
rawToken: t.rawToken,
|
|
1147
|
+
})),
|
|
1148
|
+
csRawToken,
|
|
1149
|
+
),
|
|
1150
|
+
);
|
|
1151
|
+
secretCreated = true;
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
if (!isConflict(err)) throw err;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// 4. Deployment. If creation fails AFTER we created the Secret in THIS call,
|
|
1158
|
+
// roll the Secret back (best-effort) so a retry starts clean and never
|
|
1159
|
+
// leaks a half-provisioned state. The PVC is NOT rolled back (data safety).
|
|
1160
|
+
// Also revoke any integration and chat-service tokens minted in THIS call.
|
|
1161
|
+
try {
|
|
1162
|
+
await this.k8s.createDeploymentManifest(
|
|
1163
|
+
this.config.namespace,
|
|
1164
|
+
this.deploymentManifestFor(
|
|
1165
|
+
agentId,
|
|
1166
|
+
opts?.slug,
|
|
1167
|
+
opts?.accountId,
|
|
1168
|
+
federation,
|
|
1169
|
+
),
|
|
1170
|
+
);
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
if (isConflict(err)) {
|
|
1173
|
+
// Deployment already exists — idempotent success. But if we minted and
|
|
1174
|
+
// created a NEW Secret in THIS call, the pre-existing Deployment isn't
|
|
1175
|
+
// using it; roll the orphaned Secret (and its fresh tokens) back so we
|
|
1176
|
+
// never leak it. Only ever delete a Secret THIS call created.
|
|
1177
|
+
if (secretCreated) {
|
|
1178
|
+
await this.deleteSecretBestEffort(secretName);
|
|
1179
|
+
await this.revokeIntegrationTokensBestEffort(integrationTokens);
|
|
1180
|
+
await this.revokeChatServiceTokenBestEffort(csTokenId);
|
|
1181
|
+
}
|
|
1182
|
+
return result;
|
|
1183
|
+
}
|
|
1184
|
+
if (secretCreated) {
|
|
1185
|
+
await this.deleteSecretBestEffort(secretName);
|
|
1186
|
+
await this.revokeIntegrationTokensBestEffort(integrationTokens);
|
|
1187
|
+
await this.revokeChatServiceTokenBestEffort(csTokenId);
|
|
1188
|
+
}
|
|
1189
|
+
throw err;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return result;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async removeAnthropicIdentity(agentId: string): Promise<void> {
|
|
1196
|
+
const perAgent = this.config.perAgentIdentity;
|
|
1197
|
+
if (!perAgent) {
|
|
1198
|
+
throw new Error(
|
|
1199
|
+
"per-agent Anthropic identity is not configured on this provisioner, so the agent's identity cannot be removed automatically",
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
const { client, identities } = perAgent;
|
|
1203
|
+
const identity = await identities.get(agentId);
|
|
1204
|
+
if (!identity) return;
|
|
1205
|
+
|
|
1206
|
+
// Strict order, no best-effort: a failure throws so the caller keeps the
|
|
1207
|
+
// Agent row. Each ID is cleared right after its piece is gone, so a retry
|
|
1208
|
+
// only redoes what is left.
|
|
1209
|
+
if (identity.anthropicFederationRuleId) {
|
|
1210
|
+
await tolerateAnthropicNotFound(() =>
|
|
1211
|
+
client.archiveFederationRule(
|
|
1212
|
+
identity.anthropicFederationRuleId as string,
|
|
1213
|
+
),
|
|
1214
|
+
);
|
|
1215
|
+
await identities.set(agentId, { anthropicFederationRuleId: null });
|
|
1216
|
+
}
|
|
1217
|
+
if (identity.anthropicServiceAccountId) {
|
|
1218
|
+
await tolerateAnthropicNotFound(() =>
|
|
1219
|
+
client.archiveServiceAccount(
|
|
1220
|
+
identity.anthropicServiceAccountId as string,
|
|
1221
|
+
),
|
|
1222
|
+
);
|
|
1223
|
+
await identities.set(agentId, { anthropicServiceAccountId: null });
|
|
1224
|
+
}
|
|
1225
|
+
if (identity.k8sServiceAccountName) {
|
|
1226
|
+
try {
|
|
1227
|
+
await this.k8s.deleteServiceAccount(
|
|
1228
|
+
this.config.namespace,
|
|
1229
|
+
identity.k8sServiceAccountName,
|
|
1230
|
+
);
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
if (!isNotFound(err)) throw err;
|
|
1233
|
+
}
|
|
1234
|
+
await identities.set(agentId, { k8sServiceAccountName: null });
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async deprovision(agentId: string, opts?: { slug?: string }): Promise<void> {
|
|
1239
|
+
const resourceName = this.resourceName(agentId);
|
|
1240
|
+
const secretName = this.secretNameFor(resourceName);
|
|
1241
|
+
const pvcName = this.pvcNameFor(resourceName, opts?.slug);
|
|
1242
|
+
|
|
1243
|
+
// Delete the Deployment first (it depends on the Secret), then the Secret,
|
|
1244
|
+
// then the PVC. Already-absent resources (404) are swallowed so teardown
|
|
1245
|
+
// is idempotent. Unlike provision()'s rollback path (which deliberately
|
|
1246
|
+
// never deletes the PVC — see the "data safety policy" note above), a
|
|
1247
|
+
// deliberate full agent deprovision also removes the PVC so deleted
|
|
1248
|
+
// agents don't leak storage indefinitely.
|
|
1249
|
+
await this.deleteDeploymentBestEffort(resourceName);
|
|
1250
|
+
await this.deleteSecretBestEffort(secretName);
|
|
1251
|
+
await this.deletePvcBestEffort(pvcName);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async reconcile(
|
|
1255
|
+
agents: ReconcileTarget[],
|
|
1256
|
+
opts?: { policyOnly?: boolean; dryRun?: boolean },
|
|
1257
|
+
): Promise<ReconcileResult> {
|
|
1258
|
+
const policyOnly = opts?.policyOnly === true;
|
|
1259
|
+
const dryRun = opts?.dryRun === true;
|
|
1260
|
+
const labelSelector =
|
|
1261
|
+
"app.kubernetes.io/name=shipwright-agent,app.kubernetes.io/managed-by=shipwright-admin";
|
|
1262
|
+
|
|
1263
|
+
// List all agent Deployments currently in k8s.
|
|
1264
|
+
const k8sNames = await this.k8s.listDeployments(
|
|
1265
|
+
this.config.namespace,
|
|
1266
|
+
labelSelector,
|
|
1267
|
+
);
|
|
1268
|
+
const k8sNameSet = new Set(k8sNames);
|
|
1269
|
+
|
|
1270
|
+
// Build a map from sanitized resource name → original agent entry, and track
|
|
1271
|
+
// the full set of expected k8s names.
|
|
1272
|
+
const expectedNames = new Map<string, ReconcileTarget>(); // resourceName → agent
|
|
1273
|
+
for (const agent of agents) {
|
|
1274
|
+
expectedNames.set(this.resourceName(agent.id), agent);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const recreated: string[] = [];
|
|
1278
|
+
const updated: string[] = [];
|
|
1279
|
+
const migrated: string[] = [];
|
|
1280
|
+
const noOwner: string[] = [];
|
|
1281
|
+
const orphans: string[] = [];
|
|
1282
|
+
const failed: Array<{ agentId: string; error: string }> = [];
|
|
1283
|
+
|
|
1284
|
+
// Recreate Deployments that should exist but are missing in k8s.
|
|
1285
|
+
// For Deployments that already exist, check whether the image is stale and
|
|
1286
|
+
// patch it if so. Each operation is wrapped in try/catch so a single transient
|
|
1287
|
+
// K8s error does not abort the loop — the remaining agents are always checked.
|
|
1288
|
+
for (const [resourceName, agent] of expectedNames) {
|
|
1289
|
+
if (!k8sNameSet.has(resourceName)) {
|
|
1290
|
+
// provision() is idempotent — it handles ConflictError on Secret/Deployment.
|
|
1291
|
+
if (this.config.pvcName && !agent.slug) {
|
|
1292
|
+
// When a pvcName template is active, the PVC name is derived from the slug.
|
|
1293
|
+
// If slug is absent, reconcile falls back to resourceName — which may not
|
|
1294
|
+
// match the slug-based PVC created by the original provision() call, risking
|
|
1295
|
+
// a mount to an empty volume. Callers should populate slug in the agents array.
|
|
1296
|
+
console.warn(
|
|
1297
|
+
`[reconcile] pvcName template is set but agent ${agent.id} has no slug; PVC will be named from resourceName (${resourceName}) — verify this matches the PVC created at provision time`,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
try {
|
|
1301
|
+
// perAgent mode: provision() needs an owner, so an ownerless agent is
|
|
1302
|
+
// reported rather than counted as a failure. A missing agent row
|
|
1303
|
+
// (null identity) still fails in provision() with NotFound.
|
|
1304
|
+
if (this.config.perAgentIdentity) {
|
|
1305
|
+
const identity = await this.identityOf(agent);
|
|
1306
|
+
if (identity && !identity.ownerEmail) {
|
|
1307
|
+
noOwner.push(agent.id);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
if (!dryRun) {
|
|
1312
|
+
await this.provision(agent.id, {
|
|
1313
|
+
slug: agent.slug,
|
|
1314
|
+
accountId: agent.accountId,
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
recreated.push(agent.id);
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
failed.push({
|
|
1320
|
+
agentId: agent.id,
|
|
1321
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
} else {
|
|
1325
|
+
// Deployment already exists — re-apply the desired container spec when
|
|
1326
|
+
// it has drifted. The manifest builder is the single source of truth
|
|
1327
|
+
// for image, env, and resources; the strategic-merge patch upserts env
|
|
1328
|
+
// entries by name, so manually-added extra vars survive. (Patching only
|
|
1329
|
+
// the image here would mean existing Deployments never pick up env or
|
|
1330
|
+
// resource changes shipped in a new admin version.)
|
|
1331
|
+
try {
|
|
1332
|
+
const deployment = await this.k8s.getDeployment(
|
|
1333
|
+
this.config.namespace,
|
|
1334
|
+
resourceName,
|
|
1335
|
+
);
|
|
1336
|
+
const current = deployment.spec.template.spec.containers[0];
|
|
1337
|
+
const reconciled = await this.reconcileFederationFor(agent);
|
|
1338
|
+
if (reconciled.noOwner) noOwner.push(agent.id);
|
|
1339
|
+
// Move an owned agent off the shared ServiceAccount: create its
|
|
1340
|
+
// identity (rolling back what this call created if that fails), then
|
|
1341
|
+
// fall through to the normal drift correction, which now sees the
|
|
1342
|
+
// agent's own federation wiring and patches the ServiceAccount, token
|
|
1343
|
+
// volume and federation env to it. policyOnly passes (admin startup)
|
|
1344
|
+
// never create identities; a dry run only reports the move.
|
|
1345
|
+
let { federation: desiredFederation, manage: manageFederation } =
|
|
1346
|
+
reconciled;
|
|
1347
|
+
const perAgent = this.config.perAgentIdentity;
|
|
1348
|
+
if (reconciled.move && perAgent && !policyOnly) {
|
|
1349
|
+
if (dryRun) {
|
|
1350
|
+
migrated.push(agent.id);
|
|
1351
|
+
updated.push(agent.id);
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
const created: CreatedIdentity = {};
|
|
1355
|
+
try {
|
|
1356
|
+
desiredFederation = perAgentFederation(
|
|
1357
|
+
perAgent,
|
|
1358
|
+
agent.id,
|
|
1359
|
+
await this.provisionIdentity(
|
|
1360
|
+
perAgent,
|
|
1361
|
+
agent.id,
|
|
1362
|
+
resourceName,
|
|
1363
|
+
reconciled.move,
|
|
1364
|
+
created,
|
|
1365
|
+
),
|
|
1366
|
+
);
|
|
1367
|
+
manageFederation = true;
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
await this.rollbackIdentity(perAgent, agent.id, created);
|
|
1370
|
+
throw err;
|
|
1371
|
+
}
|
|
1372
|
+
migrated.push(agent.id);
|
|
1373
|
+
}
|
|
1374
|
+
const desiredDeployment = this.deploymentManifestFor(
|
|
1375
|
+
agent.id,
|
|
1376
|
+
agent.slug,
|
|
1377
|
+
agent.accountId,
|
|
1378
|
+
desiredFederation,
|
|
1379
|
+
);
|
|
1380
|
+
const desired = desiredDeployment.spec.template.spec.containers[0];
|
|
1381
|
+
const desiredLabels = desiredDeployment.spec.template.metadata.labels;
|
|
1382
|
+
const labelsDrifted =
|
|
1383
|
+
!policyOnly &&
|
|
1384
|
+
desiredLabels[TENANT_LABEL] !== undefined &&
|
|
1385
|
+
deployment.spec.template.metadata?.labels?.[TENANT_LABEL] !==
|
|
1386
|
+
desiredLabels[TENANT_LABEL];
|
|
1387
|
+
const policy = policyPatch(deployment, desiredDeployment);
|
|
1388
|
+
// Unmanaged federation (see reconcileFederationFor): no patch, so
|
|
1389
|
+
// the live ServiceAccount, token volume and env are left alone.
|
|
1390
|
+
const federation = manageFederation
|
|
1391
|
+
? federationPatch(deployment, desiredDeployment)
|
|
1392
|
+
: undefined;
|
|
1393
|
+
const containerDrift =
|
|
1394
|
+
!policyOnly &&
|
|
1395
|
+
current !== undefined &&
|
|
1396
|
+
desired !== undefined &&
|
|
1397
|
+
containerDrifted(current, desired);
|
|
1398
|
+
if (
|
|
1399
|
+
desired &&
|
|
1400
|
+
(labelsDrifted || containerDrift || policy || federation)
|
|
1401
|
+
) {
|
|
1402
|
+
// Under policyOnly the container entry carries only the policy and
|
|
1403
|
+
// federation mount/env changes (if any); image, the rest of env,
|
|
1404
|
+
// and resources are left to other actors.
|
|
1405
|
+
const volumeMounts = [
|
|
1406
|
+
...(policy?.volumeMounts ?? []),
|
|
1407
|
+
...(federation?.volumeMounts ?? []),
|
|
1408
|
+
];
|
|
1409
|
+
const desiredEnvNames = new Set(
|
|
1410
|
+
(desired.env ?? []).map((e) => e.name),
|
|
1411
|
+
);
|
|
1412
|
+
const env = policyOnly
|
|
1413
|
+
? (federation?.env ?? [])
|
|
1414
|
+
: [
|
|
1415
|
+
...(desired.env ?? []),
|
|
1416
|
+
// Full env already upserts desired federation entries; only
|
|
1417
|
+
// the delete directives need appending.
|
|
1418
|
+
...(federation?.env ?? []).filter(
|
|
1419
|
+
(e) => !desiredEnvNames.has((e as { name: string }).name),
|
|
1420
|
+
),
|
|
1421
|
+
];
|
|
1422
|
+
const container = {
|
|
1423
|
+
name: desired.name,
|
|
1424
|
+
...(policyOnly
|
|
1425
|
+
? {}
|
|
1426
|
+
: {
|
|
1427
|
+
image: desired.image,
|
|
1428
|
+
resources: desired.resources,
|
|
1429
|
+
}),
|
|
1430
|
+
...(volumeMounts.length > 0 ? { volumeMounts } : {}),
|
|
1431
|
+
...(env.length > 0 || !policyOnly ? { env } : {}),
|
|
1432
|
+
};
|
|
1433
|
+
const includeContainer =
|
|
1434
|
+
!policyOnly || volumeMounts.length > 0 || env.length > 0;
|
|
1435
|
+
const volumes = [
|
|
1436
|
+
...(policy?.volumes ?? []),
|
|
1437
|
+
...(federation?.volumes ?? []),
|
|
1438
|
+
];
|
|
1439
|
+
const serviceAccount =
|
|
1440
|
+
federation?.serviceAccountName !== undefined
|
|
1441
|
+
? {
|
|
1442
|
+
serviceAccountName: federation.serviceAccountName,
|
|
1443
|
+
...(federation.serviceAccount === null
|
|
1444
|
+
? { serviceAccount: null }
|
|
1445
|
+
: {}),
|
|
1446
|
+
}
|
|
1447
|
+
: {};
|
|
1448
|
+
const podMetadata = {
|
|
1449
|
+
...(labelsDrifted
|
|
1450
|
+
? { labels: { [TENANT_LABEL]: desiredLabels[TENANT_LABEL] } }
|
|
1451
|
+
: {}),
|
|
1452
|
+
...(policy?.annotations
|
|
1453
|
+
? { annotations: policy.annotations }
|
|
1454
|
+
: {}),
|
|
1455
|
+
};
|
|
1456
|
+
if (!dryRun)
|
|
1457
|
+
await this.k8s.patchDeployment(
|
|
1458
|
+
this.config.namespace,
|
|
1459
|
+
resourceName,
|
|
1460
|
+
{
|
|
1461
|
+
...(labelsDrifted
|
|
1462
|
+
? {
|
|
1463
|
+
metadata: {
|
|
1464
|
+
labels: {
|
|
1465
|
+
[TENANT_LABEL]: desiredLabels[TENANT_LABEL],
|
|
1466
|
+
},
|
|
1467
|
+
},
|
|
1468
|
+
}
|
|
1469
|
+
: {}),
|
|
1470
|
+
spec: {
|
|
1471
|
+
template: {
|
|
1472
|
+
...(Object.keys(podMetadata).length > 0
|
|
1473
|
+
? { metadata: podMetadata }
|
|
1474
|
+
: {}),
|
|
1475
|
+
spec: {
|
|
1476
|
+
...serviceAccount,
|
|
1477
|
+
...(includeContainer
|
|
1478
|
+
? { containers: [container] }
|
|
1479
|
+
: {}),
|
|
1480
|
+
...(volumes.length > 0 ? { volumes } : {}),
|
|
1481
|
+
},
|
|
1482
|
+
},
|
|
1483
|
+
},
|
|
1484
|
+
},
|
|
1485
|
+
);
|
|
1486
|
+
updated.push(agent.id);
|
|
1487
|
+
}
|
|
1488
|
+
} catch (err) {
|
|
1489
|
+
failed.push({
|
|
1490
|
+
agentId: agent.id,
|
|
1491
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// Collect k8s Deployments that have no known agent.
|
|
1498
|
+
for (const k8sName of k8sNames) {
|
|
1499
|
+
if (!expectedNames.has(k8sName)) {
|
|
1500
|
+
orphans.push(k8sName);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
return { recreated, updated, migrated, noOwner, orphans, failed, dryRun };
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// ─── Spec derivation (via the pure manifest builders) ─────────────────────
|
|
1508
|
+
|
|
1509
|
+
private pvcSpec(pvcName: string): PvcSpec {
|
|
1510
|
+
return {
|
|
1511
|
+
name: pvcName,
|
|
1512
|
+
namespace: this.config.namespace,
|
|
1513
|
+
storageGi: this.config.pvcStorageGi ?? 40,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private secretSpec(
|
|
1518
|
+
secretName: string,
|
|
1519
|
+
rawToken: string,
|
|
1520
|
+
integrationSecrets: { secretKey: string; rawToken: string }[],
|
|
1521
|
+
chatServiceToken?: string,
|
|
1522
|
+
): SecretSpec {
|
|
1523
|
+
// The pure builder is the single source of truth for the Secret body (name,
|
|
1524
|
+
// token key, base64 encoding). Derive the KubernetesClient SecretSpec from
|
|
1525
|
+
// its output so the builder actually drives the secret — decoding `data`
|
|
1526
|
+
// back to plain `stringData` since createSecret re-encodes it itself.
|
|
1527
|
+
const manifest = buildAgentSecretManifest({
|
|
1528
|
+
name: secretName,
|
|
1529
|
+
namespace: this.config.namespace,
|
|
1530
|
+
token: rawToken,
|
|
1531
|
+
tokenKey: this.tokenKey,
|
|
1532
|
+
});
|
|
1533
|
+
const stringData: Record<string, string> = {};
|
|
1534
|
+
for (const [key, value] of Object.entries(manifest.data)) {
|
|
1535
|
+
stringData[key] = Buffer.from(value, "base64").toString("utf-8");
|
|
1536
|
+
}
|
|
1537
|
+
// Tokens minted for this agent's service integrations go in the same
|
|
1538
|
+
// Secret (list order) so a single Secret holds all per-agent credentials.
|
|
1539
|
+
for (const {
|
|
1540
|
+
secretKey,
|
|
1541
|
+
rawToken: integrationToken,
|
|
1542
|
+
} of integrationSecrets) {
|
|
1543
|
+
stringData[secretKey] = integrationToken;
|
|
1544
|
+
}
|
|
1545
|
+
// When a chat-service token was minted for this agent, include it alongside
|
|
1546
|
+
// the other per-agent credentials in the same Secret.
|
|
1547
|
+
if (chatServiceToken !== undefined) {
|
|
1548
|
+
stringData["chat-service-token"] = chatServiceToken;
|
|
1549
|
+
}
|
|
1550
|
+
return { name: manifest.metadata.name, stringData };
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
private async revokeIntegrationTokensBestEffort(
|
|
1554
|
+
tokens: { integration: ServiceIntegration; id: string }[],
|
|
1555
|
+
): Promise<void> {
|
|
1556
|
+
for (const { integration, id } of tokens) {
|
|
1557
|
+
if (!id) continue;
|
|
1558
|
+
try {
|
|
1559
|
+
await integration.revokeToken(id);
|
|
1560
|
+
} catch {
|
|
1561
|
+
// Best-effort — log but don't propagate so the original error surfaces.
|
|
1562
|
+
console.warn(
|
|
1563
|
+
`[provisioner] failed to revoke ${integration.name} token ${id} during rollback`,
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
private async revokeChatServiceTokenBestEffort(
|
|
1570
|
+
csTokenId: string | undefined,
|
|
1571
|
+
): Promise<void> {
|
|
1572
|
+
if (!csTokenId || !this.config.chatService) return;
|
|
1573
|
+
try {
|
|
1574
|
+
await this.config.chatService.revokeToken(csTokenId);
|
|
1575
|
+
} catch {
|
|
1576
|
+
// Best-effort — log but don't propagate so the original error surfaces.
|
|
1577
|
+
console.warn(
|
|
1578
|
+
`[provisioner] failed to revoke chat-service token ${csTokenId} during rollback`,
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
private async deleteSecretBestEffort(name: string): Promise<void> {
|
|
1584
|
+
try {
|
|
1585
|
+
await this.k8s.deleteSecret(this.config.namespace, name);
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
if (!isNotFound(err)) throw err;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
private async deleteDeploymentBestEffort(name: string): Promise<void> {
|
|
1592
|
+
try {
|
|
1593
|
+
await this.k8s.deleteDeployment(this.config.namespace, name);
|
|
1594
|
+
} catch (err) {
|
|
1595
|
+
if (!isNotFound(err)) throw err;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
private async deletePvcBestEffort(name: string): Promise<void> {
|
|
1600
|
+
try {
|
|
1601
|
+
await this.k8s.deletePvc(this.config.namespace, name);
|
|
1602
|
+
} catch (err) {
|
|
1603
|
+
if (!isNotFound(err)) throw err;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// ─── No-op implementation ─────────────────────────────────────────────────────
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* No-op provisioner used when Kubernetes provisioning is disabled. Lets the
|
|
1612
|
+
* admin service construct and run unchanged without a cluster.
|
|
1613
|
+
*/
|
|
1614
|
+
export class NoopAgentProvisioner implements AgentProvisioner {
|
|
1615
|
+
readonly canProvision = false;
|
|
1616
|
+
|
|
1617
|
+
async provision(
|
|
1618
|
+
agentId: string,
|
|
1619
|
+
_opts?: { slug?: string },
|
|
1620
|
+
): Promise<ProvisionResult> {
|
|
1621
|
+
const resourceName = sanitizeAgentName(agentId);
|
|
1622
|
+
return {
|
|
1623
|
+
resourceName,
|
|
1624
|
+
secretName: `${resourceName}-token`,
|
|
1625
|
+
deploymentName: resourceName,
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
async deprovision(
|
|
1630
|
+
_agentId: string,
|
|
1631
|
+
_opts?: { slug?: string },
|
|
1632
|
+
): Promise<void> {
|
|
1633
|
+
// intentionally a no-op
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
async reconcile(
|
|
1637
|
+
_agents: ReconcileTarget[],
|
|
1638
|
+
opts?: { policyOnly?: boolean; dryRun?: boolean },
|
|
1639
|
+
): Promise<ReconcileResult> {
|
|
1640
|
+
return {
|
|
1641
|
+
recreated: [],
|
|
1642
|
+
updated: [],
|
|
1643
|
+
migrated: [],
|
|
1644
|
+
noOwner: [],
|
|
1645
|
+
orphans: [],
|
|
1646
|
+
failed: [],
|
|
1647
|
+
dryRun: opts?.dryRun === true,
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
}
|