diff --git a/apps/dbagent/package.json b/apps/dbagent/package.json index a9091a4a..e444386e 100644 --- a/apps/dbagent/package.json +++ b/apps/dbagent/package.json @@ -29,6 +29,7 @@ "@anthropic-ai/sdk": "^0.51.0", "@aws-sdk/client-cloudwatch": "^3.812.0", "@aws-sdk/client-rds": "^3.812.0", + "@aws-sdk/client-sts": "^3.812.0", "@fluentui/react-icons": "^2.0.300", "@google-cloud/logging": "^11.2.0", "@google-cloud/monitoring": "^5.1.0", diff --git a/apps/dbagent/public/xata-agent-iam-role.yaml b/apps/dbagent/public/xata-agent-iam-role.yaml new file mode 100644 index 00000000..11623414 --- /dev/null +++ b/apps/dbagent/public/xata-agent-iam-role.yaml @@ -0,0 +1,72 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: >- + Creates an IAM Role for the Xata Agent to access AWS RDS cluster information, + instance details, logs, and CloudWatch metrics. You need to provide the + AWS Account ID where your Xata Agent or the users/roles that will assume this + role reside. + +Parameters: + TrustedAwsAccountId: + Type: AWS::AccountId + Description: The AWS Account ID that will be trusted to assume this role (e.g., the account where your application or Xata Agent runs). + RoleName: + Type: String + Default: XataAgentRDSAccessRole + Description: Name for the IAM role. Keep the default unless you have specific naming conventions. + +Resources: + XataAgentRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Ref RoleName + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + AWS: !Sub "arn:aws:iam::${TrustedAwsAccountId}:root" # Trusts the root of the specified AWS Account ID + Action: + - sts:AssumeRole + Path: "/" # Default path + Policies: + - PolicyName: XataAgentRDSAccessPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - rds:DescribeDBClusters + - rds:DescribeDBInstances + - rds:DescribeDBLogFiles + - rds:DownloadDBLogFilePortion + - cloudwatch:GetMetricStatistics + # The following permission is for future use, e.g., detecting if the agent is on an EC2 instance. + # It can be kept commented if not immediately required by your setup. + # - ec2:DescribeInstances + Resource: "*" # For simplicity, this policy allows access to all RDS and CloudWatch resources. + # For a more secure setup, you can restrict this to specific resources if known. + +Outputs: + XataAgentRoleArn: + Description: ARN of the created IAM role for Xata Agent. Use this ARN in the Xata Agent UI. + Value: !GetAtt XataAgentRole.Arn + Export: + Name: XataAgentRoleArn # Makes it easier to reference this role ARN in other CloudFormation stacks. + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: "Role Configuration" + Parameters: + - RoleName + - Label: + default: "Trusted Principal Configuration" + Parameters: + - TrustedAwsAccountId + ParameterLabels: + TrustedAwsAccountId: + default: "Trusted AWS Account ID" + RoleName: + default: "IAM Role Name" +``` diff --git a/apps/dbagent/src/components/aws-integration/actions.ts b/apps/dbagent/src/components/aws-integration/actions.ts index b5c7e828..f3194047 100644 --- a/apps/dbagent/src/components/aws-integration/actions.ts +++ b/apps/dbagent/src/components/aws-integration/actions.ts @@ -4,6 +4,7 @@ import { getRDSClusterInfo, getRDSInstanceInfo, initializeRDSClient, + isEc2InstanceWithRole, listRDSClusters, listRDSInstances, RDSClusterDetailedInfo, @@ -16,16 +17,13 @@ import { Connection } from '~/lib/db/schema'; export async function fetchRDSClusters( projectId: string, - accessKeyId: string, - secretAccessKey: string, - region: string + integration: AwsIntegration ): Promise<{ success: boolean; message: string; data: RDSClusterInfo[] }> { - const client = initializeRDSClient({ accessKeyId, secretAccessKey, region }); - try { + const client = await initializeRDSClient(integration); const clusters = await listRDSClusters(client); const instances = await listRDSInstances(client); - // Add standalone instances as "clusters" with single instance + const standaloneInstances = instances.filter((instance) => !instance.dbClusterIdentifier); const standaloneAsClusters: RDSClusterInfo[] = standaloneInstances.map((instance) => ({ identifier: instance.identifier, @@ -42,45 +40,45 @@ export async function fetchRDSClusters( clusters.push(...standaloneAsClusters); const dbAccess = await getUserSessionDBAccess(); - await saveIntegration(dbAccess, projectId, 'aws', { accessKeyId, secretAccessKey, region }); - return { success: true, message: 'RDS instances fetched successfully', data: clusters }; + + await saveIntegration(dbAccess, projectId, 'aws', integration); + return { success: true, message: 'RDS clusters/instances fetched successfully', data: clusters }; } catch (error) { - console.error('Error fetching RDS instances:', error); - return { success: false, message: 'Error fetching RDS instances', data: [] }; + console.error('Error fetching RDS clusters/instances:', error); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + return { success: false, message: `Error fetching RDS clusters/instances: ${errorMessage}`, data: [] }; } } export async function fetchRDSClusterDetails( projectId: string, - clusterInfo: RDSClusterInfo + clusterInfo: RDSClusterInfo, + integration: AwsIntegration ): Promise<{ success: boolean; message: string; data: RDSClusterDetailedInfo | null }> { - const dbAccess = await getUserSessionDBAccess(); - const aws = await getIntegration(dbAccess, projectId, 'aws'); - if (!aws) { - return { success: false, message: 'AWS integration not found', data: null }; - } - const client = initializeRDSClient({ - accessKeyId: aws.accessKeyId, - secretAccessKey: aws.secretAccessKey, - region: aws.region - }); + try { + const client = await initializeRDSClient(integration); - if (clusterInfo.isStandaloneInstance) { - const instance = await getRDSInstanceInfo(clusterInfo.identifier, client); - if (!instance) { - return { success: false, message: 'RDS instance not found', data: null }; - } - const cluster = { - ...clusterInfo, - instances: [instance] - }; - return { success: true, message: 'RDS instance details fetched successfully', data: cluster }; - } else { - const cluster = await getRDSClusterInfo(clusterInfo.identifier, client); - if (!cluster) { - return { success: false, message: 'RDS cluster not found', data: null }; + if (clusterInfo.isStandaloneInstance) { + const instance = await getRDSInstanceInfo(clusterInfo.identifier, client); + if (!instance) { + return { success: false, message: 'RDS instance not found', data: null }; + } + const cluster = { + ...clusterInfo, + instances: [instance] + }; + return { success: true, message: 'RDS instance details fetched successfully', data: cluster }; + } else { + const cluster = await getRDSClusterInfo(clusterInfo.identifier, client); + if (!cluster) { + return { success: false, message: 'RDS cluster not found', data: null }; + } + return { success: true, message: 'RDS cluster details fetched successfully', data: cluster }; } - return { success: true, message: 'RDS cluster details fetched successfully', data: cluster }; + } catch (error) { + console.error('Error fetching RDS cluster/instance details:', error); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + return { success: false, message: `Error fetching details: ${errorMessage}`, data: null }; } } @@ -93,70 +91,109 @@ export async function getAWSIntegration( if (!aws) { return { success: false, message: 'AWS integration not found', data: null }; } - return { success: true, message: 'AWS integration found', data: aws }; + // Ensure the returned data conforms to AwsIntegration, especially if old data might exist + // For example, if 'authMethod' was missing in older records, default it to 'credentials' + const validatedAws: AwsIntegration = { + authMethod: aws.authMethod || 'credentials', + region: aws.region, + ...(aws.authMethod === 'credentials' && { + accessKeyId: aws.accessKeyId, + secretAccessKey: aws.secretAccessKey + }), + ...(aws.authMethod === 'cloudformation' && { + cloudformationStackArn: aws.cloudformationStackArn + }) + } as AwsIntegration; + + return { success: true, message: 'AWS integration found', data: validatedAws }; } catch (error) { console.error('Error fetching AWS integration:', error); - return { success: false, message: 'Error fetching AWS integration', data: null }; + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + return { success: false, message: `Error fetching AWS integration: ${errorMessage}`, data: null }; + } +} + +export async function checkEc2InstanceRoleStatus(): Promise<{ + success: boolean; + message: string; + data: { hasIAMRole: boolean } | null; +}> { + try { + const hasIAMRole = await isEc2InstanceWithRole(); + return { + success: true, + message: 'Successfully checked EC2 instance IAM role status.', + data: { hasIAMRole } + }; + } catch (error) { + console.error('Error checking EC2 instance IAM role status:', error); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + return { success: false, message: `Error checking EC2 status: ${errorMessage}`, data: null }; } } export async function saveClusterDetails( clusterIdentifier: string, - region: string, connection: Connection ): Promise<{ success: boolean; message: string }> { const dbAccess = await getUserSessionDBAccess(); - const aws = await getIntegration(dbAccess, connection.projectId, 'aws'); - if (!aws) { - return { success: false, message: 'AWS integration not found' }; - } - const client = initializeRDSClient({ - accessKeyId: aws.accessKeyId, - secretAccessKey: aws.secretAccessKey, - region: region - }); - const cluster = await getRDSClusterInfo(clusterIdentifier, client); - if (cluster) { - const instanceId = await saveCluster(dbAccess, { - projectId: connection.projectId, - clusterIdentifier, - region, - data: cluster - }); - await associateClusterConnection(dbAccess, { - projectId: connection.projectId, - clusterId: instanceId, - connectionId: connection.id - }); - return { success: true, message: 'Cluster details saved successfully' }; - } else { - const instance = await getRDSInstanceInfo(clusterIdentifier, client); - if (!instance) { - return { success: false, message: 'RDS instance not found' }; + try { + const integration = await getAWSIntegration(connection.projectId); + if (!integration.success || !integration.data) { + return { success: false, message: 'AWS integration not found' }; } - const instanceId = await saveCluster(dbAccess, { - projectId: connection.projectId, - clusterIdentifier, - region, - data: { - instances: [instance], - identifier: instance.identifier, - engine: instance.engine, - engineVersion: instance.engineVersion, - status: instance.status, - endpoint: instance.endpoint?.address, - port: instance.endpoint?.port, - multiAZ: instance.multiAZ, - instanceCount: 1, - allocatedStorage: instance.allocatedStorage, - isStandaloneInstance: true + + const client = await initializeRDSClient(integration.data); + + const cluster = await getRDSClusterInfo(clusterIdentifier, client); + if (cluster) { + const clusterId = await saveCluster(dbAccess, { + projectId: connection.projectId, + clusterIdentifier, + region: integration.data.region, + data: cluster + }); + await associateClusterConnection(dbAccess, { + projectId: connection.projectId, + clusterId, + connectionId: connection.id + }); + return { success: true, message: 'Cluster details saved successfully' }; + } else { + const instanceInfo = await getRDSInstanceInfo(clusterIdentifier, client); + if (instanceInfo) { + const standaloneAsClusterData: RDSClusterDetailedInfo = { + identifier: instanceInfo.identifier, + engine: instanceInfo.engine, + engineVersion: instanceInfo.engineVersion, + status: instanceInfo.status, + endpoint: instanceInfo.endpoint?.address, + port: instanceInfo.endpoint?.port, + multiAZ: instanceInfo.multiAZ, + instanceCount: 1, + allocatedStorage: instanceInfo.allocatedStorage, + isStandaloneInstance: true, + instances: [instanceInfo] + }; + const instanceDbId = await saveCluster(dbAccess, { + projectId: connection.projectId, + clusterIdentifier, + region: integration.data.region, + data: standaloneAsClusterData + }); + await associateClusterConnection(dbAccess, { + projectId: connection.projectId, + clusterId: instanceDbId, + connectionId: connection.id + }); + return { success: true, message: 'Instance details saved successfully' }; + } else { + return { success: false, message: 'RDS cluster or instance not found' }; } - }); - await associateClusterConnection(dbAccess, { - projectId: connection.projectId, - clusterId: instanceId, - connectionId: connection.id - }); - return { success: true, message: 'Instance details saved successfully' }; + } + } catch (error) { + console.error('Error saving cluster/instance details:', error); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + return { success: false, message: `Error saving details: ${errorMessage}` }; } } diff --git a/apps/dbagent/src/components/aws-integration/aws-integration.tsx b/apps/dbagent/src/components/aws-integration/aws-integration.tsx index 3931e396..24827b91 100644 --- a/apps/dbagent/src/components/aws-integration/aws-integration.tsx +++ b/apps/dbagent/src/components/aws-integration/aws-integration.tsx @@ -19,12 +19,12 @@ import { SelectValue, toast } from '@xata.io/components'; -import { AlertCircle, Loader2 } from 'lucide-react'; +import { AlertCircle, ExternalLink, Loader2 } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { RDSClusterDetailedInfo, RDSClusterInfo } from '~/lib/aws/rds'; import { Connection } from '~/lib/db/schema'; -import { fetchRDSClusterDetails, fetchRDSClusters, getAWSIntegration } from './actions'; +import { checkEc2InstanceRoleStatus, fetchRDSClusterDetails, fetchRDSClusters, getAWSIntegration } from './actions'; import { DatabaseConnectionSelector } from './db-instance-connector'; import { RDSClusterCard } from './rds-instance-card'; @@ -53,54 +53,115 @@ const regions = [ 'ap-southeast-3', 'ap-southeast-4', 'ap-southeast-5', - 'ap-southeast-7', + 'ap-southeast-6', 'ca-central-1', 'ca-west-1', 'il-central-1', 'me-central-1', 'me-south-1', - 'mx-central-1', 'sa-east-1' ]; +type AuthMethod = 'credentials' | 'cloudformation' | 'ec2'; + export function AWSIntegration({ projectId, connections }: { projectId: string; connections: Connection[] }) { + const [authMethod, setAuthMethod] = useState('credentials'); const [accessKeyId, setAccessKeyId] = useState(''); const [secretAccessKey, setSecretAccessKey] = useState(''); + const [roleArn, setRoleArn] = useState(''); const [region, setRegion] = useState(''); const [rdsClusters, setRdsClusters] = useState([]); const [selectedCluster, setSelectedCluster] = useState(''); const [clusterDetails, setClusterDetails] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [isEc2RoleActive, setIsEc2RoleActive] = useState(null); + const [isLoadingEc2Status, setIsLoadingEc2Status] = useState(false); useEffect(() => { const loadAWSIntegration = async () => { + setIsLoading(true); const response = await getAWSIntegration(projectId); if (response.success && response.data) { - setAccessKeyId(response.data.accessKeyId); - setSecretAccessKey(response.data.secretAccessKey); - setRegion(response.data.region); + const { + authMethod: storedAuthMethod, + accessKeyId: storedAccessKeyId, + secretAccessKey: storedSecretAccessKey, + region: storedRegion, + roleArn: storedRoleArn + } = response.data as any; + if (storedAuthMethod) setAuthMethod(storedAuthMethod); + if (storedAccessKeyId) setAccessKeyId(storedAccessKeyId); + if (storedSecretAccessKey) setSecretAccessKey(storedSecretAccessKey); + if (storedRegion) setRegion(storedRegion); + if (storedRoleArn) setRoleArn(storedRoleArn); } + setIsLoading(false); }; void loadAWSIntegration(); - }, []); + + const checkEc2Status = async () => { + setIsLoadingEc2Status(true); + try { + const ec2Status = await checkEc2InstanceRoleStatus(); + setIsEc2RoleActive(ec2Status.data?.hasIAMRole ?? false); + } catch (error) { + console.error('Failed to check EC2 instance status', error); + setIsEc2RoleActive(false); + } finally { + setIsLoadingEc2Status(false); + } + }; + void checkEc2Status(); + }, [projectId]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); + setRdsClusters([]); + setSelectedCluster(''); + setClusterDetails(null); + + let authPayload: any; + switch (authMethod) { + case 'credentials': + if (!accessKeyId || !secretAccessKey) { + toast('Error: Access Key ID and Secret Access Key are required for credentials authentication.'); + setIsLoading(false); + return; + } + authPayload = { type: 'credentials', accessKeyId, secretAccessKey, region }; + break; + case 'cloudformation': + if (!roleArn) { + toast('Error: Role ARN is required for CloudFormation authentication.'); + setIsLoading(false); + return; + } + authPayload = { type: 'cloudformation', roleArn, region }; + break; + case 'ec2': + authPayload = { type: 'ec2', region }; + break; + default: + toast('Error: Invalid authentication method selected.'); + setIsLoading(false); + return; + } + try { - const response = await fetchRDSClusters(projectId, accessKeyId, secretAccessKey, region); + const response = await fetchRDSClusters(projectId, authPayload); if (response.success) { if (response.data.length === 0) { - toast('No RDS clusters found in the selected region'); + toast('No RDS clusters/instances found in the selected region with the provided authentication.'); } else { setRdsClusters(response.data); - toast('RDS clusters fetched successfully'); + toast('RDS clusters/instances fetched successfully'); } } else { - toast(`Error: Failed to fetch RDS clusters. ${response.message}`); + toast(`Error: Failed to fetch RDS clusters/instances. ${response.message}`); } } catch (error) { - toast('Error: Failed to fetch RDS clusters. Please check your credentials and try again.'); + toast('Error: Failed to fetch RDS clusters/instances. Please check your configuration and try again.'); } finally { setIsLoading(false); } @@ -113,11 +174,28 @@ export function AWSIntegration({ projectId, connections }: { projectId: string; return; } setSelectedCluster(cluster.identifier); + setIsLoading(true); try { - const details = await fetchRDSClusterDetails(projectId, cluster); + let authPayload: any; + switch (authMethod) { + case 'credentials': + authPayload = { type: 'credentials', accessKeyId, secretAccessKey, region }; + break; + case 'cloudformation': + authPayload = { type: 'cloudformation', roleArn, region }; + break; + case 'ec2': + authPayload = { type: 'ec2', region }; + break; + default: + throw new Error('Invalid auth method'); + } + const details = await fetchRDSClusterDetails(projectId, cluster, authPayload); setClusterDetails(details.data); } catch (error) { toast('Error: Failed to fetch RDS instance details.'); + } finally { + setIsLoading(false); } }; @@ -125,47 +203,114 @@ export function AWSIntegration({ projectId, connections }: { projectId: string; AWS Integration - Configure your AWS integration and select an RDS cluster + Configure your AWS integration and select an RDS cluster/instance
- Add an IAM policy and user + AWS Authentication Guide - To obtain the Access Key ID and Secret Access Key,{' '} + For detailed instructions on all authentication methods, including setting up IAM roles,{' '} - follow this guide + follow this guide - . It only takes a few minutes to set up. + .
-
-
- - setAccessKeyId(e.target.value)} required /> -
+
- - setSecretAccessKey(e.target.value)} - required - /> + +
+ + {authMethod === 'credentials' && ( + <> +
+ + setAccessKeyId(e.target.value)} + required={authMethod === 'credentials'} + placeholder="AKIAIOSFODNN7EXAMPLE" + /> +
+
+ + setSecretAccessKey(e.target.value)} + required={authMethod === 'credentials'} + placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + /> +
+ + )} + + {authMethod === 'cloudformation' && ( +
+ + setRoleArn(e.target.value)} + required={authMethod === 'cloudformation'} + placeholder="arn:aws:iam::123456789012:role/XataAgentRole" + /> +

+ Create an IAM role using our{' '} + + CloudFormation template + {' '} + and paste the Role ARN here. +

+
+ )} + + {authMethod === 'ec2' && ( + + + EC2 Instance IAM Role + + {isLoadingEc2Status + ? 'Checking EC2 IAM role status...' + : isEc2RoleActive === true + ? 'An IAM role is detected on this EC2 instance. It will be used for authentication if it has the required permissions.' + : isEc2RoleActive === false + ? 'No suitable IAM role detected or not running on an EC2 instance. Ensure an IAM role with necessary permissions is attached to the EC2 instance.' + : 'Unable to determine EC2 IAM role status. Ensure this application is running on an EC2 instance with an appropriate IAM role.'} + + + )} +
- +
-