From acc45eb5ad72bd292e7df00e6b79458054d92904 Mon Sep 17 00:00:00 2001 From: Tobin Date: Sun, 9 Feb 2025 23:12:05 +0000 Subject: [PATCH 1/4] first pass adding extra lookups --- src/index.ts | 68 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/src/index.ts b/src/index.ts index da62e3e0..99d1aea1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ import fs from 'fs'; import { DescribeStackResourcesInput, DescribeStackResourcesOutput, + DescribeStacksInput, + DescribeStacksOutput, } from 'aws-sdk/clients/cloudformation'; import { AssociateApiRequest, @@ -102,7 +104,10 @@ class ServerlessAppsyncPlugin { public readonly configurationVariablesSources?: VariablesSourcesDefinition; private api?: Api; private naming?: Naming; - + // this should instan + private cachedValues: { + apiId: string | null; + }; constructor( public serverless: Serverless, private options: Record, @@ -116,6 +121,9 @@ class ServerlessAppsyncPlugin { this.options = options; this.provider = this.serverless.getProvider('aws'); this.utils = utils; + this.cachedValues = { + apiId: null, + }; // We are using a newer version of AJV than Serverless Framework // and some customizations (eg: custom errors, $merge, filter irrelevant errors) // For SF, just validate the type of input to allow us to use a custom @@ -374,6 +382,11 @@ class ServerlessAppsyncPlugin { 'Could not find the naming service. This should not happen.', ); } + // The loading is quite involved so caching is helpful + // And the ApiId shouldn't change during a class lifecycle + if(this.cachedValues.apiId) { + return this.cachedValues.apiId; + } const logicalIdGraphQLApi = this.naming.getApiLogicalId(); @@ -385,7 +398,38 @@ class ServerlessAppsyncPlugin { LogicalResourceId: logicalIdGraphQLApi, }); - const apiId = last(StackResources?.[0]?.PhysicalResourceId?.split('/')); + let apiId = last(StackResources?.[0]?.PhysicalResourceId?.split('/')); + + if (!apiId) { + // If the user has split the stacks automatically, the SAP logical id + // will still apply and we can search the stack outputs for it + const stackPrefix = this.provider.naming.getStackName(); + const stackOutputKey = `${logicalIdGraphQLApi}ApiId` + + let NextToken: string | undefined = undefined; + let escapeCount = 10 // 10 pages of stacks is enough + do { + const stacksDescription = await this.provider.request< + DescribeStacksInput, + DescribeStacksOutput + >('CloudFormation', 'describeStacks', { + NextToken + }); + NextToken = stacksDescription.NextToken; + escapeCount--; + + // Try to extract the apiId from the outputs + // This works only when 1 GraphQL API is defined in the stack + // Which is fine as this is already a business rule. + const outputs = stacksDescription + .Stacks?.filter(({ StackName }) => StackName.startsWith(stackPrefix)) + .flatMap(stack => stack.Outputs || []) || []; + + apiId = outputs?.find(output => output.OutputKey === stackOutputKey)?.OutputValue + + } while (NextToken && !apiId && escapeCount > 0); + + } if (!apiId) { throw new this.serverless.classes.Error( @@ -393,6 +437,8 @@ class ServerlessAppsyncPlugin { ); } + this.cachedValues.apiId = apiId; + return apiId; } @@ -533,14 +579,14 @@ class ServerlessAppsyncPlugin { if (domain.useCloudFormation !== false) { this.utils.log.warning( 'You are using the CloudFormation integration for domain configuration.\n' + - 'To avoid CloudFormation drifts, you should not use it in combination with this command.\n' + - 'Set the `domain.useCloudFormation` attribute to false to use the CLI integration.\n' + - 'If you have already deployed using CloudFormation and would like to switch to using the CLI, you can ' + - terminalLink( - 'eject from CloudFormation', - 'https://github.com/sid88in/serverless-appsync-plugin/blob/master/doc/custom-domain.md#ejecting-from-cloudformation', - ) + - ' first.', + 'To avoid CloudFormation drifts, you should not use it in combination with this command.\n' + + 'Set the `domain.useCloudFormation` attribute to false to use the CLI integration.\n' + + 'If you have already deployed using CloudFormation and would like to switch to using the CLI, you can ' + + terminalLink( + 'eject from CloudFormation', + 'https://github.com/sid88in/serverless-appsync-plugin/blob/master/doc/custom-domain.md#ejecting-from-cloudformation', + ) + + ' first.', ); if (!this.options.yes && !(await confirmAction())) { @@ -747,7 +793,7 @@ class ServerlessAppsyncPlugin { if (assoc?.apiId !== apiId && !this.options.force) { throw new this.serverless.classes.Error( `The domain ${domain.name} is currently associated to another API (${assoc?.apiId})\n` + - `Try running this command from that API's stack or stage, or use the --force / -f flag`, + `Try running this command from that API's stack or stage, or use the --force / -f flag`, ); } this.utils.log.warning( From bcff75181008e4a3f900465db367a810dd37110b Mon Sep 17 00:00:00 2001 From: Adam Swift Date: Mon, 23 Jun 2025 10:46:12 -0400 Subject: [PATCH 2/4] Refactored the logic for looking for the Appsync API id output in nested stacks if it is not found in the main stack --- src/index.ts | 80 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/src/index.ts b/src/index.ts index 99d1aea1..1d5b4935 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,10 +9,14 @@ import path from 'path'; import open from 'open'; import fs from 'fs'; import { + DescribeStackInstanceInput, + DescribeStackInstanceOutput, DescribeStackResourcesInput, DescribeStackResourcesOutput, DescribeStacksInput, DescribeStacksOutput, + Outputs, + Stack, } from 'aws-sdk/clients/cloudformation'; import { AssociateApiRequest, @@ -108,6 +112,7 @@ class ServerlessAppsyncPlugin { private cachedValues: { apiId: string | null; }; + constructor( public serverless: Serverless, private options: Record, @@ -384,13 +389,15 @@ class ServerlessAppsyncPlugin { } // The loading is quite involved so caching is helpful // And the ApiId shouldn't change during a class lifecycle - if(this.cachedValues.apiId) { + if (this.cachedValues.apiId) { return this.cachedValues.apiId; } const logicalIdGraphQLApi = this.naming.getApiLogicalId(); - const { StackResources } = await this.provider.request< + let StackResources; + + const mainStackApiCheck = await this.provider.request< DescribeStackResourcesInput, DescribeStackResourcesOutput >('CloudFormation', 'describeStackResources', { @@ -398,37 +405,44 @@ class ServerlessAppsyncPlugin { LogicalResourceId: logicalIdGraphQLApi, }); - let apiId = last(StackResources?.[0]?.PhysicalResourceId?.split('/')); + StackResources = mainStackApiCheck.StackResources || []; + + let apiId: string | undefined | null = last( + StackResources?.[0]?.PhysicalResourceId?.split('/'), + ); if (!apiId) { - // If the user has split the stacks automatically, the SAP logical id + // If the user has split the stacks automatically, the SAP logical id // will still apply and we can search the stack outputs for it - const stackPrefix = this.provider.naming.getStackName(); - const stackOutputKey = `${logicalIdGraphQLApi}ApiId` + const stackOutputKey = `${logicalIdGraphQLApi}ApiId`; + + const mainStackResources = await this.provider.request< + DescribeStackResourcesInput, + DescribeStackResourcesOutput + >('CloudFormation', 'describeStackResources', { + StackName: this.provider.naming.getStackName(), + }); + StackResources = mainStackResources.StackResources || []; - let NextToken: string | undefined = undefined; - let escapeCount = 10 // 10 pages of stacks is enough - do { - const stacksDescription = await this.provider.request< + const nestedStacks = StackResources.filter( + (resource) => resource.ResourceType === 'AWS::CloudFormation::Stack', + ); + for (const nestedStack of nestedStacks) { + const nestedStackResult = await this.provider.request< DescribeStacksInput, DescribeStacksOutput >('CloudFormation', 'describeStacks', { - NextToken + StackName: nestedStack.PhysicalResourceId, }); - NextToken = stacksDescription.NextToken; - escapeCount--; - - // Try to extract the apiId from the outputs - // This works only when 1 GraphQL API is defined in the stack - // Which is fine as this is already a business rule. - const outputs = stacksDescription - .Stacks?.filter(({ StackName }) => StackName.startsWith(stackPrefix)) - .flatMap(stack => stack.Outputs || []) || []; - - apiId = outputs?.find(output => output.OutputKey === stackOutputKey)?.OutputValue - - } while (NextToken && !apiId && escapeCount > 0); + const outputs: Outputs = nestedStackResult.Stacks?.[0]?.Outputs ?? []; + apiId = outputs.find( + (output) => output.OutputKey === stackOutputKey, + )?.OutputValue; + if (apiId) { + break; + } + } } if (!apiId) { @@ -579,14 +593,14 @@ class ServerlessAppsyncPlugin { if (domain.useCloudFormation !== false) { this.utils.log.warning( 'You are using the CloudFormation integration for domain configuration.\n' + - 'To avoid CloudFormation drifts, you should not use it in combination with this command.\n' + - 'Set the `domain.useCloudFormation` attribute to false to use the CLI integration.\n' + - 'If you have already deployed using CloudFormation and would like to switch to using the CLI, you can ' + - terminalLink( - 'eject from CloudFormation', - 'https://github.com/sid88in/serverless-appsync-plugin/blob/master/doc/custom-domain.md#ejecting-from-cloudformation', - ) + - ' first.', + 'To avoid CloudFormation drifts, you should not use it in combination with this command.\n' + + 'Set the `domain.useCloudFormation` attribute to false to use the CLI integration.\n' + + 'If you have already deployed using CloudFormation and would like to switch to using the CLI, you can ' + + terminalLink( + 'eject from CloudFormation', + 'https://github.com/sid88in/serverless-appsync-plugin/blob/master/doc/custom-domain.md#ejecting-from-cloudformation', + ) + + ' first.', ); if (!this.options.yes && !(await confirmAction())) { @@ -793,7 +807,7 @@ class ServerlessAppsyncPlugin { if (assoc?.apiId !== apiId && !this.options.force) { throw new this.serverless.classes.Error( `The domain ${domain.name} is currently associated to another API (${assoc?.apiId})\n` + - `Try running this command from that API's stack or stage, or use the --force / -f flag`, + `Try running this command from that API's stack or stage, or use the --force / -f flag`, ); } this.utils.log.warning( From 6301c745b92868cde222e40cea1227c9e4eb469a Mon Sep 17 00:00:00 2001 From: Adam Swift Date: Mon, 23 Jun 2025 11:00:11 -0400 Subject: [PATCH 3/4] Cleanup imports --- src/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1d5b4935..832dc803 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,14 +9,11 @@ import path from 'path'; import open from 'open'; import fs from 'fs'; import { - DescribeStackInstanceInput, - DescribeStackInstanceOutput, DescribeStackResourcesInput, DescribeStackResourcesOutput, DescribeStacksInput, DescribeStacksOutput, Outputs, - Stack, } from 'aws-sdk/clients/cloudformation'; import { AssociateApiRequest, From 79ee536fcd240d30eea9c10318e4d2831d08b5cf Mon Sep 17 00:00:00 2001 From: Adam Swift Date: Mon, 30 Jun 2025 13:51:18 -0400 Subject: [PATCH 4/4] fix(aws): Addressed an issue if the main stack had multiple pages worth of resources the nested stack with the API could wind up in a later page. --- src/index.ts | 86 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/src/index.ts b/src/index.ts index 832dc803..18944e4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ import { DescribeStackResourcesOutput, DescribeStacksInput, DescribeStacksOutput, + ListStackResourcesInput, + ListStackResourcesOutput, Outputs, } from 'aws-sdk/clients/cloudformation'; import { @@ -391,55 +393,69 @@ class ServerlessAppsyncPlugin { } const logicalIdGraphQLApi = this.naming.getApiLogicalId(); + const stackOutputKey = `${logicalIdGraphQLApi}ApiId`; + const mainStackName = this.provider.naming.getStackName(); - let StackResources; - + // First check the main stack resources directly for the API resource const mainStackApiCheck = await this.provider.request< DescribeStackResourcesInput, DescribeStackResourcesOutput >('CloudFormation', 'describeStackResources', { - StackName: this.provider.naming.getStackName(), + StackName: mainStackName, LogicalResourceId: logicalIdGraphQLApi, }); - StackResources = mainStackApiCheck.StackResources || []; - + const StackResources = mainStackApiCheck.StackResources || []; let apiId: string | undefined | null = last( StackResources?.[0]?.PhysicalResourceId?.split('/'), ); + // If not found, we need to search through all stack resources (with pagination) + // and then check nested stacks if (!apiId) { - // If the user has split the stacks automatically, the SAP logical id - // will still apply and we can search the stack outputs for it - const stackOutputKey = `${logicalIdGraphQLApi}ApiId`; - - const mainStackResources = await this.provider.request< - DescribeStackResourcesInput, - DescribeStackResourcesOutput - >('CloudFormation', 'describeStackResources', { - StackName: this.provider.naming.getStackName(), - }); - StackResources = mainStackResources.StackResources || []; - - const nestedStacks = StackResources.filter( - (resource) => resource.ResourceType === 'AWS::CloudFormation::Stack', - ); - for (const nestedStack of nestedStacks) { - const nestedStackResult = await this.provider.request< - DescribeStacksInput, - DescribeStacksOutput - >('CloudFormation', 'describeStacks', { - StackName: nestedStack.PhysicalResourceId, - }); - - const outputs: Outputs = nestedStackResult.Stacks?.[0]?.Outputs ?? []; - apiId = outputs.find( - (output) => output.OutputKey === stackOutputKey, - )?.OutputValue; - if (apiId) { - break; + let nextToken: string | undefined; + + do { + const mainStackResources: ListStackResourcesOutput = + await this.provider.request< + ListStackResourcesInput, + ListStackResourcesOutput + >('CloudFormation', 'listStackResources', { + StackName: mainStackName, + NextToken: nextToken, + } as ListStackResourcesInput); + const resources = mainStackResources.StackResourceSummaries || []; + + const nestedStacks = + resources.filter( + (resource) => + resource.ResourceType === 'AWS::CloudFormation::Stack', + ) || []; + // If not found in main stack resources, check each nested stack + if (!apiId && nestedStacks.length > 0) { + for (const nestedStack of nestedStacks) { + if (!nestedStack.PhysicalResourceId) continue; + + const nestedStackResult = await this.provider.request< + DescribeStacksInput, + DescribeStacksOutput + >('CloudFormation', 'describeStacks', { + StackName: nestedStack.PhysicalResourceId, + }); + + const outputs: Outputs = + nestedStackResult.Stacks?.[0]?.Outputs ?? []; + apiId = outputs.find( + (output) => output.OutputKey === stackOutputKey, + )?.OutputValue; + if (apiId) { + break; // Found it, no need to check other nested stacks + } + } } - } + + nextToken = mainStackResources.NextToken; + } while (nextToken && !apiId); // Stop pagination if we found the API ID } if (!apiId) {