From 08be058782ec1ce80a36cff3b3dc5cde3b289bf2 Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Thu, 21 Nov 2024 15:57:46 -0500 Subject: [PATCH 1/2] Add ability to specify resource options at the stack level It has been possible to specify Pulumi resource options at the stack level, but it did not flow through to the actual resources. This PR makes sure that the inheritance works correctly. This PR also adds functionality to automatically set the Stack environment based on the App provider. Because the App creates the stacks in an async context, we can use provider functions to lookup the environment and then pass the resolved environment to the stack. This means that all Stacks have their environment provided by default. This will cut down on the number of Intrinsics used in the generated template. If the user provides a provider to the Stack we are no longer in an async context which means we can't determine the environment from the provider and fall back to an environment agnostic stack. re #61, re #219 --- README.md | 79 ++++++++++++ api-docs/README.md | 39 +++++- examples/examples_nodejs_test.go | 65 ++++++++++ examples/stack-provider/Pulumi.yaml | 3 + examples/stack-provider/index.ts | 70 ++++++++++ examples/stack-provider/package.json | 13 ++ examples/stack-provider/tsconfig.json | 18 +++ src/converters/app-converter.ts | 11 +- src/converters/artifact-converter.ts | 25 ---- src/stack.ts | 179 +++++++++++++++++++++++++- src/types.ts | 6 + tests/basic.test.ts | 61 ++++++++- tests/cdk-resource.test.ts | 2 +- tests/mocks.ts | 22 +++- tests/options.test.ts | 61 +++++++++ 15 files changed, 607 insertions(+), 47 deletions(-) create mode 100644 examples/stack-provider/Pulumi.yaml create mode 100644 examples/stack-provider/index.ts create mode 100644 examples/stack-provider/package.json create mode 100644 examples/stack-provider/tsconfig.json diff --git a/README.md b/README.md index 81a0321..e246309 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,85 @@ const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { }); ``` +### Stack Level Providers + +It is also possible to customize the Providers at the Stack level. This can be +useful in cases where you need to deploy resources to different AWS regions. + +```ts +const awsProvider = new aws.Provider('aws-provider'); +const awsCCAPIProvider = new ccapi.Provider('ccapi-provider', { + // enable autoNaming + autoNaming: { + autoTrim: true, + randomSuffixMinLength: 7, + } +}); + +const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + // inherits the provider from the app + const defaultProviderStack = new pulumicdk.Stack('default-provider-stack'); + const bucket = new s3.Bucket(defaultProviderStack, 'Bucket'); + + // use a different provider for this stack + const east2Stack = new pulumicdk.Stack('east2-stack', { + providers: [ + new aws.Provider('east2-provider', { region: 'us-east-2' }), + new ccapi.Provider('east2-ccapi-provider', { + region: 'us-east-2', + autoNaming: { + autoTrim: true, + randomSuffixMinLength: 7, + }, + }), + ], + }); + const bucket2 = new s3.Bucket(east2Stack, 'Bucket'); +}, { + providers: [ + dockerBuildProvider, + awsProvider, + awsCCAPIProvider, + ] +}); +``` + +One thing to note is that when you pass different custom providers to a Stack, +by default the Stack becomes an [environment agnostic stack](https://docs.aws.amazon.com/cdk/v2/guide/configure-env.html#configure-env-examples). +If you want to have the environment specified at the CDK Stack level, then you +also need to provide the environment to the Stack Props. + +```ts +const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + // inherits the provider from the app and has the CDK env auto populated + // based on the default provider + const defaultProviderStack = new pulumicdk.Stack('default-provider-stack'); + const bucket = new s3.Bucket(defaultProviderStack, 'Bucket'); + + // use a different provider for this stack + const east2Stack = new pulumicdk.Stack('east2-stack', { + props: { + env: { + region: 'us-east-2', + account: '12345678912', + }, + }, + providers: [ + new aws.Provider('east2-provider', { region: 'us-east-2' }), + new ccapi.Provider('east2-ccapi-provider', { + region: 'us-east-2', + autoNaming: { + autoTrim: true, + randomSuffixMinLength: 7, + }, + }), + ], + }); + const bucket2 = new s3.Bucket(east2Stack, 'Bucket'); +}); + +``` + ## CDK Lookups CDK [lookups](https://docs.aws.amazon.com/cdk/v2/guide/context.html#context_methods) are currently disabled by default. diff --git a/api-docs/README.md b/api-docs/README.md index 7816a53..0ad89de 100644 --- a/api-docs/README.md +++ b/api-docs/README.md @@ -74,14 +74,14 @@ export const bucket = app.outputs['bucket']; ###### Defined in -[stack.ts:96](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L96) +[stack.ts:105](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L105) #### Properties | Property | Modifier | Type | Default value | Description | Defined in | | ------ | ------ | ------ | ------ | ------ | ------ | -| `name` | `readonly` | `string` | `undefined` | The name of the component | [stack.ts:55](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L55) | -| `outputs` | `public` | `object` | `{}` | The collection of outputs from the AWS CDK Stack represented as Pulumi Outputs. Each CfnOutput defined in the AWS CDK Stack will populate a value in the outputs. | [stack.ts:61](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L61) | +| `name` | `readonly` | `string` | `undefined` | The name of the component | [stack.ts:57](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L57) | +| `outputs` | `public` | `object` | `{}` | The collection of outputs from the AWS CDK Stack represented as Pulumi Outputs. Each CfnOutput defined in the AWS CDK Stack will populate a value in the outputs. | [stack.ts:63](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L63) | *** @@ -121,7 +121,7 @@ Create and register an AWS CDK stack deployed with Pulumi. ###### Defined in -[stack.ts:264](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L264) +[stack.ts:330](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L330) #### Methods @@ -151,7 +151,7 @@ A Pulumi Output value. ###### Defined in -[stack.ts:277](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L277) +[stack.ts:432](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L432) ## Interfaces @@ -233,6 +233,31 @@ Options specific to the Pulumi CDK App component. Options for creating a Pulumi CDK Stack +Any Pulumi resource options provided at the Stack level will override those configured +at the App level + +#### Example + +```ts +new App('testapp', (scope: App) => { + // This stack will inherit the options from the App + new Stack(scope, 'teststack1'); + + // Override the options for this stack + new Stack(scope, 'teststack', { + providers: [ + new native.Provider('custom-provider', { region: 'us-east-1' }), + ], + props: { env: { region: 'us-east-1' } }, + }) +}, { + providers: [ + new native.Provider('app-provider', { region: 'us-west-2' }), + ] + +}) +``` + #### Extends - `ComponentResourceOptions` @@ -241,7 +266,7 @@ Options for creating a Pulumi CDK Stack | Property | Type | Description | Defined in | | ------ | ------ | ------ | ------ | -| `props?` | `StackProps` | The CDK Stack props | [stack.ts:230](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L230) | +| `props?` | `StackProps` | The CDK Stack props | [stack.ts:289](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L289) | ## Type Aliases @@ -255,7 +280,7 @@ Options for creating a Pulumi CDK Stack #### Defined in -[stack.ts:24](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L24) +[stack.ts:26](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L26) ## Functions diff --git a/examples/examples_nodejs_test.go b/examples/examples_nodejs_test.go index a465f0d..c2531c0 100644 --- a/examples/examples_nodejs_test.go +++ b/examples/examples_nodejs_test.go @@ -236,6 +236,71 @@ func TestScalableWebhook(t *testing.T) { integration.ProgramTest(t, &test) } +func TestStackProvider(t *testing.T) { + // App will use default provider and one stack will use explicit provider + // with region=us-east-1 + t.Run("With default env", func(t *testing.T) { + test := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "stack-provider"), + ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) { + east1LogsRegion := stack.Outputs["east1LogsRegion"].(string) + defaultLogsRegion := stack.Outputs["defaultLogsRegion"].(string) + east1StackRegion := stack.Outputs["east1StackRegion"].(string) + defaultStackRegion := stack.Outputs["defaultStackRegion"].(string) + assert.Equalf(t, "us-east-1", east1LogsRegion, "Expected east1LogsRegion to be us-east-1, got %s", east1LogsRegion) + assert.Equalf(t, "us-east-2", defaultLogsRegion, "Expected defaultLogsRegion to be us-east-2, got %s", defaultLogsRegion) + assert.Equalf(t, "us-east-1", east1StackRegion, "Expected east1StackRegion to be us-east-1, got %s", east1StackRegion) + assert.Equalf(t, "us-east-2", defaultStackRegion, "Expected defaultStackRegion to be us-east-2, got %s", defaultStackRegion) + }, + }) + + integration.ProgramTest(t, &test) + }) + + // App will use a custom explicit provider and one stack will use explicit provider + // with region=us-east-1 + t.Run("With different env", func(t *testing.T) { + test := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "stack-provider"), + Config: map[string]string{ + "default-region": "us-west-2", + }, + ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) { + east1LogsRegion := stack.Outputs["east1LogsRegion"].(string) + defaultLogsRegion := stack.Outputs["defaultLogsRegion"].(string) + east1StackRegion := stack.Outputs["east1StackRegion"].(string) + defaultStackRegion := stack.Outputs["defaultStackRegion"].(string) + assert.Equalf(t, "us-east-1", east1LogsRegion, "Expected east1LogsRegion to be us-east-1, got %s", east1LogsRegion) + assert.Equalf(t, "us-west-2", defaultLogsRegion, "Expected defaultLogsRegion to be us-west-2, got %s", defaultLogsRegion) + assert.Equalf(t, "us-east-1", east1StackRegion, "Expected east1StackRegion to be us-east-1, got %s", east1StackRegion) + assert.Equalf(t, "us-west-2", defaultStackRegion, "Expected defaultStackRegion to be us-west-2, got %s", defaultStackRegion) + }, + }) + + integration.ProgramTest(t, &test) + }) + + t.Run("Fails with different cdk env", func(t *testing.T) { + var output bytes.Buffer + test := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "stack-provider"), + Stderr: &output, + ExpectFailure: true, + Config: map[string]string{ + "cdk-region": "us-east-2", + }, + ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) { + assert.Contains(t, output.String(), "The stack 'teststack' has conflicting regions between the native provider (us-east-1) and the stack environment (us-east-2)") + }, + }) + + integration.ProgramTest(t, &test) + }) +} + func TestTheBigFan(t *testing.T) { test := getJSBaseOptions(t). With(integration.ProgramTestOptions{ diff --git a/examples/stack-provider/Pulumi.yaml b/examples/stack-provider/Pulumi.yaml new file mode 100644 index 0000000..924045b --- /dev/null +++ b/examples/stack-provider/Pulumi.yaml @@ -0,0 +1,3 @@ +name: pulumi-stack-provider +runtime: nodejs +description: A minimal TypeScript Pulumi program diff --git a/examples/stack-provider/index.ts b/examples/stack-provider/index.ts new file mode 100644 index 0000000..e9bf6f8 --- /dev/null +++ b/examples/stack-provider/index.ts @@ -0,0 +1,70 @@ +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as pulumi from '@pulumi/pulumi'; +import * as pulumicdk from '@pulumi/cdk'; +import * as native from '@pulumi/aws-native'; +import { RemovalPolicy } from 'aws-cdk-lib'; + +const config = new pulumi.Config(); +const cdkRegion = config.get('cdk-region'); +const cdkAccount = config.get('cdk-account'); +const defaultRegion = config.get('default-region'); + +export class StackProviderStack extends pulumicdk.Stack { + public readonly logsRegion: pulumi.Output; + constructor(app: pulumicdk.App, id: string, providers?: pulumi.ProviderResource[]) { + super(app, id, { + providers, + props: + cdkRegion || cdkAccount + ? { + env: { + region: cdkRegion, + account: cdkAccount, + }, + } + : undefined, + }); + + const group = new logs.LogGroup(this, 'group', { + retention: logs.RetentionDays.ONE_DAY, + removalPolicy: RemovalPolicy.DESTROY, + }); + + this.logsRegion = this.asOutput(group.logGroupArn).apply((arn) => arn.split(':')[3]); + } +} + +const app = new pulumicdk.App( + 'app', + (scope: pulumicdk.App) => { + const stack = new StackProviderStack(scope, 'teststack', [ + new native.Provider('ccapi-provider', { + region: 'us-east-1', // a different region from the app provider + }), + ]); + const defaultStack = new StackProviderStack(scope, 'default-stack'); + return { + east1LogsRegion: stack.logsRegion, + east1StackRegion: stack.asOutput(stack.region), + defaultLogsRegion: defaultStack.logsRegion, + defaultStackRegion: defaultStack.asOutput(defaultStack.region), + }; + }, + { + providers: defaultRegion + ? [ + new native.Provider('app-provider', { + region: defaultRegion as native.Region, // a different region from the default env + }), + ] + : undefined, + }, +); + +// You can (we check for this though) configure a different region on the provider +// that the stack uses vs the region in the CDK StackProps. This tests checks that both the +// stack region and the region the resources are deployed to are the same. +export const east1LogsRegion = app.outputs['east1LogsRegion']; +export const defaultLogsRegion = app.outputs['defaultLogsRegion']; +export const east1StackRegion = app.outputs['east1StackRegion']; +export const defaultStackRegion = app.outputs['defaultStackRegion']; diff --git a/examples/stack-provider/package.json b/examples/stack-provider/package.json new file mode 100644 index 0000000..902cb0e --- /dev/null +++ b/examples/stack-provider/package.json @@ -0,0 +1,13 @@ +{ + "name": "pulumi-aws-cdk", + "devDependencies": { + "@types/node": "^10.0.0" + }, + "dependencies": { + "@pulumi/aws": "^6.0.0", + "@pulumi/aws-native": "^1.9.0", + "@pulumi/cdk": "^0.5.0", + "aws-cdk-lib": "2.156.0", + "constructs": "10.3.0" + } +} diff --git a/examples/stack-provider/tsconfig.json b/examples/stack-provider/tsconfig.json new file mode 100644 index 0000000..2666e28 --- /dev/null +++ b/examples/stack-provider/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2016", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.ts" + ] +} \ No newline at end of file diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index f69ae39..87884a9 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -96,6 +96,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr readonly resources = new Map>(); readonly constructs = new Map(); private readonly cdkStack: cdk.Stack; + private readonly stackOptions?: pulumi.ComponentResourceOptions; private _stackResource?: CdkConstruct; private readonly graph: Graph; @@ -110,6 +111,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr constructor(host: AppComponent, readonly stack: StackManifest) { super(host); this.cdkStack = host.stacks[stack.id]; + this.stackOptions = host.stackOptions[stack.id]; this.graph = GraphBuilder.build(this.stack); } @@ -122,6 +124,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr for (const n of this.graph.nodes) { if (n.construct.id === this.stack.id) { this._stackResource = new CdkConstruct(`${this.app.name}/${n.construct.path}`, n.construct.id, { + ...this.stackOptions, parent: this.app.component, // NOTE: Currently we make the stack depend on all the assets and then all resources // have the parent as the stack. This means we deploy all assets before we deploy any resources @@ -564,15 +567,15 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr switch (target) { case 'AWS::AccountId': - return getAccountId({ parent: this.app.component }).then((r) => r.accountId); + return getAccountId({ parent: this.stackResource }).then((r) => r.accountId); case 'AWS::NoValue': return undefined; case 'AWS::Partition': - return getPartition({ parent: this.app.component }).then((p) => p.partition); + return getPartition({ parent: this.stackResource }).then((p) => p.partition); case 'AWS::Region': - return getRegion({ parent: this.app.component }).then((r) => r.region); + return getRegion({ parent: this.stackResource }).then((r) => r.region); case 'AWS::URLSuffix': - return getUrlSuffix({ parent: this.app.component }).then((r) => r.urlSuffix); + return getUrlSuffix({ parent: this.stackResource }).then((r) => r.urlSuffix); case 'AWS::NotificationARNs': case 'AWS::StackId': case 'AWS::StackName': diff --git a/src/converters/artifact-converter.ts b/src/converters/artifact-converter.ts index 1415f9e..a5578a3 100644 --- a/src/converters/artifact-converter.ts +++ b/src/converters/artifact-converter.ts @@ -1,5 +1,3 @@ -import * as cx from 'aws-cdk-lib/cx-api'; -import { getAccountId, getPartition, getRegion } from '@pulumi/aws-native'; import { AppComponent } from '../types'; /** @@ -7,27 +5,4 @@ import { AppComponent } from '../types'; */ export abstract class ArtifactConverter { constructor(protected readonly app: AppComponent) {} - - /** - * Takes a string and resolves any CDK environment placeholders (e.g. accountId, region, partition) - * - * @param s - The string that contains the placeholders to replace - * @returns The string with the placeholders fully resolved - */ - protected resolvePlaceholders(s: string): Promise { - const host = this.app; - return cx.EnvironmentPlaceholders.replaceAsync(s, { - async region(): Promise { - return getRegion({ parent: host.component }).then((r) => r.region); - }, - - async accountId(): Promise { - return getAccountId({ parent: host.component }).then((r) => r.accountId); - }, - - async partition(): Promise { - return getPartition({ parent: host.component }).then((p) => p.partition); - }, - }); - } } diff --git a/src/stack.ts b/src/stack.ts index c9c21c6..3bacf67 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -13,6 +13,7 @@ // limitations under the License. import * as cdk from 'aws-cdk-lib/core'; import * as pulumi from '@pulumi/pulumi'; +import * as aws from '@pulumi/aws'; import { AppComponent, AppOptions, AppResourceOptions } from './types'; import { AppConverter, StackConverter } from './converters/app-converter'; import { PulumiSynthesizer, PulumiSynthesizerBase } from './synthesizer'; @@ -20,6 +21,7 @@ import { AwsCdkCli, ICloudAssemblyDirectoryProducer } from '@aws-cdk/cli-lib-alp import { CdkConstruct } from './interop'; import { makeUniqueId } from './cdk-logical-id'; import * as native from '@pulumi/aws-native'; +import { warn } from '@pulumi/pulumi/log'; export type AppOutputs = { [outputId: string]: pulumi.Output }; @@ -70,6 +72,12 @@ export class App */ public readonly stacks: { [artifactId: string]: cdk.Stack } = {}; + /** + * The Pulumi ComponentResourceOptions associated with the stack + * @internal + */ + readonly stackOptions: { [artifactId: string]: pulumi.ComponentResourceOptions } = {}; + /** @internal */ public converter: Promise; @@ -91,6 +99,7 @@ export class App private readonly createFunc: (scope: App) => AppOutputs | void; private _app?: cdk.App; + private _env?: cdk.Environment; private appProps?: cdk.AppProps; constructor(id: string, createFunc: (scope: App) => void | AppOutputs, props?: AppResourceOptions) { @@ -130,6 +139,20 @@ export class App this.registerOutputs(this.outputs); } + /** + * Because the app creates CDK Stacks in an async function, we can grab the + * environment from the AWS CCAPI provider used by the App and make that available + * as the CDK Environment for the Stacks. + * + * @internal + */ + public get env(): cdk.Environment { + if (!this._env) { + throw new Error('cdk.Environment has not been created yet'); + } + return this._env; + } + /** * @internal */ @@ -137,7 +160,7 @@ export class App if (!this._app) { throw new Error('cdk.App has not been created yet'); } - return this._app!; + return this._app; } protected async initialize(props: { @@ -150,6 +173,17 @@ export class App this.appOptions = props.args; const lookupsEnabled = process.env.PULUMI_CDK_EXPERIMENTAL_LOOKUPS === 'true'; const lookups = lookupsEnabled && pulumi.runtime.isDryRun(); + const account = await native + .getAccountId({ + parent: this, + ...props.opts, + }) + .then((account) => account.accountId); + const region = await native.getRegion({ parent: this, ...props.opts }).then((region) => region.region); + this._env = { + account, + region, + }; try { // TODO: support lookups https://github.com/pulumi/pulumi-cdk/issues/184 await cli.synth({ quiet: true, lookups }); @@ -211,6 +245,9 @@ export class App app.node.children.forEach((child) => { if (Stack.isPulumiStack(child)) { this.stacks[child.artifactId] = child; + if (child.options) { + this.stackOptions[child.artifactId] = child.options; + } } }); @@ -222,6 +259,28 @@ export class App /** * Options for creating a Pulumi CDK Stack + * + * Any Pulumi resource options provided at the Stack level will override those configured + * at the App level + * + * @example + * new App('testapp', (scope: App) => { + * // This stack will inherit the options from the App + * new Stack(scope, 'teststack1'); + * + * // Override the options for this stack + * new Stack(scope, 'teststack', { + * providers: [ + * new native.Provider('custom-provider', { region: 'us-east-1' }), + * ], + * props: { env: { region: 'us-east-1' } }, + * }) + * }, { + * providers: [ + * new native.Provider('app-provider', { region: 'us-west-2' }), + * ] + * + * }) */ export interface StackOptions extends pulumi.ComponentResourceOptions { /** @@ -230,6 +289,8 @@ export interface StackOptions extends pulumi.ComponentResourceOptions { props?: cdk.StackProps; } +type Writeable = { -readonly [P in keyof T]: T[P] }; + /** * A Construct that represents an AWS CDK stack deployed with Pulumi. * @@ -255,17 +316,111 @@ export class Stack extends cdk.Stack { */ public converter: Promise; + /** + * @internal + */ + public options?: pulumi.ComponentResourceOptions; + /** * Create and register an AWS CDK stack deployed with Pulumi. * * @param name The _unique_ name of the resource. * @param options A bag of options that control this resource's behavior. */ - constructor(app: App, name: string, options?: StackOptions) { - super(app.app, name, options?.props); + constructor(private readonly app: App, name: string, options?: StackOptions) { + const env: Writeable = options?.props?.env ?? {}; + const hasNativeProvider = hasProvider(options?.providers, (p) => native.Provider.isInstance(p)); + + if (!env.account && !hasNativeProvider) { + env.account = app.env.account; + } + + // if the user has provided a separate native provider to the stack + // then we don't want to set the region from the app provider. The stack will + // be an environment agnostic (and determine the region from the provider) unless + // they provide a region to the stack props. + if (!env.region && !hasNativeProvider) { + env.region = app.env.region; + } + + super(app.app, name, { + // set the env based on the credentials of the App + // but allow the user to override it + ...options?.props, + env: env, + }); Object.defineProperty(this, STACK_SYMBOL, { value: true }); + this.options = options; this.converter = app.converter.then((converter) => converter.stacks.get(this.artifactId)!); + + this.validateEnv(); + } + + /** + * This function validates that the user has correctly configured the stack environment. There are two + * ways that the environment comes into play in a Pulumi CDK application. When resources are created + * they are created with a specific provider that is either inherited from the Stack or the App. There + * are some values though that CDK generates based on what environment is passed to the StackProps. + * + * Below is an example of something a user could configure (by mistake). + * + * @example + * new App('testapp', (scope: App) => { + * new Stack(scope, 'teststack', { + * providers: [ + * new native.Provider('native-provider', { region: 'us-east-1' }), + * ], + * props: { env: { region: 'us-east-2' }}, + * }) + * }, { + * providers: [ + * new native.Provider('native-provider', { region: 'us-west-2' }), + * ] + * + * }) + */ + private validateEnv(): void { + const providers = providersToArray(this.options?.providers); + const nativeProvider = providers.find((p) => native.Provider.isInstance(p)); + const awsProvider = providers.find((p) => aws.Provider.isInstance(p)); + + const awsRegion = aws.getRegionOutput({}, { parent: this.app, provider: awsProvider }).name; + const awsAccount = aws.getCallerIdentityOutput({}, { parent: this.app, provider: awsProvider }).accountId; + const nativeRegion = native.getRegionOutput({ parent: this.app, provider: nativeProvider }).region; + const nativeAccount = native.getAccountIdOutput({ parent: this.app, provider: nativeProvider }).accountId; + + pulumi + .all([awsRegion, awsAccount, nativeRegion, nativeAccount]) + .apply(([awsRegion, awsAccount, nativeRegion, nativeAccount]) => { + // This is to ensure that the user does not pass a different region to the provider and the stack environment. + if (!cdk.Token.isUnresolved(this.region) && nativeRegion !== this.region) { + throw new Error( + `The stack '${this.node.id}' has conflicting regions between the native provider (${nativeRegion}) and the stack environment (${this.region}).\n` + + 'Please ensure that the stack environment region matches the region of the native provider.', + ); + } + + if (!cdk.Token.isUnresolved(this.account) && this.account !== nativeAccount) { + throw new Error( + `The stack '${this.node.id}' has conflicting accounts between the native provider (${nativeAccount}) and the stack environment (${this.account}).\n` + + 'Please ensure that the stack environment account matches the account of the native provider.', + ); + } + + if (nativeAccount !== awsAccount) { + warn( + `The stack '${this.node.id}' uses different accounts for the AWS Provider (${awsAccount}) and the AWS CCAPI Provider (${nativeAccount}). This may be a misconfiguration.`, + this.app, + ); + } + if (nativeRegion !== awsRegion) { + warn( + `The stack '${this.node.id}' uses different regions for the AWS Provider (${awsRegion}) and the AWS CCAPI Provider (${nativeRegion}). This may be a misconfiguration.`, + this.app, + ); + } + }); } /** @@ -336,6 +491,22 @@ function generateAppId(): string { .slice(-17); } +function hasProvider( + providers: pulumi.ProviderResource[] | Record | undefined, + compareFn: (resource: pulumi.ProviderResource) => boolean, +): boolean { + if (!providers) { + return false; + } + return providersToArray(providers).some(compareFn); +} + +function providersToArray( + providers: pulumi.ProviderResource[] | Record | undefined, +): pulumi.ProviderResource[] { + return providers && !Array.isArray(providers) ? Object.values(providers) : providers ?? []; +} + /** * If the user has not provided the aws-native provider, we will create one by default in order * to enable the autoNaming feature. @@ -347,7 +518,7 @@ function createDefaultNativeProvider( // will throw an error const region = native.config.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION!; - const newProviders = providers && !Array.isArray(providers) ? Object.values(providers) : providers ?? []; + const newProviders = providersToArray(providers); if (!newProviders.find((p) => native.Provider.isInstance(p))) { newProviders.push( new native.Provider('cdk-aws-native', { diff --git a/src/types.ts b/src/types.ts index 977c006..acb79dd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,6 +83,12 @@ export interface AppComponent { */ readonly stacks: { [artifactId: string]: Stack }; + /** + * The Pulumi ComponentResourceOptions associated with the stack + * @internal + */ + readonly stackOptions: { [artifactId: string]: pulumi.ComponentResourceOptions }; + /** * The underlying ComponentResource * @internal diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 23e57c4..c4290ae 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -16,7 +16,8 @@ import * as aws from '@pulumi/aws'; import * as native from '@pulumi/aws-native'; import * as s3 from 'aws-cdk-lib/aws-s3'; import * as output from '../src/output'; -import { setMocks, testApp } from './mocks'; +import { Stack, App, AppOutputs } from '../src/stack'; +import { setMocks, testApp, awaitApp, promiseOf } from './mocks'; import { ApplicationLoadBalancer } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import { Vpc } from 'aws-cdk-lib/aws-ec2'; import { aws_ssm } from 'aws-cdk-lib'; @@ -214,3 +215,61 @@ describe('Basic tests', () => { ); }); }); + +describe('Stack environment validation', () => { + beforeEach(() => { + setMocks(); + }); + test('app default env', async () => { + const app = new App('testapp', (scope: App): AppOutputs => { + const stack = new Stack(scope, 'teststack'); + new s3.CfnBucket(stack, 'bucket'); + return { + region: stack.asOutput(stack.region), + }; + }); + await awaitApp(app); + const region = await promiseOf(app.outputs['region']); + expect(region).toEqual('us-east-2'); + }); + + test('app custom env', async () => { + const app = new App( + 'testapp', + (scope: App): AppOutputs => { + const stack = new Stack(scope, 'teststack'); + new s3.CfnBucket(stack, 'bucket'); + return { + region: stack.asOutput(stack.region), + }; + }, + { + providers: [new native.Provider('custom-region_us-west-2', { region: native.Region.UsWest2 })], + }, + ); + await awaitApp(app); + const region = await promiseOf(app.outputs['region']); + expect(region).toContain('us-west-2'); + }); + + test('app and custom stack env', async () => { + const app = new App( + 'testapp', + (scope: App): AppOutputs => { + const stack = new Stack(scope, 'teststack', { + providers: [new native.Provider('custom-region_us-west-1', { region: native.Region.UsWest1 })], + }); + new s3.CfnBucket(stack, 'bucket'); + return { + region: stack.asOutput(stack.region), + }; + }, + { + providers: [new native.Provider('custom-region_us-west-2', { region: native.Region.UsWest2 })], + }, + ); + await awaitApp(app); + const region = await promiseOf(app.outputs['region']); + expect(region).toContain('us-west-1'); + }); +}); diff --git a/tests/cdk-resource.test.ts b/tests/cdk-resource.test.ts index c26ec91..602298a 100644 --- a/tests/cdk-resource.test.ts +++ b/tests/cdk-resource.test.ts @@ -137,7 +137,7 @@ describe('CDK Construct tests', () => { { Action: 'events:PutEvents', Effect: 'Allow', - Principal: { AWS: 'arn:aws:iam::12345678912:root' }, + Principal: { AWS: 'arn:aws:iam::12345678910:root' }, Resource: 'testbus_arn', Sid: 'cdk-testsid', }, diff --git a/tests/mocks.ts b/tests/mocks.ts index c97a21d..eb590d7 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -23,6 +23,7 @@ export class MockAppComponent extends pulumi.ComponentResource implements AppCom public readonly name = 'stack'; public readonly assemblyDir: string; stacks: { [artifactId: string]: CdkStack } = {}; + stackOptions: { [artifactId: string]: pulumi.ComponentResourceOptions } = {}; dependencies: CdkConstruct[] = []; component: pulumi.ComponentResource; @@ -35,15 +36,17 @@ export class MockAppComponent extends pulumi.ComponentResource implements AppCom } } -export async function testApp(fn: (scope: Construct) => void, options?: pulumi.ComponentResourceOptions) { +export async function testApp( + fn: (scope: Construct) => void, + options?: pulumi.ComponentResourceOptions, + withEnv?: boolean, +) { + const env = withEnv ? { account: '12345678912', region: 'us-east-1' } : undefined; class TestStack extends Stack { constructor(app: App, id: string) { super(app, id, { props: { - env: { - region: 'us-east-1', - account: '12345678912', - }, + env, }, }); @@ -64,6 +67,10 @@ export async function testApp(fn: (scope: Construct) => void, options?: pulumi.C ...options, }, ); + await awaitApp(app); +} + +export async function awaitApp(app: App): Promise { const converter = await app.converter; await Promise.all( Array.from(converter.stacks.values()).flatMap((stackConverter) => { @@ -83,6 +90,11 @@ export function setMocks(resources?: MockResourceArgs[]) { accountId: '12345678910', }; case 'aws-native:index:getRegion': + if (args.provider?.includes('custom-region')) { + return { + region: args.provider, + }; + } return { region: 'us-east-2', }; diff --git a/tests/options.test.ts b/tests/options.test.ts index 068b67d..ee1a11f 100644 --- a/tests/options.test.ts +++ b/tests/options.test.ts @@ -1,3 +1,4 @@ +import * as native from '@pulumi/aws-native'; import * as pulumi from '@pulumi/pulumi'; import { StackManifest } from '../src/assembly'; import { StackConverter } from '../src/converters/app-converter'; @@ -254,4 +255,64 @@ describe('options', () => { }), ); }); + + test('provider can be set at stack level', async () => { + const manifest = new StackManifest({ + id: 'stack', + templatePath: 'test/stack', + metadata: { + 'stack/bucket': 'bucket', + }, + tree: { + path: 'stack', + id: 'stack', + children: { + bucket: { + id: 'bucket', + path: 'stack/bucket', + attributes: { + 'aws:cdk:cloudformation:type': 'AWS::S3::Bucket', + }, + }, + }, + constructInfo: { + fqn: 'aws-cdk-lib.Stack', + version: '2.149.0', + }, + }, + template: { + Resources: { + bucket: { + Type: 'AWS::S3::Bucket', + Properties: {}, + }, + }, + }, + dependencies: [], + }); + const appComponent = new MockAppComponent('/tmp/foo/bar/does/not/exist'); + + appComponent.stackOptions['stack'] = { + providers: [ + new native.Provider('test-native', { + region: 'us-west-2', + }), + ], + }; + const converter = new StackConverter(appComponent, manifest); + converter.convert(new Set()); + expect(pulumi.CustomResource).toHaveBeenCalledWith( + 'aws-native:s3:Bucket', + 'bucket', + expect.anything(), + expect.objectContaining({ + parent: expect.objectContaining({ + __name: 'stack/stack', + __providers: expect.objectContaining({ + 'aws-native': expect.objectContaining({ __name: 'test-native' }), + }), + }), + }), + ); + }); }); From 4d93a76ac0345e51e693bf9ffbdde900d9e7667e Mon Sep 17 00:00:00 2001 From: corymhall <43035978+corymhall@users.noreply.github.com> Date: Fri, 22 Nov 2024 13:57:55 -0500 Subject: [PATCH 2/2] updates --- api-docs/README.md | 37 ++++++++++++++++++++++++++++--- examples/lookups-enabled/index.ts | 5 +---- src/stack.ts | 36 +++++++++--------------------- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/api-docs/README.md b/api-docs/README.md index 0ad89de..aabade5 100644 --- a/api-docs/README.md +++ b/api-docs/README.md @@ -83,6 +83,37 @@ export const bucket = app.outputs['bucket']; | `name` | `readonly` | `string` | `undefined` | The name of the component | [stack.ts:57](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L57) | | `outputs` | `public` | `object` | `{}` | The collection of outputs from the AWS CDK Stack represented as Pulumi Outputs. Each CfnOutput defined in the AWS CDK Stack will populate a value in the outputs. | [stack.ts:63](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L63) | +#### Accessors + +##### env + +###### Get Signature + +> **get** **env**(): `Environment` + +This can be used to get the CDK Environment based on the Pulumi Provider used for the App. +You can then use this to configure an explicit environment on Stacks. + +###### Example + +```ts +const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + const stack = new pulumicdk.Stack(scope, 'pulumi-stack', { + props: { env: app.env }, + }); +}); +``` + +###### Returns + +`Environment` + +the CDK Environment configured for the App + +###### Defined in + +[stack.ts:155](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L155) + *** ### Stack @@ -121,7 +152,7 @@ Create and register an AWS CDK stack deployed with Pulumi. ###### Defined in -[stack.ts:330](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L330) +[stack.ts:336](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L336) #### Methods @@ -151,7 +182,7 @@ A Pulumi Output value. ###### Defined in -[stack.ts:432](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L432) +[stack.ts:418](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L418) ## Interfaces @@ -266,7 +297,7 @@ new App('testapp', (scope: App) => { | Property | Type | Description | Defined in | | ------ | ------ | ------ | ------ | -| `props?` | `StackProps` | The CDK Stack props | [stack.ts:289](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L289) | +| `props?` | `StackProps` | The CDK Stack props | [stack.ts:295](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L295) | ## Type Aliases diff --git a/examples/lookups-enabled/index.ts b/examples/lookups-enabled/index.ts index d4fffea..1a9c402 100644 --- a/examples/lookups-enabled/index.ts +++ b/examples/lookups-enabled/index.ts @@ -1,4 +1,3 @@ -import * as aws from '@pulumi/aws'; import * as pulumi from '@pulumi/pulumi'; import * as pulumicdk from '@pulumi/cdk'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; @@ -12,14 +11,12 @@ import { const config = new pulumi.Config(); const zoneName = config.require('zoneName'); -const accountId = config.require('accountId'); -const region = aws.config.requireRegion(); export class Ec2CdkStack extends pulumicdk.Stack { constructor(app: pulumicdk.App, id: string) { super(app, id, { props: { - env: { region, account: accountId }, + env: app.env, }, }); diff --git a/src/stack.ts b/src/stack.ts index 3bacf67..ad1a1ac 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -140,11 +140,17 @@ export class App } /** - * Because the app creates CDK Stacks in an async function, we can grab the - * environment from the AWS CCAPI provider used by the App and make that available - * as the CDK Environment for the Stacks. + * This can be used to get the CDK Environment based on the Pulumi Provider used for the App. + * You can then use this to configure an explicit environment on Stacks. * - * @internal + * @example + * const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + * const stack = new pulumicdk.Stack(scope, 'pulumi-stack', { + * props: { env: app.env }, + * }); + * }); + * + * @returns the CDK Environment configured for the App */ public get env(): cdk.Environment { if (!this._env) { @@ -328,27 +334,7 @@ export class Stack extends cdk.Stack { * @param options A bag of options that control this resource's behavior. */ constructor(private readonly app: App, name: string, options?: StackOptions) { - const env: Writeable = options?.props?.env ?? {}; - const hasNativeProvider = hasProvider(options?.providers, (p) => native.Provider.isInstance(p)); - - if (!env.account && !hasNativeProvider) { - env.account = app.env.account; - } - - // if the user has provided a separate native provider to the stack - // then we don't want to set the region from the app provider. The stack will - // be an environment agnostic (and determine the region from the provider) unless - // they provide a region to the stack props. - if (!env.region && !hasNativeProvider) { - env.region = app.env.region; - } - - super(app.app, name, { - // set the env based on the credentials of the App - // but allow the user to override it - ...options?.props, - env: env, - }); + super(app.app, name, options?.props); Object.defineProperty(this, STACK_SYMBOL, { value: true }); this.options = options;