diff --git a/README.md b/README.md index 81a03214..81be50de 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,10 @@ combination with CDK `fromXXX` methods. **Example** ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as aws from '@pulumi/aws'; +import * as aws_route53 from 'aws-cdk-lib/aws-route53'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { const stack = new pulumicdk.Stack('example-stack'); @@ -183,6 +187,9 @@ outputs. You can do this in one of two ways. Any `CfnOutput` that you create automatically gets added to the `App outputs`. ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as s3 from 'aws-cdk-lib/aws-s3'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { const stack = new pulumicdk.Stack('example-stack'); const bucket = new s3.Bucket(stack, 'Bucket'); @@ -196,6 +203,9 @@ export const bucketName = app.outputs['BucketName']; **AppOutputs** ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as s3 from 'aws-cdk-lib/aws-s3'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { const stack = new pulumicdk.Stack('example-stack'); const bucket = new s3.Bucket(stack, 'Bucket'); @@ -248,6 +258,93 @@ 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 +import * as aws from '@pulumi/aws'; +import * as ccapi from '@pulumi/aws-native'; +import * as pulumicdk from '@pulumi/cdk'; + +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, as follows: + +```ts +import * as aws from '@pulumi/aws'; +import * as ccapi from '@pulumi/aws-native'; +import * as pulumicdk from '@pulumi/cdk'; + +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. @@ -260,6 +357,9 @@ Instead of using CDK Lookups you can use Pulumi functions along with CDK **Example** ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as aws from '@pulumi/aws'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { const stack = new pulumicdk.Stack('example-stack'); // use getAmiOutput to lookup the AMI instead of ec2.LookupMachineImage @@ -295,6 +395,9 @@ Pulumi twice (the first execution will fail). **Example** ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as aws_route53 from 'aws-cdk-lib/aws-route53'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { const stack = new pulumicdk.Stack('example-stack'); const hostedZone = aws_route53.HostedZone.fromLookup(this, 'hosted-zone', { @@ -335,6 +438,7 @@ of CDK. Below is an example output using Pulumi's [Compliance Ready Policies](https://www.pulumi.com/docs/iac/packages-and-automation/crossguard/compliance-ready-policies/) ```ts +import * as pulumicdk from '@pulumi/cdk'; import * as s3 from 'aws-cdk-lib/aws-s3'; const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { @@ -358,6 +462,7 @@ Policies: Pulumi CDK supports CDK Aspects, including aspects like [cdk-nag](https://github.com/cdklabs/cdk-nag) ```ts +import * as pulumicdk from '@pulumi/cdk'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { AwsSolutionsChecks } from 'cdk-nag'; @@ -380,6 +485,7 @@ const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutput Pulumi CDK also supports [CDK Policy Validation Plugins](https://docs.aws.amazon.com/cdk/v2/guide/policy-validation-synthesis.html). ```ts +import * as pulumicdk from '@pulumi/cdk'; import { CfnGuardValidator } from '@cdklabs/cdk-validator-cfnguard'; import * as s3 from 'aws-cdk-lib/aws-s3'; @@ -450,6 +556,9 @@ a reference. ### Simple mapping ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as aws from '@pulumi/aws'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { const stack = new pulumicdk.Stack('example-stack'); }, { @@ -479,6 +588,9 @@ resources. In these cases you should return the `logicalId` of the resource along with the resource itself. ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as aws from '@pulumi/aws'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { const stack = new pulumicdk.Stack('example-stack'); }, { @@ -516,6 +628,12 @@ you create the asset. This is because Pulumi CDK will automatically create a ECR Repository per image asset. ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as aws from '@pulumi/aws'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as ecs from 'aws-cdk-lib/aws-ecs'; +import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { const stack = new pulumicdk.Stack('example-stack'); @@ -548,6 +666,8 @@ For example, if you wanted to set `protect` on database resources you could use a transform like this. ```ts +import * as pulumicdk from '@pulumi/cdk'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { const stack = new pulumicdk.Stack('example-stack'); }, { @@ -577,13 +697,16 @@ In order to customize the settings, you can pass in a `PulumiSynthesizer` that you create. ```ts +import * as pulumicdk from '@pulumi/cdk'; +import * as s3 from 'aws-cdk-lib/aws-s3'; + const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { const stack = new pulumicdk.Stack('example-stack'); const bucket = new s3.Bucket(stack, 'Bucket'); }, { appOptions: { props: { - defaultStackSynthesizer: new PulumiSynthesizer({ + defaultStackSynthesizer: new pulumicdk.PulumiSynthesizer({ appId: `cdk-${pulumi.getStack()}`, autoDeleteStagingAssets: false, }) @@ -624,7 +747,10 @@ default. If you _are_ configuring your own `aws-native` provider then you will have to enable this. ```ts -const nativeProvider = new aws_native.Provider('cdk-native-provider', { +import * as pulumicdk from '@pulumi/cdk'; +import * as ccapi from '@pulumi/aws-native'; + +const nativeProvider = new ccapi.Provider('cdk-native-provider', { region: 'us-east-2', autoNaming: { autoTrim: true, diff --git a/api-docs/README.md b/api-docs/README.md index 7816a53f..36af97fb 100644 --- a/api-docs/README.md +++ b/api-docs/README.md @@ -74,14 +74,45 @@ 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) | + +#### 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) *** @@ -121,7 +152,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: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:277](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L277) +[stack.ts:418](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L418) ## Interfaces @@ -233,6 +264,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 +297,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:297](https://github.com/pulumi/pulumi-cdk/blob/main/src/stack.ts#L297) | ## Type Aliases @@ -255,7 +311,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 a465f0d2..c2531c07 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/lookups-enabled/index.ts b/examples/lookups-enabled/index.ts index d4fffeac..1a9c4028 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/examples/stack-provider/Pulumi.yaml b/examples/stack-provider/Pulumi.yaml new file mode 100644 index 00000000..924045bd --- /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 00000000..e9bf6f84 --- /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 00000000..902cb0ea --- /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 00000000..2666e28e --- /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 30125beb..f774a663 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -99,6 +99,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; @@ -113,6 +114,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); } @@ -125,6 +127,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 @@ -664,18 +667,18 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr } getAccountId(): intrinsics.Result { - return getAccountId({ parent: this.app.component }).then((r) => r.accountId); + return getAccountId({ parent: this.stackResource }).then((r) => r.accountId); } getRegion(): intrinsics.Result { - return getRegion({ parent: this.app.component }).then((r) => r.region); + return getRegion({ parent: this.stackResource }).then((r) => r.region); } getPartition(): intrinsics.Result { - return getPartition({ parent: this.app.component }).then((p) => p.partition); + return getPartition({ parent: this.stackResource }).then((p) => p.partition); } getURLSuffix(): intrinsics.Result { - return getUrlSuffix({ parent: this.app.component }).then((r) => r.urlSuffix); + return getUrlSuffix({ parent: this.stackResource }).then((r) => r.urlSuffix); } } diff --git a/src/converters/artifact-converter.ts b/src/converters/artifact-converter.ts index 1415f9e9..a5578a37 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 c9c21c6e..a4ade107 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,26 @@ export class App this.registerOutputs(this.outputs); } + /** + * 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 + * 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) { + throw new Error('cdk.Environment has not been created yet'); + } + return this._env; + } + /** * @internal */ @@ -137,7 +166,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 +179,19 @@ export class App this.appOptions = props.args; const lookupsEnabled = process.env.PULUMI_CDK_EXPERIMENTAL_LOOKUPS === 'true'; const lookups = lookupsEnabled && pulumi.runtime.isDryRun(); + const [account, region] = await Promise.all([ + native + .getAccountId({ + parent: this, + ...props.opts, + }) + .then((account) => account.accountId), + 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 +253,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 +267,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 { /** @@ -255,17 +322,91 @@ 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) { + constructor(private readonly app: App, name: string, options?: StackOptions) { super(app.app, name, options?.props); 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 +477,12 @@ function generateAppId(): string { .slice(-17); } +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 +494,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 977c0066..acb79ddb 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 23e57c4a..c4290aef 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 c26ec912..602298ae 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 c97a21dd..eb590d73 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 068b67df..ee1a11fe 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' }), + }), + }), + }), + ); + }); });