diff --git a/docs/commands.md b/docs/commands.md index f2ea60a0e..657d39104 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -116,6 +116,11 @@ agentcore deploy -y --json # JSON output | `--diff` | Show CDK diff without deploying | | `--json` | JSON output | +By default, deploying a project with credential resources creates or updates matching AgentCore Identity credential +providers using values from `agentcore/.env.local` or matching process environment variables. If no local credential +value is present, deploy looks for an existing AgentCore Identity credential provider with the same name and links its +ARN into `agentcore/.cli/deployed-state.json` for CDK/IAM wiring. + ### status Check deployment status and resource details. diff --git a/docs/configuration.md b/docs/configuration.md index 6e42c101a..86c195988 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -288,7 +288,10 @@ Strategy configuration: | `usage` | No | `"inbound"` or `"outbound"` | The actual secrets (API keys, client IDs, client secrets) are stored in `.env.local` for local development and in -AgentCore Identity service for deployed environments. +AgentCore Identity service for deployed environments. During deploy, the CLI creates or updates matching AgentCore +Identity credential providers from `agentcore/.env.local`. If no local secret is present, deploy attempts to link an +existing AgentCore Identity credential provider with the same credential name and records its ARN in +`.cli/deployed-state.json`. --- diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index b3d7ad55b..5ba8bd044 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -145,7 +145,9 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; - if (hasIdentityApiProviders(context.projectSpec)) { + const hasApiProviders = hasIdentityApiProviders(context.projectSpec); + const hasOAuthProviders = hasIdentityOAuthProviders(context.projectSpec); + if (hasApiProviders) { startStep('Creating credentials...'); const identityResult = await setupApiKeyProviders({ @@ -177,7 +179,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ @@ -21,7 +23,9 @@ const { mockSetTokenVaultKmsKey: vi.fn(), mockReadEnvFile: vi.fn(), mockGetCredentialProvider: vi.fn(), + mockGetApiKeyProvider: vi.fn(), mockOAuth2ProviderExists: vi.fn(), + mockGetOAuth2Provider: vi.fn(), mockCreateOAuth2Provider: vi.fn(), mockUpdateOAuth2Provider: vi.fn(), })); @@ -47,9 +51,11 @@ vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ vi.mock('../../identity/index.js', () => ({ apiKeyProviderExists: vi.fn(), createApiKeyProvider: vi.fn(), + getApiKeyProvider: mockGetApiKeyProvider, setTokenVaultKmsKey: mockSetTokenVaultKmsKey, updateApiKeyProvider: vi.fn(), oAuth2ProviderExists: mockOAuth2ProviderExists, + getOAuth2Provider: mockGetOAuth2Provider, createOAuth2Provider: mockCreateOAuth2Provider, updateOAuth2Provider: mockUpdateOAuth2Provider, })); @@ -199,6 +205,47 @@ describe('setupApiKeyProviders - KMS key reuse via GetTokenVault', () => { }); }); +describe('setupApiKeyProviders - existing provider linking', () => { + afterEach(() => vi.clearAllMocks()); + + beforeEach(() => { + mockReadEnvFile.mockResolvedValue({}); + mockGetCredentialProvider.mockReturnValue({}); + mockGetApiKeyProvider.mockResolvedValue({ + success: true, + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/openai', + }); + }); + + it('links an existing API key provider by name when no local secret exists', async () => { + const projectSpec = { + name: 'test-project', + credentials: [{ name: 'openai', authorizerType: 'ApiKeyCredentialProvider' }], + runtimes: [], + }; + + const result = await setupApiKeyProviders({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + enableKmsEncryption: true, + }); + + expect(result.hasErrors).toBe(false); + expect(result.kmsKeyArn).toBeUndefined(); + expect(result.results).toEqual([ + { + providerName: 'openai', + status: 'linked', + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/openai', + }, + ]); + expect(mockGetApiKeyProvider).toHaveBeenCalledWith(expect.anything(), 'openai'); + expect(mockControlSend).not.toHaveBeenCalled(); + expect(mockKmsSend).not.toHaveBeenCalled(); + }); +}); + describe('hasIdentityOAuthProviders', () => { it('returns true when OAuthCredentialProvider exists', () => { const projectSpec = { @@ -273,6 +320,47 @@ describe('setupOAuth2Providers', () => { vi.clearAllMocks(); }); + it('links an existing OAuth2 provider by name when local client credentials are missing', async () => { + mockReadEnvFile.mockResolvedValue({}); + mockGetOAuth2Provider.mockResolvedValue({ + success: true, + result: { + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/test-oauth', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:test-oauth', + callbackUrl: 'https://callback.example.com', + }, + }); + + const projectSpec = { + credentials: [ + { + name: 'test-oauth', + authorizerType: 'OAuthCredentialProvider', + }, + ], + }; + + const result = await setupOAuth2Providers({ + projectSpec: projectSpec as any, + configBaseDir: '/tmp', + region: 'us-east-1', + }); + + expect(result.hasErrors).toBe(false); + expect(result.results).toEqual([ + { + providerName: 'test-oauth', + status: 'linked', + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/test-oauth', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123:secret:test-oauth', + callbackUrl: 'https://callback.example.com', + }, + ]); + expect(mockGetOAuth2Provider).toHaveBeenCalledWith(expect.anything(), 'test-oauth'); + expect(mockCreateOAuth2Provider).not.toHaveBeenCalled(); + expect(mockUpdateOAuth2Provider).not.toHaveBeenCalled(); + }); + it('creates OAuth2 provider when it does not exist', async () => { mockReadEnvFile.mockResolvedValue({ AGENTCORE_CREDENTIAL_TEST_OAUTH_CLIENT_ID: 'client123', @@ -343,8 +431,9 @@ describe('setupOAuth2Providers', () => { expect(mockUpdateOAuth2Provider).toHaveBeenCalled(); }); - it('skips when env vars are missing', async () => { + it('returns error when env vars are missing and provider cannot be linked', async () => { mockReadEnvFile.mockResolvedValue({}); + mockGetOAuth2Provider.mockResolvedValue({ success: false, error: 'ResourceNotFoundException' }); const projectSpec = { credentials: [{ name: 'test-oauth', authorizerType: 'OAuthCredentialProvider' }], @@ -356,10 +445,11 @@ describe('setupOAuth2Providers', () => { region: 'us-east-1', }); - expect(result.hasErrors).toBe(false); + expect(result.hasErrors).toBe(true); expect(result.results).toHaveLength(1); - expect(result.results[0]!.status).toBe('skipped'); + expect(result.results[0]!.status).toBe('error'); expect(result.results[0]!.error).toContain('Missing'); + expect(result.results[0]!.error).toContain('no existing AgentCore Identity OAuth2 credential provider'); }); it('returns error on failure', async () => { diff --git a/src/cli/operations/deploy/pre-deploy-identity.ts b/src/cli/operations/deploy/pre-deploy-identity.ts index 8484f16aa..da5a2ee3b 100644 --- a/src/cli/operations/deploy/pre-deploy-identity.ts +++ b/src/cli/operations/deploy/pre-deploy-identity.ts @@ -8,6 +8,8 @@ import { apiKeyProviderExists, createApiKeyProvider, createOAuth2Provider, + getApiKeyProvider, + getOAuth2Provider, oAuth2ProviderExists, setTokenVaultKmsKey, updateApiKeyProvider, @@ -22,7 +24,7 @@ import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms'; export interface ApiKeyProviderSetupResult { providerName: string; - status: 'created' | 'updated' | 'exists' | 'skipped' | 'error'; + status: 'created' | 'updated' | 'exists' | 'linked' | 'skipped' | 'error'; credentialProviderArn?: string; error?: string; } @@ -64,9 +66,19 @@ export async function setupApiKeyProviders(options: SetupApiKeyProvidersOptions) const client = new BedrockAgentCoreControlClient({ region, credentials }); - // Configure KMS encryption for token vault if enabled + const apiKeyCredentials = projectSpec.credentials.filter( + credential => credential.authorizerType === 'ApiKeyCredentialProvider' + ); + + // Configure KMS encryption only when this deploy will write API key secrets. let kmsKeyArn: string | undefined; - if (enableKmsEncryption) { + const hasApiKeyCredentialValue = + apiKeyCredentials.length === 0 || + apiKeyCredentials.some(credential => { + const envVarName = computeDefaultCredentialEnvVarName(credential.name); + return Boolean(allCredentials.get(envVarName)); + }); + if (enableKmsEncryption && hasApiKeyCredentialValue) { const kmsResult = await setupTokenVaultKms(region, credentials, projectSpec); if (!kmsResult.success) { return { @@ -84,11 +96,9 @@ export async function setupApiKeyProviders(options: SetupApiKeyProvidersOptions) } // Set up each credential in the project - for (const credential of projectSpec.credentials) { - if (credential.authorizerType === 'ApiKeyCredentialProvider') { - const result = await setupApiKeyCredentialProvider(client, credential, allCredentials); - results.push(result); - } + for (const credential of apiKeyCredentials) { + const result = await setupApiKeyCredentialProvider(client, credential, allCredentials); + results.push(result); } return { @@ -152,10 +162,21 @@ async function setupApiKeyCredentialProvider( const apiKey = credentials.get(envVarName); if (!apiKey) { + const existingProvider = await getApiKeyProvider(client, credential.name); + if (existingProvider.success && existingProvider.credentialProviderArn) { + return { + providerName: credential.name, + status: 'linked', + credentialProviderArn: existingProvider.credentialProviderArn, + }; + } + return { providerName: credential.name, - status: 'skipped', - error: `No ${envVarName} found in agentcore/.env.local`, + status: 'error', + error: + `No ${envVarName} found in agentcore/.env.local and no existing AgentCore Identity ` + + `API key credential provider named "${credential.name}" was found.`, }; } @@ -263,7 +284,7 @@ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCre export interface OAuth2ProviderSetupResult { providerName: string; - status: 'created' | 'updated' | 'skipped' | 'error'; + status: 'created' | 'updated' | 'linked' | 'skipped' | 'error'; error?: string; credentialProviderArn?: string; clientSecretArn?: string; @@ -334,10 +355,23 @@ async function setupSingleOAuth2Provider( const clientSecret = credentials.get(clientSecretEnvVar); if (!clientId || !clientSecret) { + const existingProvider = await getOAuth2Provider(client, credential.name); + if (existingProvider.success && existingProvider.result) { + return { + providerName: credential.name, + status: 'linked', + credentialProviderArn: existingProvider.result.credentialProviderArn, + clientSecretArn: existingProvider.result.clientSecretArn, + callbackUrl: existingProvider.result.callbackUrl, + }; + } + return { providerName: credential.name, - status: 'skipped', - error: `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`, + status: 'error', + error: + `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local and no existing ` + + `AgentCore Identity OAuth2 credential provider named "${credential.name}" was found.`, }; } diff --git a/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts index e7cbafb62..21d69078e 100644 --- a/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts +++ b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts @@ -1,6 +1,7 @@ import { apiKeyProviderExists, createApiKeyProvider, + getApiKeyProvider, setTokenVaultKmsKey, updateApiKeyProvider, } from '../api-key-credential-provider.js'; @@ -62,6 +63,30 @@ describe('apiKeyProviderExists', () => { }); }); +describe('getApiKeyProvider', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns the credential provider ARN when provider exists', async () => { + mockSend.mockResolvedValue({ + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/prov', + }); + + expect(await getApiKeyProvider(makeMockClient(), 'prov')).toEqual({ + success: true, + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123:credential-provider/prov', + }); + }); + + it('returns failure when provider response has no ARN', async () => { + mockSend.mockResolvedValue({}); + + expect(await getApiKeyProvider(makeMockClient(), 'prov')).toEqual({ + success: false, + error: 'No credential provider ARN in response', + }); + }); +}); + describe('createApiKeyProvider', () => { afterEach(() => vi.clearAllMocks()); diff --git a/src/cli/operations/identity/api-key-credential-provider.ts b/src/cli/operations/identity/api-key-credential-provider.ts index 36ef82619..59f8351dd 100644 --- a/src/cli/operations/identity/api-key-credential-provider.ts +++ b/src/cli/operations/identity/api-key-credential-provider.ts @@ -32,6 +32,27 @@ export async function apiKeyProviderExists( } } +/** + * Get an existing API key credential provider. + */ +export async function getApiKeyProvider( + client: BedrockAgentCoreControlClient, + providerName: string +): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> { + try { + const response = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + if (!response.credentialProviderArn) { + return { success: false, error: 'No credential provider ARN in response' }; + } + return { success: true, credentialProviderArn: response.credentialProviderArn }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + /** * Create an API key credential provider. * Returns success even if provider already exists (idempotent). diff --git a/src/cli/operations/identity/index.ts b/src/cli/operations/identity/index.ts index 92a5b40a1..25dad28ec 100644 --- a/src/cli/operations/identity/index.ts +++ b/src/cli/operations/identity/index.ts @@ -1,6 +1,7 @@ export { apiKeyProviderExists, createApiKeyProvider, + getApiKeyProvider, setTokenVaultKmsKey, updateApiKeyProvider, } from './api-key-credential-provider'; diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 062da752f..66fec2731 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -686,6 +686,8 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { logger.log(`Updated API key provider: ${result.providerName}`); } else if (result.status === 'exists') { logger.log(`API key provider exists: ${result.providerName}`); + } else if (result.status === 'linked') { + logger.log(`Linked API key provider: ${result.providerName}`); } else if (result.status === 'skipped') { logger.log(`Skipped ${result.providerName}: ${result.error}`); } else if (result.status === 'error') { @@ -730,6 +732,8 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { logger.log(`Created OAuth provider: ${result.providerName}`); } else if (result.status === 'updated') { logger.log(`Updated OAuth provider: ${result.providerName}`); + } else if (result.status === 'linked') { + logger.log(`Linked OAuth provider: ${result.providerName}`); } else if (result.status === 'skipped') { logger.log(`Skipped ${result.providerName}: ${result.error}`); } else if (result.status === 'error') {