@app-vitals/agent-admin 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +64 -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,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
|
+
}
|