diff --git a/.gitignore b/.gitignore index 4cf4127..15f14ec 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ test-mcp.sh # Claude Code local state .claude/scheduled_tasks.lock + +# Agent worktrees — scratch checkouts, never part of the tree +.claude/worktrees/ diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 6d560a4..0a6994f 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -194,7 +194,7 @@ _mcpctl() { else case "$create_sub" in server) - COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --volume --health-check-tool --health-check-args --health-check-interval --health-check-timeout --health-check-failure-threshold --from-template --env-from-secret --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --volume --health-check-tool --health-check-args --health-check-interval --health-check-timeout --health-check-failure-threshold --secret-delivery --entrypoint --from-template --env-from-secret --force -h --help" -- "$cur")) ;; secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index c3f9961..76f4a8f 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -386,6 +386,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-arg complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-interval -d 'Readiness probe interval in seconds (default 60)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-timeout -d 'Readiness probe timeout in seconds (default 10)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-failure-threshold -d 'Consecutive failures before the instance is marked unhealthy (default 3)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l secret-delivery -d 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l entrypoint -d 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l from-template -d 'Create from template (name or name:version)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env-from-secret -d 'Map template env vars from a secret' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update if already exists' diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index d3bbb2a..da66a8e 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -44,6 +44,8 @@ const ServerSpecSchema = z.object({ env: z.array(ServerEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), volumes: z.array(VolumeSpecSchema).default([]), + secretDelivery: z.enum(['env', 'injector']).optional(), + entrypoint: z.array(z.string()).optional(), }); const SecretSpecSchema = z.object({ @@ -136,6 +138,8 @@ const TemplateSpecSchema = z.object({ env: z.array(TemplateEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), volumes: z.array(VolumeSpecSchema).default([]), + secretDelivery: z.enum(['env', 'injector']).optional(), + entrypoint: z.array(z.string()).optional(), }); const UserSpecSchema = z.object({ diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index cc5d720..c64a681 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -252,6 +252,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--health-check-interval ', 'Readiness probe interval in seconds (default 60)') .option('--health-check-timeout ', 'Readiness probe timeout in seconds (default 10)') .option('--health-check-failure-threshold ', 'Consecutive failures before the instance is marked unhealthy (default 3)') + .option('--secret-delivery ', 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)') + .option('--entrypoint ', 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect') .option('--from-template ', 'Create from template (name or name:version)') .option('--env-from-secret ', 'Map template env vars from a secret') .option('--force', 'Update if already exists') @@ -327,6 +329,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.description !== undefined) body.description = opts.description; if (opts.transport) body.transport = opts.transport; if (opts.replicas) body.replicas = parseInt(opts.replicas, 10); + if (opts.secretDelivery) body.secretDelivery = opts.secretDelivery; + if (opts.entrypoint) body.entrypoint = (opts.entrypoint as string).split(',').map((a) => a.trim()).filter(Boolean); if (opts.packageName) body.packageName = opts.packageName; if (opts.runtime) body.runtime = opts.runtime; if (opts.dockerImage) body.dockerImage = opts.dockerImage; diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index a425e42..2449fb5 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -21,6 +21,20 @@ function formatServerDetail(server: Record): string { lines.push(`${pad('Name:')}${server.name}`); lines.push(`${pad('Transport:')}${server.transport ?? '-'}`); lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`); + { + // Surface the remaining exposure rather than leaving it silent: a server + // still on `env` with secret refs has those VALUES written into its pod + // spec, i.e. cleartext in etcd for anyone with `get pod`. + const delivery = (server as { secretDelivery?: string }).secretDelivery ?? 'env'; + const env = (server.env ?? []) as Array<{ valueFrom?: unknown }>; + const hasSecretRefs = env.some((e) => e.valueFrom !== undefined); + const note = delivery === 'injector' + ? ' (pod fetches its own secrets under a scoped OpenBao identity)' + : hasSecretRefs + ? ' ⚠ secret values are inlined into the pod spec' + : ''; + lines.push(`${pad('Secret Delivery:')}${delivery}${note}`); + } if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`); if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`); if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`); diff --git a/src/db/prisma/migrations/20260820234500_add_server_secret_delivery/migration.sql b/src/db/prisma/migrations/20260820234500_add_server_secret_delivery/migration.sql new file mode 100644 index 0000000..900e905 --- /dev/null +++ b/src/db/prisma/migrations/20260820234500_add_server_secret_delivery/migration.sql @@ -0,0 +1,12 @@ +-- Per-server secret delivery mode. +-- +-- 'env' preserves today's behaviour exactly: mcpd resolves secret refs and +-- writes the VALUES into the pod spec. Defaulting to it means every existing +-- server is unchanged by this migration, and migration to 'injector' is an +-- explicit, reversible, per-server decision. +ALTER TABLE "McpServer" ADD COLUMN "secretDelivery" TEXT NOT NULL DEFAULT 'env'; + +-- argv to exec after the injector's rendered secrets are sourced. Only needed +-- for dockerImage servers, where the image ENTRYPOINT is what would otherwise +-- run and mcpd cannot introspect it. +ALTER TABLE "McpServer" ADD COLUMN "entrypoint" JSONB; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 7b71bc8..77d16bc 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -71,9 +71,23 @@ model McpServer { env Json @default("[]") healthCheck Json? volumes Json @default("[]") - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + /// How secret-backed env reaches the container. + /// env — mcpd resolves values and writes them into the pod spec. + /// Cleartext in etcd, readable by anyone with `get pod`. + /// injector — the pod fetches its own secrets from OpenBao via the agent + /// injector, using a ServiceAccount + role scoped to just this + /// server's secrets. The value never touches the pod spec. + /// Defaults to `env` so existing servers are bit-for-bit unchanged. + secretDelivery String @default("env") + + /// argv the injector wrapper must exec after sourcing the rendered secrets. + /// Only needed for dockerImage servers using `injector`, where the image's + /// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it. + entrypoint Json? + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt templateName String? templateVersion String? @@ -109,8 +123,22 @@ model McpTemplate { env Json @default("[]") healthCheck Json? volumes Json @default("[]") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + + /// How secret-backed env reaches the container. + /// env — mcpd resolves values and writes them into the pod spec. + /// Cleartext in etcd, readable by anyone with `get pod`. + /// injector — the pod fetches its own secrets from OpenBao via the agent + /// injector, using a ServiceAccount + role scoped to just this + /// server's secrets. The value never touches the pod spec. + /// Defaults to `env` so existing servers are bit-for-bit unchanged. + secretDelivery String @default("env") + + /// argv the injector wrapper must exec after sourcing the rendered secrets. + /// Only needed for dockerImage servers using `injector`, where the image's + /// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it. + entrypoint Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([name]) } diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 51ed1c4..013361e 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -25,6 +25,9 @@ import { SecretBackendService } from './services/secret-backend.service.js'; import { SecretMigrateService } from './services/secret-migrate.service.js'; import { bootstrapSecretBackends } from './bootstrap/secret-backends.js'; import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js'; +import { ServerIdentityService } from './services/server-identity.service.js'; +import { K8sServiceAccountPort } from './services/k8s/service-account-port.js'; +import { K8sOfficialClient } from './services/k8s/k8s-client-official.js'; import { warmSecretCache } from './bootstrap/warm-secret-cache.js'; import { registerSecretBackendRoutes } from './routes/secret-backends.js'; import { registerSecretMigrateRoutes } from './routes/secret-migrate.js'; @@ -519,8 +522,38 @@ async function main(): Promise { // AgentService + ChatService get fully wired below once projectService and // mcpProxyService are constructed (ChatService needs them via the // ChatToolDispatcher bridge). - const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService); + // Per-server OpenBao identities, for servers with `secretDelivery: injector`. + // Kubernetes-only: it needs a ServiceAccount per server and an orchestrator + // that can honour secret references. Left undefined elsewhere, which makes + // InstanceService fall back to inline resolution — so Docker/Podman local + // development is unaffected. + const serverIdentityService = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes' + ? (() => { + const k8s = new K8sOfficialClient(); + return new ServerIdentityService( + secretBackendService, + new K8sServiceAccountPort(k8s.core, k8s.serversNamespace), + { + info: (obj: Record, msg: string): void => { app.log.info(obj, msg); }, + warn: (obj: Record, msg: string): void => { app.log.warn(obj, msg); }, + }, + { + // Must be the mount that validates THIS cluster's SA tokens; the + // chart default (`auth/kubernetes`) belongs to a different cluster + // and holds none of our roles. + authPath: process.env['MCPD_SECRET_INJECTOR_AUTH_PATH'] ?? 'auth/kubernetes', + mount: process.env['MCPD_SECRET_INJECTOR_MOUNT'] ?? 'secret', + pathPrefix: process.env['MCPD_SECRET_INJECTOR_PREFIX'] ?? 'mcpctl', + }, + ); + })() + : undefined; + + const instanceService = new InstanceService( + instanceRepo, serverRepo, orchestrator, secretService, serverIdentityService, + ); serverService.setInstanceService(instanceService); + if (serverIdentityService !== undefined) serverService.setServerIdentity(serverIdentityService); const projectService = new ProjectService(projectRepo, serverRepo); const auditLogService = new AuditLogService(auditLogRepo); const auditEventService = new AuditEventService(auditEventRepo); diff --git a/src/mcpd/src/services/backup/backup-service.ts b/src/mcpd/src/services/backup/backup-service.ts index c6410b3..5311d1f 100644 --- a/src/mcpd/src/services/backup/backup-service.ts +++ b/src/mcpd/src/services/backup/backup-service.ts @@ -40,6 +40,9 @@ export interface BackupServer { env: unknown; healthCheck: unknown; volumes: unknown; + /** Optional so backups taken before the field remain restorable. */ + secretDelivery?: string; + entrypoint?: unknown; } export interface BackupSecret { @@ -142,6 +145,8 @@ export class BackupService { env: s.env, healthCheck: s.healthCheck, volumes: s.volumes, + secretDelivery: s.secretDelivery, + entrypoint: s.entrypoint, })); } diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts index 2aa9deb..a710bbc 100644 --- a/src/mcpd/src/services/backup/restore-service.ts +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -177,6 +177,11 @@ export class RestoreService { // a claim of the same name if one survived, and otherwise starts on a // fresh empty volume. volumes: (server.volumes ?? []) as Parameters[0]['volumes'], + // Preserve the delivery mode across a backup/restore round-trip. + // Falling back to 'env' keeps pre-field backups restorable, and 'env' + // is the safe default: it works without the per-server OpenBao + // identity, which a restore cannot assume still exists. + secretDelivery: (server.secretDelivery === 'injector' ? 'injector' : 'env'), }; if (server.packageName) createData.packageName = server.packageName; if (server.runtime) createData.runtime = server.runtime; diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 79b758d..e1f4422 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -29,6 +29,14 @@ function mapState(state: string | undefined): ContainerInfo['state'] { } export class DockerContainerManager implements McpOrchestrator { + /** + * No injector equivalent exists for plain Docker/Podman containers, so + * `envFromSecret` cannot be honoured here — mcpd resolves those refs and + * merges them into `env` instead. Local development therefore keeps today's + * behaviour exactly. + */ + readonly capabilities = { secretRefs: false } as const; + private docker: Docker; constructor(opts?: Docker.DockerOptions) { diff --git a/src/mcpd/src/services/env-resolver.ts b/src/mcpd/src/services/env-resolver.ts index c964079..df7c89a 100644 --- a/src/mcpd/src/services/env-resolver.ts +++ b/src/mcpd/src/services/env-resolver.ts @@ -44,3 +44,63 @@ export async function resolveServerEnv( return result; } + +/** + * Split a server's env into inline values and secret references, WITHOUT + * resolving the references. + * + * Used for injector-delivered servers, where the pod fetches its own values and + * mcpd must never materialise them. `resolveServerEnv` above stays as-is: it is + * still the path for Docker, for opted-out servers, and for the generated + * client `.mcp.json`. + */ +export function partitionServerEnv(server: McpServer): { + inline: Record; + refs: Array<{ name: string; secretName: string; key: string }>; +} { + const entries = (server.env ?? []) as ServerEnvEntry[]; + const inline: Record = {}; + const refs: Array<{ name: string; secretName: string; key: string }> = []; + + for (const entry of entries) { + if (entry.value !== undefined) { + inline[entry.name] = entry.value; + } else if (entry.valueFrom?.secretRef) { + refs.push({ + name: entry.name, + secretName: entry.valueFrom.secretRef.name, + key: entry.valueFrom.secretRef.key, + }); + } + } + return { inline, refs }; +} + +/** + * Resolve every reference and DISCARD the values, purely to make a missing + * secret or key fail loudly at instance start. + * + * Once a server is injector-delivered, mcpd no longer reads its secrets, so the + * check that commit e6cd735 bought stops firing for it: a bad secretRef would + * degrade from a clear `secret resolution failed: …` into a vault-agent-init + * crashloop that mcpd reports as a generic pod failure. That is the exact class + * of bug that had gitea-mcp running for weeks with an empty token while + * reporting healthy — so we keep paying for the check. After the value cache it + * is a cache hit and costs essentially nothing. + */ +export async function validateServerEnvRefs( + server: McpServer, + resolver: SecretResolver, +): Promise { + const { refs } = partitionServerEnv(server); + for (const ref of refs) { + try { + await resolver.resolve(ref.secretName, ref.key); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Cannot resolve secret for server '${server.name}' env '${ref.name}': ${msg}`, + ); + } + } +} diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index 6464fbf..14f88ad 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -2,7 +2,28 @@ import type { McpInstance, McpServer } from '@prisma/client'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js'; import { NotFoundError } from './mcp-server.service.js'; -import { resolveServerEnv, type SecretResolver } from './env-resolver.js'; +import { + resolveServerEnv, + partitionServerEnv, + validateServerEnvRefs, + type SecretResolver, +} from './env-resolver.js'; + +/** + * What InstanceService needs from the per-server identity layer. Kept narrow + * and local so the orchestration stays testable without an OpenBao backend. + */ +export interface ServerIdentityProvisioner { + /** Converge the server's identity; resolves to the ServiceAccount/role name. */ + ensureFor(server: McpServer): Promise; + /** Injector pod annotations for these refs under that identity. */ + annotationsFor( + identity: string, + refs: Array<{ name: string; secretName: string; key: string }>, + ): Record; + /** Rewrite argv to source the rendered secrets before exec'ing the server. */ + wrapCommand(server: McpServer, command: string[] | undefined): string[] | undefined; +} /** Runner images for package-based MCP servers, keyed by runtime name. */ const RUNNER_IMAGES: Record = { @@ -62,6 +83,8 @@ export class InstanceService { private serverRepo: IMcpServerRepository, private orchestrator: McpOrchestrator, private secretResolver?: SecretResolver, + /** Provisions per-server OpenBao identities. Absent = injector unavailable. */ + private serverIdentity?: ServerIdentityProvisioner, ) {} /** @@ -501,11 +524,31 @@ export class InstanceService { // will retry it in-place on the next tick whose nextRetryAt has // elapsed. Optional/missing env vars should be modeled as `value: ""` // entries on the server, not as silent secret-resolution failures. + // Injected delivery needs BOTH an orchestrator that can honour references + // (Docker cannot) and a provisioner for the per-server identity. Falling + // back to inline resolution when either is missing keeps local dev and + // plaintext-backend setups working unchanged. + const useInjector = server.secretDelivery === 'injector' + && this.orchestrator.capabilities?.secretRefs === true + && this.serverIdentity !== undefined; + if (this.secretResolver) { try { - const resolvedEnv = await resolveServerEnv(server, this.secretResolver); - if (Object.keys(resolvedEnv).length > 0) { - spec.env = resolvedEnv; + if (useInjector) { + // Resolve and DISCARD, purely so a missing secret still fails here + // with a clear message instead of becoming a vault-agent-init + // crashloop that mcpd reports as a generic pod failure. See + // validateServerEnvRefs. + await validateServerEnvRefs(server, this.secretResolver); + + const { inline, refs } = partitionServerEnv(server); + if (Object.keys(inline).length > 0) spec.env = inline; + spec.envFromSecret = refs; + } else { + const resolvedEnv = await resolveServerEnv(server, this.secretResolver); + if (Object.keys(resolvedEnv).length > 0) { + spec.env = resolvedEnv; + } } } catch (envErr) { const msg = envErr instanceof Error ? envErr.message : String(envErr); @@ -513,6 +556,22 @@ export class InstanceService { } } + if (useInjector && spec.envFromSecret !== undefined && spec.envFromSecret.length > 0) { + try { + // Converge the identity BEFORE the pod exists: a pod whose role is + // not yet written logs in, gets no capabilities, and crashloops. + const identity = await this.serverIdentity!.ensureFor(server); + spec.serviceAccountName = identity; + spec.automountServiceAccountToken = true; + spec.annotations = this.serverIdentity!.annotationsFor(identity, spec.envFromSecret); + const wrapped = this.serverIdentity!.wrapCommand(server, spec.command); + if (wrapped !== undefined) spec.command = wrapped; + } catch (idErr) { + const msg = idErr instanceof Error ? idErr.message : String(idErr); + return this.markInstanceError(instance, `secret identity provisioning failed: ${msg}`); + } + } + // Pull image if not available locally try { await this.orchestrator.pullImage(image); diff --git a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts index 27ace79..2057052 100644 --- a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts +++ b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts @@ -97,6 +97,9 @@ function podToContainerInfo(pod: V1Pod): ContainerInfo { } export class KubernetesOrchestrator implements McpOrchestrator { + /** Pods can fetch their own secrets via the OpenBao agent injector. */ + readonly capabilities = { secretRefs: true } as const; + private client: K8sOfficialClient; private namespace: string; diff --git a/src/mcpd/src/services/k8s/manifest-generator.ts b/src/mcpd/src/services/k8s/manifest-generator.ts index b348142..c46ee3f 100644 --- a/src/mcpd/src/services/k8s/manifest-generator.ts +++ b/src/mcpd/src/services/k8s/manifest-generator.ts @@ -10,8 +10,10 @@ export interface K8sPodManifest { name: string; namespace: string; labels: Record; + annotations?: Record; }; spec: { + serviceAccountName?: string; containers: Array<{ name: string; image: string; @@ -41,6 +43,108 @@ export interface K8sPodManifest { }; } +/** Directory the OpenBao agent renders this pod's secrets into. */ +export const INJECTED_SECRET_DIR = '/vault/secrets'; + +/** + * Quote a shell value for a file that will be `.`-sourced: single quotes, with + * any embedded `'` closed-escaped-reopened. + * + * Not cosmetic. The rendered file is sourced by /bin/sh, so a value containing + * a space, `$`, a quote or a newline would otherwise truncate or mangle the + * variable — *silently*, producing an empty token and a server that starts, + * answers tools/list and reports healthy while every authenticated call fails. + * That is precisely the failure this change exists to prevent, so the escaping + * is part of the contract, not a detail. + */ +export function shellSingleQuote(inner: string): string { + return "'" + inner.split("'").join("'\\''") + "'"; +} + +export interface InjectorEnvRef { + /** Env var name inside the container. */ + name: string; + /** mcpctl Secret name. */ + secretName: string; + /** Key within that secret. */ + key: string; +} + +export interface InjectorConfig { + /** OpenBao role to log in as — one per server, scoped to its own secrets. */ + role: string; + /** Auth mount that validates THIS cluster's ServiceAccount tokens. */ + authPath: string; + /** KV v2 mount, e.g. `secret`. */ + mount: string; + /** Path prefix under the mount, e.g. `mcpctl`. */ + pathPrefix: string; +} + +/** + * Pod annotations that drive the OpenBao Agent Injector. + * + * One rendered file per referenced Secret (the agent keys files by the + * annotation suffix), each holding `export NAME='value'` lines for every env + * var drawn from that Secret. + * + * `agent-pre-populate-only` is deliberate: the agent runs as an init container + * and exits. MCP servers cannot reload env anyway, so a long-lived sidecar + * would buy nothing and cost memory in every pod — and these pods run at a flat + * 512Mi where Node-based servers have already been OOMKilled. + */ +export function buildInjectorAnnotations( + refs: InjectorEnvRef[], + cfg: InjectorConfig, +): Record { + const bySecret = new Map(); + for (const ref of refs) { + const list = bySecret.get(ref.secretName) ?? []; + list.push(ref); + bySecret.set(ref.secretName, list); + } + + const annotations: Record = { + 'vault.hashicorp.com/agent-inject': 'true', + 'vault.hashicorp.com/agent-pre-populate-only': 'true', + 'vault.hashicorp.com/role': cfg.role, + // Without this the agent logs in at the default `auth/kubernetes`, which + // validates a DIFFERENT cluster's ServiceAccounts and holds none of our + // roles — every login would fail with a confusing permission error. + 'vault.hashicorp.com/auth-path': cfg.authPath, + }; + + const prefix = cfg.pathPrefix === '' ? '' : `${cfg.pathPrefix}/`; + for (const [secretName, entries] of [...bySecret.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const path = `${cfg.mount}/data/${prefix}${secretName}`; + annotations[`vault.hashicorp.com/agent-inject-secret-${secretName}`] = path; + const lines = entries + .slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map((e) => `export ${e.name}=${shellSingleQuote(`{{ .Data.data.${e.key} }}`)}`); + annotations[`vault.hashicorp.com/agent-inject-template-${secretName}`] = + `{{- with secret "${path}" -}}\n${lines.join('\n')}\n{{- end -}}`; + } + return annotations; +} + +/** + * Rewrite the container command to source the rendered secrets, then exec the + * real process. + * + * `sh -c