diff --git a/packages/@aws-cdk-testing/cli-integ/lib/eventually.ts b/packages/@aws-cdk-testing/cli-integ/lib/eventually.ts new file mode 100644 index 0000000000000..6936ff32f76fd --- /dev/null +++ b/packages/@aws-cdk-testing/cli-integ/lib/eventually.ts @@ -0,0 +1,42 @@ +/** + * @param maxAttempts the maximum number of attempts + * @param interval interval in milliseconds to observe between attempts + */ +export type EventuallyOptions = { + maxAttempts?: number; + interval?: number; +}; + +const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +const DEFAULT_INTERVAL = 1000; +const DEFAULT_MAX_ATTEMPTS = 10; + +/** + * Runs a function on an interval until the maximum number of attempts has + * been reached. + * + * Default interval = 1000 milliseconds + * Default maxAttempts = 10 + * + * @param fn function to run + * @param options EventuallyOptions + */ +const eventually = async (call: () => Promise, options?: EventuallyOptions): Promise => { + const opts = { + interval: options?.interval ? options.interval : DEFAULT_INTERVAL, + maxAttempts: options?.maxAttempts ? options.maxAttempts : DEFAULT_MAX_ATTEMPTS, + }; + + while (opts.maxAttempts-- >= 0) { + try { + return await call(); + } catch (err) { + if (opts.maxAttempts <= 0) throw err; + } + await wait(opts.interval); + } + + throw new Error('An unexpected error has occurred.'); +}; + +export default eventually; \ No newline at end of file diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts index 18889bde7dd7e..ab56611bf39e0 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/bootstrapping.integtest.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'yaml'; import { integTest, randomString, withoutBootstrap } from '../../lib'; +import eventually from '../../lib/eventually'; jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime @@ -283,17 +284,25 @@ integTest('can remove customPermissionsBoundary', withoutBootstrap(async (fixtur }), }); policyArn = policy.Policy?.Arn; - await fixture.cdkBootstrapModern({ - // toolkitStackName doesn't matter for this particular invocation - toolkitStackName: bootstrapStackName, - customPermissionsBoundary: policyName, - }); - const response = await fixture.aws.cloudFormation('describeStacks', { StackName: bootstrapStackName }); - expect( - response.Stacks?.[0].Parameters?.some( - param => (param.ParameterKey === 'InputPermissionsBoundary' && param.ParameterValue === policyName), - )).toEqual(true); + // Policy creation and consistency across regions is "almost immediate" + // See: https://docs.aws.amazon.com/IAM/latest/UserGuide/troubleshoot_general.html#troubleshoot_general_eventual-consistency + // We will put this in an `eventually` block to retry stack creation with a reasonable timeout + const createStackWithPermissionBoundary = async (): Promise => { + await fixture.cdkBootstrapModern({ + // toolkitStackName doesn't matter for this particular invocation + toolkitStackName: bootstrapStackName, + customPermissionsBoundary: policyName, + }); + + const response = await fixture.aws.cloudFormation('describeStacks', { StackName: bootstrapStackName }); + expect( + response.Stacks?.[0].Parameters?.some( + param => (param.ParameterKey === 'InputPermissionsBoundary' && param.ParameterValue === policyName), + )).toEqual(true); + }; + + await eventually(createStackWithPermissionBoundary, { maxAttempts: 3 }); await fixture.cdkBootstrapModern({ // toolkitStackName doesn't matter for this particular invocation