diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts index cafeba5b792..79f7796db2b 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.test.ts @@ -65,6 +65,12 @@ describe('CFNOutputResolver', () => { Ref: 'snstopic', }, }, + KinesisStreamArn: { + Description: 'Kinesis Stream Arn', + Value: { + 'Fn::GetAtt': ['MyKinesisStream', 'Arn'], + }, + }, }, Resources: { MyS3Bucket: { @@ -221,6 +227,29 @@ describe('CFNOutputResolver', () => { }, }, }, + MyKinesisStream: { + Type: 'AWS::Kinesis::Stream', + Properties: { + Name: 'MyKinesisStream', + ShardCount: 1, + }, + }, + KinesisStreamPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'KinesisStreamPolicy', + PolicyDocument: { + Statement: [ + { + Effect: 'Allow', + Action: 'kinesis:PutRecord', + Resource: { 'Fn::GetAtt': ['MyKinesisStream', 'Arn'] }, + }, + ], + }, + Roles: [{ Ref: 'AuthenticatedRole' }], + }, + }, }, }; const expectedTemplate: CFNTemplate = { @@ -264,6 +293,10 @@ describe('CFNOutputResolver', () => { Description: 'SnsTopicArn', Value: 'arn:aws:sns:us-east-1:12345:snsTopic', }, + KinesisStreamArn: { + Description: 'Kinesis Stream Arn', + Value: 'arn:aws:kinesis:us-east-1:12345:stream/MyKinesisStream', + }, }, Resources: { MyS3Bucket: { @@ -412,6 +445,29 @@ describe('CFNOutputResolver', () => { BucketName: 'test-bucket', }, }, + MyKinesisStream: { + Type: 'AWS::Kinesis::Stream', + Properties: { + Name: 'MyKinesisStream', + ShardCount: 1, + }, + }, + KinesisStreamPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'KinesisStreamPolicy', + PolicyDocument: { + Statement: [ + { + Effect: 'Allow', + Action: 'kinesis:PutRecord', + Resource: 'arn:aws:kinesis:us-east-1:12345:stream/MyKinesisStream', + }, + ], + }, + Roles: [{ Ref: 'AuthenticatedRole' }], + }, + }, }, }; @@ -452,6 +508,10 @@ describe('CFNOutputResolver', () => { OutputKey: 'snsTopicArn', OutputValue: 'arn:aws:sns:us-east-1:12345:snsTopic', }, + { + OutputKey: 'KinesisStreamArn', + OutputValue: 'arn:aws:kinesis:us-east-1:12345:stream/MyKinesisStream', + }, ], [ { @@ -485,4 +545,67 @@ describe('CFNOutputResolver', () => { ), ).toEqual(expectedTemplate); }); + + it('should throw error when Kinesis ARN is not exposed in outputs', () => { + // Template with Kinesis stream reference NOT exposed via outputs + const templateWithKinesisNotInOutputs: CFNTemplate = { + AWSTemplateFormatVersion: '2010-09-09', + Description: 'Test template - Kinesis not in outputs', + Parameters: {}, + Outputs: { + SomeOtherOutput: { + Description: 'Other output', + Value: 'some-value', + }, + }, + Resources: { + MyKinesisStream: { + Type: 'AWS::Kinesis::Stream', + Properties: { + Name: 'MyKinesisStream', + ShardCount: 1, + }, + }, + KinesisPolicy: { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'KinesisPolicy', + PolicyDocument: { + Statement: [ + { + Effect: 'Allow', + Action: 'kinesis:PutRecord', + Resource: { 'Fn::GetAtt': ['MyKinesisStream', 'Arn'] }, + }, + ], + }, + }, + }, + }, + }; + + // When Kinesis ARN is not exposed in outputs, the resolver should fail early + // since the physical resource ID for Kinesis streams is the stream name, not the ARN. + expect(() => + new CfnOutputResolver(templateWithKinesisNotInOutputs, 'us-east-1', '123456789012').resolve( + [], + [{ OutputKey: 'SomeOtherOutput', OutputValue: 'some-value' }], + [ + { + StackName: 'test-stack', + StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/test-stack', + LogicalResourceId: 'MyKinesisStream', + PhysicalResourceId: 'MyKinesisStream', // Stream name, NOT ARN + ResourceType: 'AWS::Kinesis::Stream', + Timestamp: new Date(), + ResourceStatus: 'CREATE_COMPLETE', + }, + ], + ), + ).toThrow( + `Kinesis stream ARN must be exposed in CloudFormation outputs. ` + + `Found physical resource ID 'MyKinesisStream' for logical resource 'MyKinesisStream' which is not a valid ARN. ` + + `Please add an output with Fn::GetAtt for the Kinesis stream's Arn attribute.`, + ); + }); }); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/generators/template-generator.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/generators/template-generator.ts index adfa0e41873..5af1218a793 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/generators/template-generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/generators/template-generator.ts @@ -20,6 +20,7 @@ import { CFNStackStatus, CFNTemplate, ResourceMapping, + CFN_ANALYTICS_TYPE, } from '../types'; import { pollStackForCompletionState, tryUpdateStack } from '../cfn-stack-updater'; import { SSMClient } from '@aws-sdk/client-ssm'; @@ -33,7 +34,7 @@ import { Logger } from '../../../gen2-migration'; const CFN_RESOURCE_STACK_TYPE = 'AWS::CloudFormation::Stack'; const GEN2_AMPLIFY_AUTH_LOGICAL_ID_PREFIX = 'amplifyAuth'; -const CATEGORIES: CATEGORY[] = ['auth', 'storage']; +const CATEGORIES: CATEGORY[] = ['auth', 'storage', 'analytics']; const TEMPLATES_DIR = '.amplify/migration/templates'; const SEPARATOR = ' to '; @@ -48,6 +49,7 @@ const AUTH_RESOURCES_TO_REFACTOR = [ ]; const AUTH_USER_POOL_GROUP_RESOURCES_TO_REFACTOR = [CFN_AUTH_TYPE.UserPoolGroup]; const STORAGE_RESOURCES_TO_REFACTOR = [CFN_S3_TYPE.Bucket, CFN_DYNAMODB_TYPE.Table]; +const ANALYTICS_RESOURCES_TO_REFACTOR = [CFN_ANALYTICS_TYPE.Stream]; // The following is only used for revert operation const GEN1_RESOURCE_TYPE_TO_LOGICAL_RESOURCE_IDS_MAP = new Map([ @@ -58,11 +60,13 @@ const GEN1_RESOURCE_TYPE_TO_LOGICAL_RESOURCE_IDS_MAP = new Map([ [CFN_AUTH_TYPE.UserPoolDomain.valueOf(), 'UserPoolDomain'], [CFN_S3_TYPE.Bucket.valueOf(), 'S3Bucket'], [CFN_DYNAMODB_TYPE.Table.valueOf(), 'DynamoDBTable'], + [CFN_ANALYTICS_TYPE.Stream.valueOf(), 'KinesisStream'], ]); const LOGICAL_IDS_TO_REMOVE_FOR_REVERT_MAP = new Map([ ['auth', AUTH_RESOURCES_TO_REFACTOR], ['auth-user-pool-group', AUTH_USER_POOL_GROUP_RESOURCES_TO_REFACTOR], ['storage', [CFN_S3_TYPE.Bucket, CFN_DYNAMODB_TYPE.Table]], + ['analytics', ANALYTICS_RESOURCES_TO_REFACTOR], ]); const GEN2_NATIVE_APP_CLIENT = 'UserPoolNativeAppClient'; const GEN1_USER_POOL_GROUPS_STACK_TYPE_DESCRIPTION = 'auth-Cognito-UserPool-Groups'; @@ -84,6 +88,9 @@ class TemplateGenerator { storage: { resourcesToRefactor: STORAGE_RESOURCES_TO_REFACTOR, }, + analytics: { + resourcesToRefactor: ANALYTICS_RESOURCES_TO_REFACTOR, + }, } as const; constructor( @@ -240,7 +247,7 @@ class TemplateGenerator { let destinationPhysicalResourceId: string | undefined; let userPoolGroupDestinationPhysicalResourceId: string | undefined; - // Find the corresponding category stack in the destination by matching category prefix + // find the corresponding category stack in Gen2 stack const correspondingCategoryStackInDestination = destinationCategoryStacks.find( ({ LogicalResourceId: destinationLogicalResourceId }) => destinationLogicalResourceId?.startsWith(category), ); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts index b2acf3fcef3..e49285389d0 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts @@ -94,6 +94,21 @@ class CfnOutputResolver { const fnGetAttRegExpPerLogicalId = new RegExp(`{"${GET_ATT}":\\["${groups.LogicalResourceId}","(?\\w+)"]}`, 'g'); const stackResourcePhysicalId = stackResourceWithMatchingLogicalId.PhysicalResourceId; assert(stackResourcePhysicalId); + + // Kinesis streams require their ARN to be exposed in CloudFormation outputs. + // The physical resource ID for Kinesis streams is the stream name, not the ARN. + if ( + stackResourceWithMatchingLogicalId.ResourceType === 'AWS::Kinesis::Stream' && + groups.AttributeName === 'Arn' && + !stackResourcePhysicalId.startsWith('arn:aws:kinesis') + ) { + throw new Error( + `Kinesis stream ARN must be exposed in CloudFormation outputs. ` + + `Found physical resource ID '${stackResourcePhysicalId}' for logical resource '${groups.LogicalResourceId}' which is not a valid ARN. ` + + `Please add an output with Fn::GetAtt for the Kinesis stream's Arn attribute.`, + ); + } + if (groups.AttributeName === 'Arn') { // Few resources like SQS have their physical ids as their HTTP URLs. We need to construct the arn manually in such cases. const resourceId = stackResourcePhysicalId.startsWith('http') ? stackResourcePhysicalId.split('/')[2] : stackResourcePhysicalId; @@ -165,6 +180,11 @@ class CfnOutputResolver { return { Arn: `arn:aws:lambda:${this.region}:${this.accountId}:function:${resourceIdentifier}`, }; + case 'AWS::Kinesis::Stream': + return { + // output is already in ARN format + Arn: resourceIdentifier, + }; default: return undefined; } diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/types.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/types.ts index e9d9daf0174..52c227aba66 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/types.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/types.ts @@ -70,12 +70,14 @@ export enum NON_CUSTOM_RESOURCE_CATEGORY { AUTH = 'auth', STORAGE = 'storage', AUTH_USER_POOL_GROUP = 'auth-user-pool-group', + ANALYTICS = 'analytics', } export type CATEGORY = | NON_CUSTOM_RESOURCE_CATEGORY.AUTH | NON_CUSTOM_RESOURCE_CATEGORY.STORAGE | NON_CUSTOM_RESOURCE_CATEGORY.AUTH_USER_POOL_GROUP + | NON_CUSTOM_RESOURCE_CATEGORY.ANALYTICS | string; export interface ResourceMappingLocation { @@ -105,6 +107,10 @@ export enum CFN_DYNAMODB_TYPE { Table = 'AWS::DynamoDB::Table', } +export enum CFN_ANALYTICS_TYPE { + Stream = 'AWS::Kinesis::Stream', +} + export enum CFN_IAM_TYPE { Role = 'AWS::IAM::Role', } @@ -117,11 +123,18 @@ export enum CFN_LAMBDA_TYPE { Function = 'AWS::Lambda::Function', } -export type CFN_RESOURCE_TYPES = CFN_AUTH_TYPE | CFN_S3_TYPE | CFN_DYNAMODB_TYPE | CFN_IAM_TYPE | CFN_SQS_TYPE | CFN_LAMBDA_TYPE; +export type CFN_RESOURCE_TYPES = + | CFN_AUTH_TYPE + | CFN_S3_TYPE + | CFN_DYNAMODB_TYPE + | CFN_ANALYTICS_TYPE + | CFN_IAM_TYPE + | CFN_SQS_TYPE + | CFN_LAMBDA_TYPE; export type AWS_RESOURCE_ATTRIBUTES = 'Arn'; -export type CFN_CATEGORY_TYPE = CFN_AUTH_TYPE | CFN_S3_TYPE | CFN_IAM_TYPE | string; +export type CFN_CATEGORY_TYPE = CFN_AUTH_TYPE | CFN_S3_TYPE | CFN_ANALYTICS_TYPE | CFN_IAM_TYPE | string; export enum CFN_PSEUDO_PARAMETERS_REF { StackName = 'AWS::StackName',