diff --git a/packages/@aws-cdk/toolkit-lib/lib/actions/diagnose/index.ts b/packages/@aws-cdk/toolkit-lib/lib/actions/diagnose/index.ts index deca0367b..473d91728 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/actions/diagnose/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/actions/diagnose/index.ts @@ -86,4 +86,30 @@ export interface TracedResourceError { * (Not optional on purpose so we are not allowed to forget to call the code that should fill it) */ readonly sourceTrace: SourceTrace | undefined; + + /** + * Additional context gathered from AWS service APIs to help diagnose the root cause. + * + * For example, CloudWatch Logs from an ECS service whose tasks failed to start. + */ + readonly additionalContext?: AdditionalDiagnosticContext[]; +} + +export interface AdditionalDiagnosticContext { + /** + * A short description of where this context came from + * + * @example "CloudWatch Logs (log-group-name)" + */ + readonly source: string; + + /** + * The log lines or messages retrieved + */ + readonly messages: string[]; + + /** + * An optional console deep link for further investigation + */ + readonly link?: string; } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts index 13a19af03..d1507f42e 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/aws-auth/sdk.ts @@ -253,6 +253,11 @@ import { } from '@aws-sdk/client-ecr'; import type { DescribeServicesCommandInput, + DescribeServicesCommandOutput, + DescribeTaskDefinitionCommandInput, + DescribeTaskDefinitionCommandOutput, + DescribeTasksCommandInput, + DescribeTasksCommandOutput, RegisterTaskDefinitionCommandInput, ListClustersCommandInput, ListClustersCommandOutput, @@ -261,6 +266,9 @@ import type { UpdateServiceCommandOutput, } from '@aws-sdk/client-ecs'; import { + DescribeServicesCommand, + DescribeTaskDefinitionCommand, + DescribeTasksCommand, ECSClient, ListClustersCommand, RegisterTaskDefinitionCommand, @@ -555,6 +563,9 @@ export interface IECRClient { } export interface IECSClient { + describeServices(input: DescribeServicesCommandInput): Promise; + describeTaskDefinition(input: DescribeTaskDefinitionCommandInput): Promise; + describeTasks(input: DescribeTasksCommandInput): Promise; listClusters(input: ListClustersCommandInput): Promise; registerTaskDefinition(input: RegisterTaskDefinitionCommandInput): Promise; updateService(input: UpdateServiceCommandInput): Promise; @@ -950,6 +961,12 @@ export class SDK { public ecs(): IECSClient { const client = new ECSClient(this.config); return { + describeServices: (input: DescribeServicesCommandInput): Promise => + client.send(new DescribeServicesCommand(input)), + describeTaskDefinition: (input: DescribeTaskDefinitionCommandInput): Promise => + client.send(new DescribeTaskDefinitionCommand(input)), + describeTasks: (input: DescribeTasksCommandInput): Promise => + client.send(new DescribeTasksCommand(input)), listClusters: (input: ListClustersCommandInput): Promise => client.send(new ListClustersCommand(input)), registerTaskDefinition: ( diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts index 85793fd8c..ddc26d358 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/deployments/deployments.ts @@ -416,6 +416,7 @@ export class Deployments { sourceTracer: new StackArtifactSourceTracer(options.stack), ioHelper: this.ioHelper, topLevelStackHierarchicalId: options.stack.hierarchicalId, + additionalExplorationSdkProvider: async () => (await this.envs.accessStackForLookupBestEffort(options.stack)).sdk, }), }, this.ioHelper); } @@ -486,6 +487,7 @@ export class Deployments { sourceTracer: new StackArtifactSourceTracer(stack), ioHelper: this.ioHelper, topLevelStackHierarchicalId: stack.hierarchicalId, + additionalExplorationSdkProvider: async () => (await this.envs.accessStackForLookupBestEffort(stack)).sdk, }), }); } diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/diagnosis-formatting.ts b/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/diagnosis-formatting.ts index 1dba0450b..e4fbe133f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/diagnosis-formatting.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/diagnosis-formatting.ts @@ -125,6 +125,7 @@ function formatResourceErrors(es: TracedResourceError[]) { const nodeText = b.nodeText(p); nodeText.header = [`${lastPart} ${addendum(' ', e.resourceType, e.logicalId)}`.trim()]; nodeText.body.push(...sideBySide(['🛑'], ' ', wrapText(120, e.message))); + nodeText.body.push(...formatAdditionalContext(e)); nodeText.footer = e.sourceTrace?.creationStackTrace ? sideBySide(['Source Location:'], ' ', e.sourceTrace?.creationStackTrace) : []; @@ -132,6 +133,27 @@ function formatResourceErrors(es: TracedResourceError[]) { return b.render(); } +function formatAdditionalContext(e: TracedResourceError): string[] { + if (!e.additionalContext || e.additionalContext.length === 0) { + return []; + } + + const lines: string[] = []; + for (const ctx of e.additionalContext) { + lines.push('', `📋 ${ctx.source}:`); + for (const msg of ctx.messages.slice(0, 20)) { + lines.push(` ${msg}`); + } + if (ctx.messages.length > 20) { + lines.push(` ... (${ctx.messages.length - 20} more lines)`); + } + if (ctx.link) { + lines.push(` 🔗 ${ctx.link}`); + } + } + return lines; +} + /** * Return a /-separated construct path for the given error, or try to build as close a represention as possible if we don't have a construct path * diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/resource-investigation.ts b/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/resource-investigation.ts new file mode 100644 index 000000000..89e798373 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/resource-investigation.ts @@ -0,0 +1,288 @@ +import type { AdditionalDiagnosticContext } from '../../actions/diagnose'; +import type { ICloudWatchLogsClient, IECSClient, SDK } from '../aws-auth/sdk'; +import type { ResourceError } from '../stack-events/resource-errors'; + +/** + * Investigate a failed resource using AWS service APIs to gather additional root cause context. + * + * Returns additional diagnostic context (e.g. log lines) or an empty array if + * investigation is not possible or yields no results for this resource type. + */ +export async function investigateResource( + err: ResourceError, + sdk: SDK, + debug: (msg: string) => Promise, +): Promise { + switch (err.resourceType) { + case 'AWS::ECS::Service': + return investigateEcsService(err, sdk, debug); + default: + return []; + } +} + +async function investigateEcsService( + err: ResourceError, + sdk: SDK, + debug: (msg: string) => Promise, +): Promise { + const physicalId = err.physicalId; + if (!physicalId) { + await debug('ECS investigation: no physical ID available'); + return []; + } + + const { clusterArn, serviceName } = parseEcsServiceIdentifier(physicalId); + if (!serviceName) { + await debug(`ECS investigation: could not parse service identifier from "${physicalId}"`); + return []; + } + + const region = sdk.currentRegion; + const ecs = sdk.ecs(); + const cwl = sdk.cloudWatchLogs(); + + const service = await describeService(ecs, clusterArn, serviceName, debug); + if (!service) { + return []; + } + + const results: AdditionalDiagnosticContext[] = []; + + const stoppedTaskContext = await getStoppedTaskReasons(ecs, clusterArn, serviceName, region, service, debug); + if (stoppedTaskContext) { + results.push(stoppedTaskContext); + } + + const taskDefinitionArn = service.taskDefinition; + if (!taskDefinitionArn) { + return results; + } + + const taskDefInfo = await getTaskDefinitionInfo(ecs, taskDefinitionArn, debug); + + if (taskDefInfo && taskDefInfo.images.length > 0) { + results.push({ source: 'Container Images', messages: taskDefInfo.images }); + } + + const logConfigs = taskDefInfo?.logConfigs ?? []; + + if (logConfigs.length === 0) { + return results; + } + + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + const logResults = await Promise.all(logConfigs.map(cfg => fetchRecentLogs(cwl, cfg, region, debug))); + let hasLogs = false; + for (const context of logResults) { + if (context) { + results.push(context); + hasLogs = true; + } + } + + if (!hasLogs) { + results.push({ + source: 'CloudWatch Logs', + messages: ['No application logs found (container may not have started). Check the stopped task reasons above for details.'], + }); + } + + return results; +} + +function parseEcsServiceIdentifier(physicalId: string): { clusterArn?: string; serviceName?: string } { + // ARN format: arn:aws:ecs:region:account:service/cluster-name/service-name + const arnMatch = physicalId.match(/arn:.*:ecs:.*:.*:service\/([^/]+)\/(.+)/); + if (arnMatch) { + return { clusterArn: arnMatch[1], serviceName: arnMatch[2] }; + } + + const parts = physicalId.split('/'); + if (parts.length === 2) { + return { clusterArn: parts[0], serviceName: parts[1] }; + } + + return { serviceName: physicalId }; +} + +async function describeService( + ecs: IECSClient, + cluster: string | undefined, + serviceName: string, + debug: (msg: string) => Promise, +) { + try { + const resp = await ecs.describeServices({ cluster, services: [serviceName] }); + const service = resp.services?.[0]; + if (!service) { + await debug(`ECS investigation: service "${serviceName}" not found`); + } + return service; + } catch (e: any) { + await debug(`ECS investigation: failed to describe service: ${e.message}`); + return undefined; + } +} + +async function getStoppedTaskReasons( + ecs: IECSClient, + cluster: string | undefined, + serviceName: string, + region: string, + service: { events?: Array<{ message?: string }>; [key: string]: any }, + debug: (msg: string) => Promise, +): Promise { + try { + const failureEvents = (service.events ?? []) + .filter(e => e.message?.includes('stopped') || e.message?.includes('failed')) + .slice(0, 5); + + if (failureEvents.length === 0) { + return undefined; + } + + const taskIds = (service.events ?? []) + .map(e => { + const match = e.message?.match(/task ([a-f0-9-]+)/); + return match ? match[1] : undefined; + }) + .filter((id): id is string => id != null) + .slice(0, 3); + + const messages: string[] = []; + + if (taskIds.length > 0) { + const tasksResp = await ecs.describeTasks({ cluster, tasks: taskIds }); + for (const task of tasksResp.tasks ?? []) { + if (task.stoppedReason) { + messages.push(`Task stopped: ${task.stoppedReason}`); + } + for (const container of task.containers ?? []) { + if (container.reason) { + messages.push(`Container "${container.name}": ${container.reason}`); + } + if (container.exitCode != null && container.exitCode !== 0) { + messages.push(`Container "${container.name}" exited with code ${container.exitCode}`); + } + } + } + } + + if (messages.length === 0) { + for (const event of failureEvents) { + if (event.message) { + messages.push(event.message); + } + } + } + + if (messages.length === 0) { + return undefined; + } + + return { + source: 'ECS Stopped Tasks', + messages, + link: ecsStoppedTasksConsoleUrl(region, cluster ?? 'default', serviceName), + }; + } catch (e: any) { + await debug(`ECS investigation: failed to get stopped task reasons: ${e.message}`); + return undefined; + } +} + +interface AwsLogsConfig { + logGroup: string; + streamPrefix?: string; + containerName?: string; +} + +interface TaskDefinitionInfo { + logConfigs: AwsLogsConfig[]; + images: string[]; +} + +async function getTaskDefinitionInfo( + ecs: IECSClient, + taskDefinitionArn: string, + debug: (msg: string) => Promise, +): Promise { + try { + const resp = await ecs.describeTaskDefinition({ taskDefinition: taskDefinitionArn }); + const containers = resp.taskDefinition?.containerDefinitions ?? []; + const logConfigs: AwsLogsConfig[] = []; + const images: string[] = []; + for (const container of containers) { + if (container.image) { + images.push(`${container.name}: ${container.image}`); + } + const logConfig = container.logConfiguration; + if (logConfig?.logDriver === 'awslogs') { + const logGroup = logConfig.options?.['awslogs-group']; + if (logGroup) { + logConfigs.push({ + logGroup, + streamPrefix: logConfig.options?.['awslogs-stream-prefix'], + containerName: container.name, + }); + } + } + } + return { logConfigs, images }; + } catch (e: any) { + await debug(`ECS investigation: failed to describe task definition: ${e.message}`); + return undefined; + } +} + +async function fetchRecentLogs( + cwl: ICloudWatchLogsClient, + logConfig: AwsLogsConfig, + region: string, + debug: (msg: string) => Promise, +): Promise { + try { + const startTime = Date.now() - 30 * 60 * 1000; + + const resp = await cwl.filterLogEvents({ + logGroupName: logConfig.logGroup, + startTime, + limit: 20, + ...(logConfig.streamPrefix ? { logStreamNamePrefix: logConfig.streamPrefix } : {}), + }); + + const events = resp.events ?? []; + if (events.length === 0) { + await debug(`ECS investigation: no recent log events in ${logConfig.logGroup}`); + return undefined; + } + + const messages = events + .map(e => e.message?.trimEnd()) + .filter((m): m is string => m != null); + + const source = logConfig.containerName + ? `CloudWatch Logs: ${logConfig.logGroup} (container: ${logConfig.containerName})` + : `CloudWatch Logs: ${logConfig.logGroup}`; + + return { + source, + messages, + link: cloudWatchLogsConsoleUrl(region, logConfig.logGroup), + }; + } catch (e: any) { + await debug(`ECS investigation: failed to fetch logs from ${logConfig.logGroup}: ${e.message}`); + return undefined; + } +} + +// CloudWatch console uses double-URI-encoding with '$' replacing '%' for the log group in the fragment. +function cloudWatchLogsConsoleUrl(region: string, logGroup: string): string { + const encodedLogGroup = encodeURIComponent(encodeURIComponent(logGroup)).replace(/%/g, '$'); + return `https://${region}.console.aws.amazon.com/cloudwatch/home?region=${region}#logsV2:log-groups/log-group/${encodedLogGroup}`; +} + +function ecsStoppedTasksConsoleUrl(region: string, cluster: string, serviceName: string): string { + return `https://${region}.console.aws.amazon.com/ecs/v2/clusters/${cluster}/services/${serviceName}/tasks?status=STOPPED®ion=${region}`; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/stack-diagnoser.ts b/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/stack-diagnoser.ts index dcdc0579c..b819c2d76 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/stack-diagnoser.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/diagnosing/stack-diagnoser.ts @@ -2,6 +2,7 @@ import type { ChangeSetSummary, Stack } from '@aws-sdk/client-cloudformation'; import { ChangeSetStatus, ChangeType } from '@aws-sdk/client-cloudformation'; import type { ChangeSetResourceError } from './changeset-error-fetcher'; import { ChangeSetResourceErrorFetcher } from './changeset-error-fetcher'; +import { investigateResource } from './resource-investigation'; import type { StackDiagnosis, StackProblemSource, TracedResourceError } from '../../actions/diagnose'; import type { ICloudFormationClient, SDK } from '../aws-auth/sdk'; import type { EnvironmentResources } from '../environment'; @@ -17,8 +18,24 @@ export interface CloudFormationStackDiagnoserProps { readonly sourceTracer: ISourceTracer; readonly ioHelper: IoHelper; readonly topLevelStackHierarchicalId: string; + + /** + * Optionally: a function to return an SDK that can be used for additional + * (readonly) exploratory calls. + * + * Typically, this should be an SDK that is primed with the "lookup" role, or similar. + * + * This is necessary because the "deploy" role will not typically have permissions to + * do very much. + * + * Regardless, if lookups of additional information fail, they are emitted at debug + * level and their information is simply not added to the output. + */ + readonly additionalExplorationSdkProvider?: SdkProvider; } +export type SdkProvider = () => Promise; + /** * Diagnose a stack's failed state * @@ -35,6 +52,8 @@ export interface CloudFormationStackDiagnoserProps { export class CloudFormationStackDiagnoser { private readonly cfn: ICloudFormationClient; private parentStackLogicalIds: string[]; + private additionalExplorationSdkFetched = false; + private _additionalExplorationSdk?: SDK; constructor(private readonly props: CloudFormationStackDiagnoserProps) { this.cfn = this.props.sdk.cloudFormation(); @@ -63,7 +82,7 @@ export class CloudFormationStackDiagnoser { }; } - if (status.isFailure) { + if (status.isFailure || status.isRollbackSuccess) { return await this._diagnoseViaStackEvents(stackName, stack); } @@ -87,8 +106,20 @@ export class CloudFormationStackDiagnoser { /** * Diagnose potential problems with the change set */ - public async diagnoseFromErrorCollection(errors: ResourceErrors, stack: Stack): Promise { + public async diagnoseFromErrorCollection(errors: ResourceErrors, stack: Stack, allowFallback = true): Promise { if (errors.isEmpty()) { + if (allowFallback) { + // The monitor may not have seen failure events yet (race condition). + // Fall back to polling stack events directly. + const stackName = stack.StackName; + if (stackName) { + try { + return await this._diagnoseViaStackEvents(stackName, stack); + } catch (e: any) { + await this.props.ioHelper.defaults.debug(`Fallback diagnosis failed: ${e.message}`); + } + } + } return { type: 'no-problem' }; } @@ -99,7 +130,7 @@ export class CloudFormationStackDiagnoser { stackStatus: stack.StackStatus ?? '', statusReason: stack.StackStatusReason ?? '', }, - problems: await this.addErrorTraces(errors.all), + problems: await this.enhanceErrors(errors.all), }; } @@ -118,7 +149,7 @@ export class CloudFormationStackDiagnoser { // which is the thing we care about. await poller.poll(); - return this.diagnoseFromErrorCollection(poller.errors, stack); + return this.diagnoseFromErrorCollection(poller.errors, stack, false); } private async _diagnoseChangeSetFailureFromStackName(stackName: string): Promise { @@ -187,7 +218,7 @@ export class CloudFormationStackDiagnoser { changeSetName: changeSet.ChangeSetName ?? '', statusReason: changeSet.StatusReason ?? '', }, - problems: await this.addErrorTraces(failedAutoErrors), + problems: await this.enhanceErrors(failedAutoErrors), }; } @@ -216,7 +247,7 @@ export class CloudFormationStackDiagnoser { return { type: 'problem', detectedBy, - problems: await this.addErrorTraces(ev.errors.map((e) => resourceErrorFromEarlyValidationError(changeSet.StackId ?? '', this.parentStackLogicalIds, e))), + problems: await this.enhanceErrors(ev.errors.map((e) => resourceErrorFromEarlyValidationError(changeSet.StackId ?? '', this.parentStackLogicalIds, e))), }; } @@ -229,20 +260,47 @@ export class CloudFormationStackDiagnoser { return this._nonSpecificChangeSetError(changeSet, detectedBy); } - private async addErrorTraces(errs: readonly ResourceError[]): Promise { + private async enhanceErrors(errs: readonly ResourceError[]): Promise { + // We're not actually limiting this here. But we are making the assumption that the amount of resources + // that will have errors are always pretty low in number. // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism - return Promise.all(errs.map((e) => this.addErrorTrace(e))); + return Promise.all(errs.map((e) => this.enhanceError(e))); } - private async addErrorTrace(err: ResourceError): Promise { - let sourceTrace; + private async enhanceError(err: ResourceError): Promise { + let sourceTracePromise; if (err.logicalId) { - sourceTrace = await this.props.sourceTracer.traceResource(err.stackArn, err.parentStackLogicalIds, err.logicalId); + sourceTracePromise = await this.props.sourceTracer.traceResource(err.stackArn, err.parentStackLogicalIds, err.logicalId); } else { - sourceTrace = await this.props.sourceTracer.traceStack(err.stackArn, err.parentStackLogicalIds); + sourceTracePromise = await this.props.sourceTracer.traceStack(err.stackArn, err.parentStackLogicalIds); } - return { ...err, sourceTrace, topLevelStackHierarchicalId: this.props.topLevelStackHierarchicalId }; + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + const [sourceTrace] = await Promise.all([ + sourceTracePromise, + ]); + + const additionalContext = await this.investigateResourceBestEffort(err); + + return { + ...err, + sourceTrace, + topLevelStackHierarchicalId: this.props.topLevelStackHierarchicalId, + ...(additionalContext.length > 0 ? { additionalContext } : {}), + }; + } + + private async investigateResourceBestEffort(err: ResourceError) { + const sdk = await this.additionalExplorationSdk(); + if (!sdk) { + return []; + } + try { + return await investigateResource(err, sdk, (msg) => this.props.ioHelper.defaults.debug(msg)); + } catch (e: any) { + await this.props.ioHelper.defaults.debug(`Resource investigation failed: ${e.message}`); + return []; + } } /** @@ -255,7 +313,7 @@ export class CloudFormationStackDiagnoser { type: 'problem', detectedBy, problems: [ - await this.addErrorTrace({ + await this.enhanceError({ // It's about a stack logicalId: undefined, message: changeSet.StatusReason ?? '', @@ -375,6 +433,17 @@ export class CloudFormationStackDiagnoser { } return ret; } + + /** + * Return the additional exploration SDK, if available. + */ + private async additionalExplorationSdk(): Promise { + if (!this.additionalExplorationSdkFetched) { + this.additionalExplorationSdkFetched = true; + this._additionalExplorationSdk = await this.props.additionalExplorationSdkProvider?.(); + } + return this._additionalExplorationSdk; + } } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts index c8f6fd754..cfab4f1c7 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/toolkit/toolkit.ts @@ -629,6 +629,7 @@ export class Toolkit extends CloudAssemblySourceBuilder { sourceTracer: new StackArtifactSourceTracer(stack), ioHelper, topLevelStackHierarchicalId: stack.hierarchicalId, + additionalExplorationSdkProvider: () => Promise.resolve(stackEnv.sdk), }); const diagnosis = await diagnoser.diagnoseFromFresh(stack.stackName); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap b/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap index 8fb0d999e..467b9b46b 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap +++ b/packages/@aws-cdk/toolkit-lib/test/api/aws-auth/__snapshots__/sdk-logger.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`formatting a failing SDK call looks broadly reasonable 1`] = `"[2 attempts, 30ms retry] S3.GetBucketLocation({"Bucket":"....."}) -> Error: it failed"`; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/diagnosing/__snapshots__/diagnosis-formatting.test.ts.snap b/packages/@aws-cdk/toolkit-lib/test/api/diagnosing/__snapshots__/diagnosis-formatting.test.ts.snap index eab523041..32eccc32f 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/diagnosing/__snapshots__/diagnosis-formatting.test.ts.snap +++ b/packages/@aws-cdk/toolkit-lib/test/api/diagnosing/__snapshots__/diagnosis-formatting.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`hostMessageFromDiagnosis change set failure with multiple errors for the same resource 1`] = ` "❌ Stack MyStack: