diff --git a/.github/workflows/run-acceptance-tests.yml b/.github/workflows/run-acceptance-tests.yml index c6d2bb71..5ddd5ad9 100644 --- a/.github/workflows/run-acceptance-tests.yml +++ b/.github/workflows/run-acceptance-tests.yml @@ -45,7 +45,9 @@ jobs: name: acceptance-test concurrency: group: acceptance-test-${{ matrix.index }} # TODO: concurrent tests across PRs can cause problems - cancel-in-progress: false + # TODO[pulumi/pulumi-cdk#152]: means that some resources in tests will have a static name + # which if the test does not complete, will not be cleaned up causing the next run to fail with resource already exists + cancel-in-progress: false runs-on: ubuntu-latest steps: - name: Checkout Repo diff --git a/.gitignore b/.gitignore index 17be668a..233eb5e6 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,4 @@ test-reports/ lib/ **/cdk.out +**/cdk.context.json diff --git a/README.md b/README.md index c2af643e..3502fbdd 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,22 @@ # Pulumi CDK Adapter (preview) -The Pulumi CDK Adapter is a library that enables [Pulumi](https://github.com/pulumi/pulumi) programs to use [AWS CDK](https://github.com/aws/aws-cdk) constructs. +The Pulumi CDK Adapter is a library that enables +[Pulumi](https://github.com/pulumi/pulumi) programs to use [AWS +CDK](https://github.com/aws/aws-cdk) constructs. -The adapter allows writing AWS CDK code as part of an AWS CDK Stack inside a Pulumi program, and having the resulting AWS resources be deployed and managed via Pulumi. Outputs of resources defined in a Pulumi program can be passed into AWS CDK Constructs, and outputs from AWS CDK stacks can be used as inputs to other Pulumi resources. +The adapter allows writing AWS CDK code as part of an AWS CDK Stack inside a +Pulumi program, and having the resulting AWS resources be deployed and managed +via Pulumi. Outputs of resources defined in a Pulumi program can be passed +into AWS CDK Constructs, and outputs from AWS CDK stacks can be used as inputs +to other Pulumi resources. -> Note: Currently, the Pulumi CDK Adapter preview is available only for TypeScript/JavaScript users. +> Note: Currently, the Pulumi CDK Adapter preview is available only for +> TypeScript/JavaScript users. -For example, to construct an [AWS AppRunner `Service` resource](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-apprunner-alpha-readme.html) from within a Pulumi program, and export the resulting service's URL as as Pulumi Stack Output you write the following: +For example, to construct an [AWS AppRunner `Service` +resource](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-apprunner-alpha-readme.html) +from within a Pulumi program, and export the resulting service's URL as as +Pulumi Stack Output you write the following: ```ts import * as pulumi from '@pulumi/pulumi'; @@ -27,18 +37,21 @@ class AppRunnerStack extends pulumicdk.Stack { }); this.url = this.asOutput(service.serviceUrl); - - this.synth(); } } -const stack = new AppRunnerStack('teststack'); -export const url = stack.url; +const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new AppRunnerStack('teststack'); + return { + url: stack.url, + }; +}); +export const url = app.outputs['url']; ``` And then deploy with `pulumi update`: -``` +```console > pulumi up Updating (dev) @@ -60,7 +73,7 @@ Resources: And curl the endpoint: -``` +```console > curl https://$(pulumi stack output url) ______ __ __ __ _ __ @@ -81,99 +94,135 @@ Try the workshop at https://apprunnerworkshop.com Read the docs at https://docs.aws.amazon.com/apprunner ``` -## Getting Started +## Bootstrapping + +CDK has the concept of [bootstrapping](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html) +which requires you to first bootstrap your account with certain AWS resources +that CDK needs to exist. With Pulumi CDK this is not required! Pulumi CDK will +automatically and dynamically create the bootstrap resources as needed. -### Bootstrapping +### S3 Resources -AWS CDK requires that your AWS account and target region are "bootstrapped" for use with CDK, so in order to use CDK on Pulumi, you'll need to do that first. We recommend using the latest bootstrap template (v2, as of this writing). [See the AWS documentation](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html) for details. (Note that resources deployed with Pulumi on CDK are deployed and managed with Pulumi, not CloudFormation; the bootstrapping step is required by CDK for additional runtime support.) +When any file assets are added to your application, CDK will automatically +create the following staging resources. + +1. [aws.s3.BucketV2](https://www.pulumi.com/registry/packages/aws/api-docs/s3/bucketv2/) + - `forceDestroy`: true +2. [aws.s3.BucketServerSideEncryptionConfigurationV2](https://www.pulumi.com/registry/packages/aws/api-docs/s3/bucketserversideencryptionconfigurationv2/) + - `AES256` +3. [aws.s3.BucketVersioningV2](https://www.pulumi.com/registry/packages/aws/api-docs/s3/bucketversioningv2/) + - `Enabled` +4. [aws.s3.BucketLifecycleConfigurationV2](https://www.pulumi.com/registry/packages/aws/api-docs/s3/bucketlifecycleconfigurationv2/) + - Expire old versions > 365 days + - Expire deploy-time assets > 30 days +5. [aws.s3.BucketPolicy](https://www.pulumi.com/registry/packages/aws/api-docs/s3/bucketpolicy/) + - Require SSL ## API -### `Stack` +### `App` -A Construct that represents an AWS CDK stack deployed with Pulumi. In order to deploy a CDK stack with Pulumi, it must derive from this class. The `synth` method must be called after all CDK resources have been defined in order to deploy the stack (usually, this is done as the last line of the subclass's constructor). +A Pulumi CDK App component. This is the entrypoint to your Pulumi CDK application. +In order to deploy a CDK application with Pulumi, you must start with this class. #### `constructor` -Create and register an AWS CDK stack deployed with Pulumi. +Create and register an AWS CDK app deployed with Pulumi. ```ts -constructor(name: string, options?: StackOptions) +constructor(id: string, createFunc: (scope: App) => void | AppOutputs, props?: AppResourceOptions) ``` Parameters: -* `name`: The _unique_ name of the resource. -* `options`: A bag of options that control this resource's behavior. - -### `urn` - -The URN of the underlying Pulumi component. +* `id`: The _unique_ name of the app +* `createFunc`: A callback function where all CDK Stacks must be created +* `options`: A bag of options that control the resource's behavior #### `outputs` -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. +The collection of outputs from the Pulumi CDK App represented as Pulumi Outputs. +Each `CfnOutput` defined in each AWS CDK Stack will populate a value in the +outputs. ```ts outputs: { [outputId: string]: pulumi.Output } ``` -#### `synth` - -Finalize the stack and deploy its resources. - -```ts -protected synth() -``` +#### `AppResourceOptions` -#### `asOutput` - -Convert a CDK value to a Pulumi output. - -Parameters: -* `v`: A CDK value. - -```ts -asOutput(v: T): pulumi.Output -``` - -### `StackOptions` - -Options specific to the Stack component. +Options specific to the App Component ```ts -interface StackOptions +interface AppResourceOptions ``` #### `remapCloudControlResource` -This optional method can be implemented to define a mapping to override and/or provide an implementation for a CloudFormation resource type that is not (yet) implemented in the AWS Cloud Control API (and thus not yet available in the Pulumi AWS Native provider). Pulumi code can override this method to provide a custom mapping of CloudFormation elements and their properties into Pulumi CustomResources, commonly by using the AWS Classic provider to implement the missing resource. +This optional method can be implemented to define a mapping to override and/or +provide an implementation for a CloudFormation resource type that is not (yet) +implemented in the AWS Cloud Control API (and thus not yet available in the +Pulumi AWS Native provider). Pulumi code can override this method to provide a +custom mapping of CloudFormation elements and their properties into Pulumi +CustomResources, commonly by using the AWS Classic provider to implement the +missing resource. ```ts -remapCloudControlResource(element: CfnElement, logicalId: string, typeName: string, props: any, options: pulumi.ResourceOptions): { [key: string]: pulumi.CustomResource } | undefined +remapCloudControlResource(logicalId: string, typeName: string, props: any, options: pulumi.ResourceOptions): { [key: string]: pulumi.CustomResource } | undefined ``` Parameters: -* `element`: The full CloudFormation element object being mapped. * `logicalId`: The logical ID of the resource being mapped. * `typeName`: The CloudFormation type name of the resource being mapped. * `props`: The bag of input properties to the CloudFormation resource being mapped. * `options`: The set of Pulumi ResourceOptions to apply to the resource being mapped. -Returns an object containing one or more logical IDs mapped to Pulumi resources that must be created to implement the mapped CloudFormation resource, or else undefined if no mapping is implemented. +Returns an object containing one or more logical IDs mapped to Pulumi resources +that must be created to implement the mapped CloudFormation resource, or else +undefined if no mapping is implemented. + +#### `appId` + +This is a unique identifier for the application. It will be used in the names of the +staging resources created for the application. This `appId` should be unique across apps. + +### `Stack` + +A Construct that represents an AWS CDK stack deployed with Pulumi. In order to +deploy a CDK stack with Pulumi, it must derive from this class. -#### `create` +#### `constructor` Create and register an AWS CDK stack deployed with Pulumi. ```ts -create(name: string, ctor: typeof Stack, opts?: pulumi.CustomResourceOptions): StackComponent +constructor(app: App, name: string, options?: StackOptions) ``` Parameters: +* `app`: The Pulumi CDK App * `name`: The _unique_ name of the resource. -* `stack`: The CDK Stack subclass to create. -* `parent`: The Pulumi CDKStackComponent parent resource. -* `opts`: A bag of options that control this resource's behavior. +* `options`: A bag of options that control this resource's behavior. + + +#### `asOutput` + +Convert a CDK value to a Pulumi output. + +Parameters: +* `v`: A CDK value. + +```ts +asOutput(v: T): pulumi.Output +``` + +### `StackOptions` + +Options specific to the Stack component. + +```ts +interface StackOptions +``` + ### `asString` diff --git a/adr/assets/cdk_synth.png b/adr/assets/cdk_synth.png new file mode 100644 index 00000000..f8b29ea6 Binary files /dev/null and b/adr/assets/cdk_synth.png differ diff --git a/adr/cdk-cli-lib.md b/adr/cdk-cli-lib.md new file mode 100644 index 00000000..7d0cb9ee --- /dev/null +++ b/adr/cdk-cli-lib.md @@ -0,0 +1,414 @@ +# CDK CLI Library + +Updating to use the +[cdk-cli-lib](https://docs.aws.amazon.com/cdk/api/v2/docs/cli-lib-alpha-readme.html#cloud-assembly-directory-producer) +in order to support CDK Context requires us to make some user-facing API +changes. + +## Context + +AWS CDK is split between the “framework” and the “cli”. In order to have all of +the features of CDK you need to support both. An example of this is how +[context](https://docs.aws.amazon.com/cdk/v2/guide/context.html) and lookups +work in CDK. When `cdk synth` is called via the CLI and there are lookups to be +performed it will roughly follow the following steps: + +1. CLI executes the framework synth (i.e. `node bin/app.js`) +2. Lookup method called in the “framework” (e.g. `Vpc.fromLookup()`) +3. The “framework” looks up the value in the `cdk.context.json` file. + 1. If the value does not exist then it registers the context as “missing” in + the Cloud Assembly. + 2. If it does exist then it uses that value and is done +4. Once the framework is done with `synth`, the CLI reads the Cloud Assembly + and looks for missing context. + 1. If there are no missing context then the CLI is done and it exits. + 2. If there is missing context then it continues. +5. For each missing context it executes the corresponding context lookup + function in the CLI to perform AWS SDK calls and gather the data +6. The data is stored in `cdk.context.json` +7. The CLI executes framework synth again + +![][./assets/cdk_synth.png] + + +### Currently supported CDK Context Providers + +CDK does not support very many resource lookups. This is the current list and +I’ve included whether it should be possible to use a Pulumi lookup instead. + +- AMIs (Yes) +- Availability Zones (Kind of. It’s not possible for the defining VPC AZs) +- Route53 Hosted Zones (Yes) +- KMS Keys (Yes) +- SSM Parameters +- Security Groups (Yes, mostly) +- Endpoint Service Availability Zones (Yes) +- VPCs (No) +- Load Balancers (No, requires VPC) + + +## Constraints + +1. We can use the + [cdk-cli-lib](https://docs.aws.amazon.com/cdk/api/v2/docs/cli-lib-alpha-readme.html#cloud-assembly-directory-producer) + to handle gathering context and handle the multiple passes of executing the + framework, but we have to create the `cdk.App` within an async method. + +```javascript +class MyProducer implements ICloudAssemblyDirectoryProducer { + async produce(context: Record) { + const app = new cdk.App({ context }); + const stack = new cdk.Stack(app); + return app.synth().directory; + } +} + +const cli = AwsCdkCli.fromCloudAssemblyDirectoryProducer(new MyProducer()); +await cli.synth(); +``` + +This library requires us to create the `cdk.App` within the `produce` method of +`ICloudAssemblyDirectoryProducer`. This is because the `App` and all constructs +within it must be constructed with the full `context` value. It is not possible +to add context after a construct has been constructed. + +2. Because the constructs can be called multiple times, any Pulumi resources + which are created inside a construct class will also be constructed multiple + times. This means we need a way of knowing when the final call happens and + it is safe to construct the Pulumi resources. + 1. One thing to note: CDK lookups are infrequently performed. Typically they + are performed once when they are added and then they are cached in the + `cdk.context.json` file. This means that the multiple pass execution will + happen infrequently. + + +## Decision + +### App with callback argument + +We will introduce a new `App` class which accepts a callback function as the +second argument. The user would have to create resources within this call back +function. The API that looks something like this for the end user. + +This would allow us to create the `App` inside a Pulumi `ComponentResource` and +then pass the created `App` to the `(scope: pulumicdk.App) => {}` function. + +```javascript +const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + new Ec2CdkStack(scope, 'teststack'); +}); + +// with app outputs +const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new Ec2CdkStack(scope, 'teststack'); + return { + instanceId: stack.instanceId, + }; +}); +export const instanceId = app.outputs['instanceId']; +``` + +### User retry + +In order to handle #2 above where resources will be called multiple times, we +will require the user to run the Pulumi command two times. Context +lookups are different from Pulumi data calls since they are meant to be called +one time when they are initially added and then from then on the data is cached +in `cdk.context.json` and the lookup doesn’t need to be performed again. The +user experience would look something like this: + +1. User adds a lookup `Vpc.fromLookup()` +2. `pulumi up` +3. Lookups are performed, stored in `cdk.context.json` and an error is thrown. +4. `pulumi up` succeeds from then on. + +While this option is not ideal, it will offer the best compromise which still +allows users to perform all CDK lookups. We will call it out in documentation +and recommend users to use Pulumi native lookups for most things and only fall +back to CDK lookups for `VPC` and `Load Balancers`. + +## Alternatives + +### Mock resource calls + +In order to handle #2 above where resources will be called multiple times, we +could handle it by somehow mocking the resource calls until we are done. I’m +not sure if this is even possible and would probably require some new features +in core. At a very high level it could look something like this. + +```javascript +export class App extends pulumi.ComponentResource { + async initialize() { + const cli = AwsCdkCli.fromCloudAssemblyDirectoryProducer(this); + // set mocks before we synth + pulumi.runtime.setMocks(); + // multiple passes will occur within this. Once we are done + // it will proceed past this. + await cli.synth(); + // restore + pulumi.runtime.resetMocks(); + // create resources 1 last time with all context available. + await cli.synth(); + } +} +``` + +### Disable context lookups (but still support static context) + +In this case we would simply disable context lookups. If the application needed +to perform lookups it would throw an error like the example below. + +We could offer partial support for simple lookups. The user could get the value +and populate `cdk.context.json` manually. Alternatively, for simple lookups +they could switch to using Pulumi data resources. The downside to this, since +it wouldn’t be possible to support all lookups, is that it would add a lot of +extra friction having to know what is/is not supported and how to do it the +Pulumi way. + +``` +Diagnostics: + pulumi:pulumi:Stack (pulumi-lookups-dev): + error: Context lookups have been disabled. Make sure all necessary context is already in "cdk.context.json". + Missing context keys: 'availability-zones:account=12345678910:region=us-east-2, ami:account=12345678910:filters.image-type.0=machine:filters.name.0=al2023-ami-2023.*.*.*.*-arm64:filters.state.0=available:region=us-east-2' +``` + +**Examples** + +**VPC Lookup** + +In order to import a VPC you need all of this information. The logic for VPC +lookup is pretty complicated and I’m not sure it would be a good idea to try +and replicate it in Pulumi. + +```javascript +aws_ec2.Vpc.fromVpcAttributes(this, 'Vpc', { + region: '', + vpcId: '', + availabilityZones: [], + vpcCidrBlock: '', + vpnGatewayId: '', + publicSubnetIds: [], + privateSubnetIds: [], + isolatedSubnetIds: [], + publicSubnetNames: [], + privateSubnetNames: [], + isolatedSubnetNames: [], + publicSubnetRouteTableIds: [], + privateSubnetRouteTableIds: [], + publicSubnetIpv4CidrBlocks: [], + isolatedSubnetRouteTableIds: [], + privateSubnetIpv4CidrBlocks: [], + isolatedSubnetIpv4CidrBlocks: [], +}); +``` + +**SecurityGroup With CDK Lookups** + +```javascript +aws_ec2.SecurityGroup.fromLookupByName(this, 'sg', 'sg-name', vpc); +``` + +**SecurityGroup Without CDK Lookups** + +```javascript +const sg = aws.ec2.getSecurityGroupOutput({ + vpcId: this.asOutput(vpc.vpcId), + name: 'sg-name', +}); + +aws_ec2.SecurityGroup.fromSecurityGroupId(this, 'sg', pulumicdk.asString(sg.id), { + // CDK fromLookup will figure this out for you + allowAllOutbound: false, +}); +``` + +**KMS Key With CDK Lookups** + +```javascript +const key = aws_kms.Key.fromLookup(this, 'key', { + aliasName: 'alias', +}); +``` + +**KMS Key Without CDK Lookups** + +```javascript +const alias = aws.kms.getAliasOutput({ + name: 'alias/somealias', +}); + +const key = aws_kms.Key.fromKeyArn(this, 'key', pulumicdk.asString(alias.targetKeyArn)) +``` + +**Route53 With CDK Lookups** + +```javascript +const hostedZone = aws_route53.HostedZone.fromLookup(this, 'hosted-zone', { + domainName: 'pulumi-demos.net', +}); + +new aws_route53.AaaaRecord(this, 'record', { + zone: hostedZone, + target: aws_route53.RecordTarget.fromAlias(new aws_route53_targets.LoadBalancerTarget(lb)), +}); + +``` + +**Route53 Without CDK Lookups** + +```javascript + +const zone = aws.route53.getZoneOutput({ + name: 'pulumi-demos.net', +}); +const hostedZone = aws_route53.HostedZone.fromHostedZoneAttributes(this, 'hosted-zone', { + zoneName: pulumicdk.asString(zone.name), + hostedZoneId: pulumicdk.asString(zone.zoneId), +}); + +new aws_route53.AaaaRecord(this, 'record', { + zone: hostedZone, + target: aws_route53.RecordTarget.fromAlias(new aws_route53_targets.LoadBalancerTarget(lb)), +}); +``` + +**AMI / AZs With CDK lookups** + +```javascript +export class Ec2CdkStack extends pulumicdk.Stack { + constructor(app: pulumicdk.App, id: string) { + super(app, id, { + props: { + // env must be specified to enable lookups + env: { region: process.env.AWS_REGION, account: process.env.AWS_ACCOUNT }, + }, + }); + + // Create new VPC with 2 Subnets + const vpc = new ec2.Vpc(this, 'VPC', { + natGateways: 0, + subnetConfiguration: [ + { + cidrMask: 24, + name: 'asterisk', + subnetType: ec2.SubnetType.PUBLIC, + }, + ], + }); + // This performs the CDK lookup + const machineImage = new ec2.LookupMachineImage({ + name: 'al2023-ami-2023.*.*.*.*-arm64', + }); + + const instance = new ec2.Instance(this, 'Instance', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO), + machineImage, + }); + } +} + +const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + new Ec2CdkStack(scope, 'teststack'); +}); +``` + +**AMI / AZs Without CDK lookups** + +```javascript +export class Ec2CdkStack extends pulumicdk.Stack { + constructor(app: pulumicdk.App, id: string) { + super(app, id, { + props: { + env: { region: process.env.AWS_REGION, account: process.env.AWS_ACCOUNT }, + }, + }); + + // Create new VPC with 2 Subnets + const vpc = new ec2.Vpc(this, 'VPC', { + natGateways: 0, + subnetConfiguration: [ + { + cidrMask: 24, + name: 'asterisk', + subnetType: ec2.SubnetType.PUBLIC, + }, + ], + }); + // Use a pulumi lookup to get the AMI + const ami = aws.ec2.getAmiOutput({ + owners: ['amazon'], + mostRecent: true, + filters: [ + { + name: 'name', + values: ['al2023-ami-2023.*.*.*.*-arm64'], + }, + ], + }); + + const machineImage = ec2.MachineImage.genericLinux({ + 'us-east-2': pulumicdk.asString(ami.imageId), + }); + + const instance = new ec2.Instance(this, 'Instance', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO), + machineImage, + }); + } + + // Since the stack cannot lookup the availability zone you have to provide this method + get availabilityZones(): string[] { + return ['us-east-2a', 'us-east-2b']; + } +} + +const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + new Ec2CdkStack(scope, 'teststack'); +}); + + +``` + +### Do not support Context + +The other alternative would be to not change the API which would mean that we +do not support CDK Context. The user would have to supply any context directly +to the `pulumi-cdk.Stack` + +```javascript +new pulumicdk.Stack('Stack', { + context: { + // optionally provide lookup context data directly in the correct format + "availability-zones:account=12345678910:region=us-east-2": [ + "us-east-2a", + "us-east-2b", + "us-east-2c" + ], + // CDK feature flags + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": "true", + // Arbitrary user context + "my-context-key": "my-context-value", + } +}); +``` + +## Consequences + +Supporting CDK Context would have the following impact on users. + +1. Support for sourcing context values natively (from `cdk.json`, + `cdk.context.json`, environment variables, etc) + 1. There is a workaround where users could directly supply the context to + the `Stack` itself. This would only be viable for static context like + feature flags (example in + [appendix](https://docs.google.com/document/d/1TFO0RJ4CtynBW8p4vKapy8v4L2AjUxldmrjxS60Tx4o/edit#heading=h.b1s6g8uq0ou7) + ) +2. Allowing resource lookups + 1. Resource lookups are not used in any of the core CDK constructs, instead + they are something that the end user would use to get an object that + would be an input to a core construct. + 2. There is no data on how frequently these are used by end users, but they + are definitely used more frequently in migration cases since this allows + you to reference resources created elsewhere. diff --git a/examples/alb/index.ts b/examples/alb/index.ts index 78d95c62..22af2cbe 100644 --- a/examples/alb/index.ts +++ b/examples/alb/index.ts @@ -3,24 +3,23 @@ import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import * as pulumi from '@pulumi/pulumi'; import * as pulumicdk from '@pulumi/cdk'; +import { CfnOutput } from 'aws-cdk-lib'; class AlbStack extends pulumicdk.Stack { url: pulumi.Output; - constructor(id: string, options?: pulumicdk.StackOptions) { - super(id, options); - // necessary for local testing - const t = this as any; + constructor(app: pulumicdk.App, id: string) { + super(app, id); - const vpc = new ec2.Vpc(t, 'VPC'); + const vpc = new ec2.Vpc(this, 'VPC'); - const asg = new autoscaling.AutoScalingGroup(t, 'ASG', { + const asg = new autoscaling.AutoScalingGroup(this, 'ASG', { vpc, instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MICRO), machineImage: new ec2.AmazonLinuxImage(), }); - const lb = new elbv2.ApplicationLoadBalancer(t, 'LB', { + const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', { vpc, internetFacing: true, }); @@ -45,10 +44,18 @@ class AlbStack extends pulumicdk.Stack { }); this.url = this.asOutput(lb.loadBalancerDnsName); + } +} - this.synth(); +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new AlbStack(scope, 'teststack'); + return { url: stack.url }; + }); } } -const stack = new AlbStack('teststack'); -export const url = stack.url; +const app = new MyApp(); + +export const url = app.outputs['url']; diff --git a/examples/api-websocket-lambda-dynamodb/index.ts b/examples/api-websocket-lambda-dynamodb/index.ts index 3ee11bff..3eabb1ac 100644 --- a/examples/api-websocket-lambda-dynamodb/index.ts +++ b/examples/api-websocket-lambda-dynamodb/index.ts @@ -10,8 +10,8 @@ import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; class ChatAppStack extends pulumicdk.Stack { public readonly url: Output; public readonly table: Output; - constructor(id: string) { - super(id); + constructor(app: pulumicdk.App, id: string) { + super(app, id); // initialise api const name = id + '-api'; @@ -93,11 +93,21 @@ class ChatAppStack extends pulumicdk.Stack { this.table = this.asOutput(table.tableName); this.url = this.asOutput(stage.url); + } +} - this.synth(); +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new ChatAppStack(scope, 'chat-app'); + return { + url: stack.url, + table: stack.table, + }; + }); } } -const stack = new ChatAppStack('chat-app'); -export const url = stack.url; -export const table = stack.table; +const app = new MyApp(); +export const url = app.outputs['url']; +export const table = app.outputs['table']; diff --git a/examples/apprunner/index.ts b/examples/apprunner/index.ts index 2777ca17..958aafab 100644 --- a/examples/apprunner/index.ts +++ b/examples/apprunner/index.ts @@ -1,15 +1,12 @@ -import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as pulumi from '@pulumi/pulumi'; import * as pulumicdk from '@pulumi/cdk'; -import { Construct } from 'constructs'; import { Service, Source } from '@aws-cdk/aws-apprunner-alpha'; -import { CfnOutput } from 'aws-cdk-lib'; class AppRunnerStack extends pulumicdk.Stack { url: pulumi.Output; - constructor(id: string) { - super(id); + constructor(app: pulumicdk.App, id: string) { + super(app, id); const service = new Service(this, 'service', { source: Source.fromEcrPublic({ @@ -19,10 +16,17 @@ class AppRunnerStack extends pulumicdk.Stack { }); this.url = this.asOutput(service.serviceUrl); + } +} - this.synth(); +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new AppRunnerStack(scope, 'teststack'); + return { url: stack.url }; + }); } } -const stack = new AppRunnerStack('teststack'); -export const url = stack.url; +const app = new MyApp(); +export const url = app.outputs['url']; diff --git a/examples/appsvc/index.ts b/examples/appsvc/index.ts index 58bd181d..f915bb09 100644 --- a/examples/appsvc/index.ts +++ b/examples/appsvc/index.ts @@ -22,8 +22,8 @@ const azs = aws.getAvailabilityZonesOutput({ class ClusterStack extends pulumicdk.Stack { serviceName: pulumi.Output; - constructor(name: string) { - super(name); + constructor(app: pulumicdk.App, name: string) { + super(app, name); const vpc = ec2.Vpc.fromVpcAttributes(this, 'Vpc', { vpcId: pulumicdk.asString(defaultVpc.id), @@ -82,11 +82,18 @@ class ClusterStack extends pulumicdk.Stack { ], }); - this.synth(); - this.serviceName = this.asOutput(service.serviceName); } } -const stack = new ClusterStack('teststack'); -export const serviceName = stack.serviceName; +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new ClusterStack(scope, 'teststack'); + return { serviceName: stack.serviceName }; + }); + } +} + +const app = new MyApp(); +export const serviceName = app.outputs['serviceName']; diff --git a/examples/cloudfront-lambda-urls/index.ts b/examples/cloudfront-lambda-urls/index.ts index cdee38e2..cef39243 100644 --- a/examples/cloudfront-lambda-urls/index.ts +++ b/examples/cloudfront-lambda-urls/index.ts @@ -1,15 +1,13 @@ import * as pulumi from '@pulumi/pulumi'; import * as pulumicdk from '@pulumi/cdk'; -import { Code, FunctionUrlAuthType, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { FunctionUrlAuthType, Runtime } from 'aws-cdk-lib/aws-lambda'; import { Distribution, - experimental, Function, FunctionCode, FunctionEventType, FunctionRuntime, KeyValueStore, - LambdaEdgeEventType, } from 'aws-cdk-lib/aws-cloudfront'; import { FunctionUrlOrigin, S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; @@ -17,8 +15,8 @@ import { Bucket } from 'aws-cdk-lib/aws-s3'; class CloudFrontAppStack extends pulumicdk.Stack { public cloudFrontUrl: pulumi.Output; - constructor(id: string) { - super(id); + constructor(scope: pulumicdk.App, id: string) { + super(scope, id); const handler = new NodejsFunction(this, 'handler', { runtime: Runtime.NODEJS_LATEST, @@ -52,10 +50,17 @@ class CloudFrontAppStack extends pulumicdk.Stack { new KeyValueStore(this, 'KVStore'); this.cloudFrontUrl = this.asOutput(distro.distributionDomainName); - - this.synth(); } } -const stack = new CloudFrontAppStack('cloudfront-app'); -export const url = pulumi.interpolate`https://${stack.cloudFrontUrl}`; +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new CloudFrontAppStack(scope, 'cloudfront-app'); + return { url: stack.cloudFrontUrl }; + }); + } +} +const app = new MyApp(); +const output = app.outputs['url']; +export const url = pulumi.interpolate`https://${output}`; diff --git a/examples/cron-lambda/index.ts b/examples/cron-lambda/index.ts index 644fe821..1f16c520 100644 --- a/examples/cron-lambda/index.ts +++ b/examples/cron-lambda/index.ts @@ -9,8 +9,8 @@ import * as pulumicdk from '@pulumi/cdk'; class LambdaStack extends pulumicdk.Stack { lambdaArn: pulumi.Output; - constructor(id: string, options?: pulumicdk.StackOptions) { - super(id, options); + constructor(app: pulumicdk.App, id: string) { + super(app, id); // Use the AWS CDK Lambda Function API directly. const lambdaFn = new aws_lambda.Function(this, 'lambda', { @@ -31,10 +31,17 @@ class LambdaStack extends pulumicdk.Stack { // Export the Lambda function's ARN as an output. this.lambdaArn = this.asOutput(lambdaFn.functionArn); + } +} - this.synth(); +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new LambdaStack(scope, 'teststack'); + return { lambdaArn: stack.lambdaArn }; + }); } } -const stack = new LambdaStack('teststack'); -export const lambdaArn = stack.lambdaArn; +const app = new MyApp(); +export const lambdaArn = app.outputs['lambdaArn']; diff --git a/examples/ec2-instance/index.ts b/examples/ec2-instance/index.ts index 613c2ebe..08274a42 100644 --- a/examples/ec2-instance/index.ts +++ b/examples/ec2-instance/index.ts @@ -6,8 +6,8 @@ import * as pulumicdk from '@pulumi/cdk'; import { Asset } from 'aws-cdk-lib/aws-s3-assets'; export class Ec2CdkStack extends pulumicdk.Stack { - constructor(id: string) { - super(id); + constructor(app: pulumicdk.App, id: string) { + super(app, id); // Create a Key Pair to be used with this EC2 Instance // Temporarily disabled since `cdk-ec2-key-pair` is not yet CDK v2 compatible @@ -80,12 +80,19 @@ export class Ec2CdkStack extends pulumicdk.Stack { new cdk.CfnOutput(this, 'ssh command', { value: 'ssh -i cdk-key.pem -o IdentitiesOnly=yes ec2-user@' + ec2Instance.instancePublicIp, }); + } +} - this.synth(); +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App) => { + new Ec2CdkStack(scope, 'teststack'); + }); } } -const stack = new Ec2CdkStack('teststack'); -export const ipAddress = stack.outputs['IP Address']; -export const keyCommand = stack.outputs['Download Key Command']; -export const sshCommand = stack.outputs['sshCommand']; +const app = new MyApp(); + +export const ipAddress = app.outputs['IP Address']; +export const keyCommand = app.outputs['Download Key Command']; +export const sshCommand = app.outputs['sshCommand']; diff --git a/examples/ecscluster/Pulumi.yaml b/examples/ecscluster/Pulumi.yaml deleted file mode 100644 index 83e1803e..00000000 --- a/examples/ecscluster/Pulumi.yaml +++ /dev/null @@ -1,3 +0,0 @@ -name: pulumi-cdk-ecscluster -runtime: nodejs -description: ECS Cluster diff --git a/examples/ecscluster/index.ts b/examples/ecscluster/index.ts deleted file mode 100644 index fdc97b69..00000000 --- a/examples/ecscluster/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as ecs from 'aws-cdk-lib/aws-ecs'; -import * as pulumi from '@pulumi/pulumi'; -import * as pulumicdk from '@pulumi/cdk'; -import * as ec2 from 'aws-cdk-lib/aws-ec2'; -import * as pulumiaws from "@pulumi/aws-native"; - -class ECSClusterStack extends pulumicdk.Stack { - clusterArn: pulumi.Output; - - constructor(id: string, options?: pulumicdk.StackOptions) { - super(id, options); - - const vpc = ec2.Vpc.fromLookup(this, 'MyVpc', { - isDefault: true, - }) - const cluster = new ecs.Cluster(this, 'fargate-service-autoscaling', { vpc }); - - this.clusterArn = this.asOutput(cluster.clusterArn); - - this.synth(); - } -} - -export const clusterArn = pulumiaws.getAccountId().then(account => { - const stack = new ECSClusterStack('teststack', { - props: { - env: { - region: pulumiaws.config.region, - account: account.accountId, - } - } - }); - return stack.clusterArn; -}); diff --git a/examples/ecscluster/tsconfig.json b/examples/ecscluster/tsconfig.json deleted file mode 100644 index c7c2de61..00000000 --- a/examples/ecscluster/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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" - ] -} diff --git a/examples/eventbridge-atm/index.ts b/examples/eventbridge-atm/index.ts index 2bcd5c9b..23f6ea10 100644 --- a/examples/eventbridge-atm/index.ts +++ b/examples/eventbridge-atm/index.ts @@ -10,8 +10,8 @@ import { } from 'aws-cdk-lib'; class EventbridgeAtmStack extends pulumicdk.Stack { - constructor(id: string) { - super(id); + constructor(app: pulumicdk.App, id: string) { + super(app, id); this.node.setContext('@aws-cdk/aws-apigateway:disableCloudWatchRole', 'true'); /** @@ -110,9 +110,9 @@ class EventbridgeAtmStack extends pulumicdk.Stack { new apigw.LambdaRestApi(this, 'Endpoint', { handler: atmProducerLambda, }); - - this.synth(); } } -new EventbridgeAtmStack('eventbridge-sns-stack'); +new pulumicdk.App('app', (scope: pulumicdk.App) => { + new EventbridgeAtmStack(scope, 'eventbridge-sns-stack'); +}); diff --git a/examples/eventbridge-sns/index.ts b/examples/eventbridge-sns/index.ts index 511056b9..41a70080 100644 --- a/examples/eventbridge-sns/index.ts +++ b/examples/eventbridge-sns/index.ts @@ -10,8 +10,8 @@ import { } from 'aws-cdk-lib'; class EventBridgeSnsStack extends pulumicdk.Stack { - constructor(id: string) { - super(id); + constructor(scope: pulumicdk.App, id: string) { + super(scope, id); const eventBus = new aws_events.EventBus(this, 'Bus'); const handler = new aws_lambda_nodejs.NodejsFunction(this, 'handler', { @@ -72,8 +72,15 @@ class EventBridgeSnsStack extends pulumicdk.Stack { rawMessageDelivery: true, }), ); - this.synth(); } } -new EventBridgeSnsStack('eventbridge-sns-stack'); +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App) => { + new EventBridgeSnsStack(scope, 'eventbridge-sns-stack'); + }); + } +} + +new MyApp(); diff --git a/examples/examples_nodejs_test.go b/examples/examples_nodejs_test.go index 55c7a61f..e7589c48 100644 --- a/examples/examples_nodejs_test.go +++ b/examples/examples_nodejs_test.go @@ -34,15 +34,6 @@ func TestAppSvc(t *testing.T) { integration.ProgramTest(t, &test) } -func TestECSCluster(t *testing.T) { - test := getJSBaseOptions(t). - With(integration.ProgramTestOptions{ - Dir: filepath.Join(getCwd(t), "ecscluster"), - }) - - integration.ProgramTest(t, &test) -} - func TestAppRunner(t *testing.T) { test := getJSBaseOptions(t). With(integration.ProgramTestOptions{ @@ -110,6 +101,17 @@ func TestCloudFront(t *testing.T) { integration.ProgramTest(t, &test) } +func TestLookups(t *testing.T) { + test := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "lookups"), + Config: map[string]string{ + "zoneName": "coolcompany.io", + }, + }) + + integration.ProgramTest(t, &test) +} func TestEventBridgeSNS(t *testing.T) { test := getJSBaseOptions(t). diff --git a/examples/fargate/index.ts b/examples/fargate/index.ts index bcbc98cf..372bc81a 100644 --- a/examples/fargate/index.ts +++ b/examples/fargate/index.ts @@ -10,8 +10,8 @@ import { CfnTargetGroup } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; class FargateStack extends pulumicdk.Stack { loadBalancerDNS: pulumi.Output; - constructor(id: string, options?: pulumicdk.StackOptions) { - super(id, options); + constructor(app: pulumicdk.App, id: string) { + super(app, id); // Create VPC and Fargate Cluster // NOTE: Limit AZs to avoid reaching resource quotas @@ -46,11 +46,17 @@ class FargateStack extends pulumicdk.Stack { }); this.loadBalancerDNS = this.asOutput(fargateService.loadBalancer.loadBalancerDnsName); + } +} - // Finalize the stack and deploy its resources. - this.synth(); +class MyApp extends pulumicdk.App { + constructor() { + super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + const stack = new FargateStack(scope, 'fargatestack'); + return { loadBalancerURL: stack.loadBalancerDNS }; + }); } } -const stack = new FargateStack('fargatestack'); -export const loadBalancerURL = stack.loadBalancerDNS; +const app = new MyApp(); +export const loadBalancerURL = app.outputs['loadBalancerURL']; diff --git a/examples/lookups/Pulumi.yaml b/examples/lookups/Pulumi.yaml new file mode 100644 index 00000000..277f42ad --- /dev/null +++ b/examples/lookups/Pulumi.yaml @@ -0,0 +1,3 @@ +name: pulumi-lookups +runtime: nodejs +description: A minimal TypeScript Pulumi program diff --git a/examples/lookups/index.ts b/examples/lookups/index.ts new file mode 100644 index 00000000..281df631 --- /dev/null +++ b/examples/lookups/index.ts @@ -0,0 +1,158 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as aws from '@pulumi/aws'; +import * as pulumicdk from '@pulumi/cdk'; +import * as native from '@pulumi/aws-native'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import { + aws_elasticloadbalancingv2, + aws_elasticloadbalancingv2_targets, + aws_route53, + aws_route53_targets, + CfnOutput, +} from 'aws-cdk-lib'; + +const config = new pulumi.Config(); +const zoneName = config.require('zoneName'); + +export class Ec2CdkStack extends pulumicdk.Stack { + constructor(app: pulumicdk.App, id: string) { + super(app, id, { + props: { + env: { region: aws.config.region }, + }, + }); + + // Create new VPC with 2 Subnets + const vpc = new ec2.Vpc(this, 'VPC', { + natGateways: 0, + subnetConfiguration: [ + { + cidrMask: 24, + name: 'asterisk', + subnetType: ec2.SubnetType.PUBLIC, + }, + ], + }); + + // use getAmiOutput to lookup the AMI instead of ec2.LookupMachineImage + const ami = aws.ec2.getAmiOutput({ + owners: ['amazon'], + mostRecent: true, + filters: [ + { + name: 'name', + values: ['al2023-ami-2023.*.*.*.*-arm64'], + }, + ], + }); + + const region = aws.config.requireRegion(); + const machineImage = ec2.MachineImage.genericLinux({ + [region]: pulumicdk.asString(ami.imageId), + }); + + const instance = new ec2.Instance(this, 'Instance', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO), + machineImage, + }); + + const lb = new aws_elasticloadbalancingv2.ApplicationLoadBalancer(this, 'lb', { + vpc, + }); + + const listener = lb.addListener('http', { + protocol: aws_elasticloadbalancingv2.ApplicationProtocol.HTTP, + }); + + const tg = listener.addTargets('instance', { + protocol: aws_elasticloadbalancingv2.ApplicationProtocol.HTTP, + targets: [new aws_elasticloadbalancingv2_targets.InstanceTarget(instance)], + }); + // workaround for https://github.com/pulumi/pulumi-cdk/issues/62 + const cfnTargetGroup = tg.node.defaultChild as aws_elasticloadbalancingv2.CfnTargetGroup; + cfnTargetGroup.overrideLogicalId('LBListenerTG'); + + // use pulumi getZoneOutput and HostedZone.fromHostedZoneAttributes instead of HostedZone.fromLookup + const zone = aws.route53.getZoneOutput( + { + name: zoneName, + tags: { + Purpose: 'Lookups', + }, + }, + { parent: app }, + ); + + const hostedZone = aws_route53.HostedZone.fromHostedZoneAttributes(this, 'hosted-zone', { + zoneName: pulumicdk.asString(zone.name), + hostedZoneId: pulumicdk.asString(zone.zoneId), + }); + + new aws_route53.AaaaRecord(this, 'record', { + zone: hostedZone, + target: aws_route53.RecordTarget.fromAlias(new aws_route53_targets.LoadBalancerTarget(lb)), + }); + + // use pulumi native resources side-by-side with CDK resources + new native.ssm.Parameter( + 'instance-param', + { + value: this.asOutput(instance.instanceId), + type: 'String', + }, + { parent: app }, + ); + new native.ssm.Parameter( + 'image-param', + { + value: this.asOutput(machineImage.getImage(this).imageId), + type: 'String', + }, + { parent: app }, + ); + + new CfnOutput(this, 'instanceId', { value: instance.instanceId }); + new CfnOutput(this, 'imageId', { value: machineImage.getImage(this).imageId }); + } +} + +const app = new pulumicdk.App( + 'app', + (scope: pulumicdk.App) => { + new Ec2CdkStack(scope, 'teststack'); + }, + { + appOptions: { + remapCloudControlResource(logicalId, typeName, props, options) { + switch (typeName) { + case 'AWS::Route53::RecordSet': + return [ + new aws.route53.Record( + logicalId, + { + zoneId: props.HostedZoneId, + aliases: [ + { + name: props.AliasTarget.DNSName, + zoneId: props.AliasTarget.HostedZoneId, + evaluateTargetHealth: props.AliasTarget.EvaluateTargetHealth ?? false, + }, + ], + name: props.Name, + type: props.Type, + records: props.ResourceRecords, + }, + options, + ), + ]; + default: + return undefined; + } + }, + }, + }, +); + +export const imageId = app.outputs['imageId']; +export const instanceId = app.outputs['instanceId']; diff --git a/examples/lookups/package.json b/examples/lookups/package.json new file mode 100644 index 00000000..82f6ffeb --- /dev/null +++ b/examples/lookups/package.json @@ -0,0 +1,13 @@ +{ + "name": "pulumi-aws-cdk", + "devDependencies": { + "@types/node": "^10.0.0" + }, + "dependencies": { + "@pulumi/aws-native": "^1.0.2", + "@pulumi/aws": "^6.0.0", + "@pulumi/cdk": "^0.5.0", + "aws-cdk-lib": "2.149.0", + "constructs": "10.3.0" + } +} diff --git a/examples/lookups/tsconfig.json b/examples/lookups/tsconfig.json new file mode 100644 index 00000000..2666e28e --- /dev/null +++ b/examples/lookups/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/examples/s3-object-lambda/index.ts b/examples/s3-object-lambda/index.ts index 4754b8bb..0b661160 100644 --- a/examples/s3-object-lambda/index.ts +++ b/examples/s3-object-lambda/index.ts @@ -1,7 +1,19 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as pulumicdk from '@pulumi/cdk'; +import type { AppOutputs } from '@pulumi/cdk'; import { S3ObjectLambdaStack } from './src/s3-object-lambda-stack'; -const s = new S3ObjectLambdaStack('stack'); -export const exampleBucketArn = s.exampleBucketArn; -export const objectLambdaArn = s.objectLambdaArn; -export const objectLambdaAccessPointArn = s.objectLambdaAccessPointArn; -export const objectLambdaAccessPointUrl = s.objectLambdaAccessPointUrl; +const app = new pulumicdk.App('app', (scope: pulumicdk.App): AppOutputs => { + const s = new S3ObjectLambdaStack(scope, 'stack'); + return { + exampleBucketArn: s.exampleBucketArn, + objectLambdaArn: s.objectLambdaArn, + objectLambdaAccessPointArn: s.objectLambdaAccessPointArn, + objectLambdaAccessPointUrl: s.objectLambdaAccessPointUrl, + }; +}); +export const exampleBucketArn = app.outputs['exampleBucketArn']; +export const objectLambdaArn = app.outputs['objectLambdaArn']; +export const objectLambdaAccessPointArn = app.outputs['objectLambdaAccessPointArn']; +export const objectLambdaAccessPointUrl = app.outputs['objectLambdaAccessPointUrl']; +export const bucketName = app.outputs['BucketName']; diff --git a/examples/s3-object-lambda/src/s3-object-lambda-stack.ts b/examples/s3-object-lambda/src/s3-object-lambda-stack.ts index 84398f3d..ccf009be 100644 --- a/examples/s3-object-lambda/src/s3-object-lambda-stack.ts +++ b/examples/s3-object-lambda/src/s3-object-lambda-stack.ts @@ -17,8 +17,8 @@ export class S3ObjectLambdaStack extends pulumicdk.Stack { objectLambdaAccessPointArn: pulumi.Output; objectLambdaAccessPointUrl: pulumi.Output; - constructor(id: string) { - super(id); + constructor(app: pulumicdk.App, id: string) { + super(app, id); const accessPoint = `arn:aws:s3:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:accesspoint/${S3_ACCESS_POINT_NAME}`; @@ -108,13 +108,12 @@ export class S3ObjectLambdaStack extends pulumicdk.Stack { }, }); + new cdk.CfnOutput(this, 'BucketName', { value: bucket.bucketName }); this.exampleBucketArn = this.asOutput(bucket.bucketArn); this.objectLambdaArn = this.asOutput(retrieveTransformedObjectLambda.functionArn); this.objectLambdaAccessPointArn = this.asOutput(objectLambdaAP.attrArn); this.objectLambdaAccessPointUrl = this.asOutput( `https://console.aws.amazon.com/s3/olap/${cdk.Aws.ACCOUNT_ID}/${OBJECT_LAMBDA_ACCESS_POINT_NAME}?region=${cdk.Aws.REGION}`, ); - - this.synth(); } } diff --git a/examples/scalable-webhook/index.ts b/examples/scalable-webhook/index.ts index d6fffb53..23091ff1 100644 --- a/examples/scalable-webhook/index.ts +++ b/examples/scalable-webhook/index.ts @@ -11,8 +11,8 @@ import { } from 'aws-cdk-lib'; class ScalableWebhookStack extends pulumicdk.Stack { - constructor(id: string) { - super(id); + constructor(app: pulumicdk.App, id: string) { + super(app, id); this.node.setContext('@aws-cdk/aws-apigateway:disableCloudWatchRole', 'true'); /** @@ -77,9 +77,9 @@ class ScalableWebhookStack extends pulumicdk.Stack { new apigw.LambdaRestApi(this, 'Endpoint', { handler: sqsPublishLambda, }); - - this.synth(); } } -new ScalableWebhookStack('eventbridge-sns-stack'); +new pulumicdk.App('app', (scope: pulumicdk.App) => { + new ScalableWebhookStack(scope, 'eventbridge-sns-stack'); +}); diff --git a/examples/the-big-fan/index.ts b/examples/the-big-fan/index.ts index 4771b5d2..181d1ff1 100644 --- a/examples/the-big-fan/index.ts +++ b/examples/the-big-fan/index.ts @@ -11,8 +11,8 @@ import * as pulumicdk from '@pulumi/cdk'; import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; class TheBigFanStack extends pulumicdk.Stack { - constructor(id: string) { - super(id); + constructor(app: pulumicdk.App, id: string) { + super(app, id); this.node.setContext('@aws-cdk/aws-apigateway:disableCloudWatchRole', 'true'); /** @@ -221,8 +221,9 @@ class TheBigFanStack extends pulumicdk.Stack { ], }, ); - - this.synth(); } } -new TheBigFanStack('TheBigFanStack'); + +new pulumicdk.App('app', (scope: pulumicdk.App) => { + new TheBigFanStack(scope, 'TheBigFanStack'); +}); diff --git a/package.json b/package.json index 2752adec..ff2caa76 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "constructs": "^10.0.111" }, "dependencies": { + "@aws-cdk/cli-lib-alpha": "^2.161.1-alpha.0", "@types/glob": "^8.1.0", "archiver": "^7.0.1", "cdk-assets": "^2.154.8", diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index df42a0e4..c426bdd8 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -1,8 +1,9 @@ +import * as cdk from 'aws-cdk-lib/core'; import * as pulumi from '@pulumi/pulumi'; import { AssemblyManifestReader, StackManifest } from '../assembly'; import { ConstructInfo, GraphBuilder } from '../graph'; -import { StackComponentResource, lift, Mapping } from '../types'; import { ArtifactConverter } from './artifact-converter'; +import { lift, Mapping, AppComponent } from '../types'; import { CdkConstruct, ResourceMapping } from '../interop'; import { debug } from '@pulumi/pulumi/log'; import { @@ -30,7 +31,7 @@ export class AppConverter { public readonly manifestReader: AssemblyManifestReader; - constructor(readonly host: StackComponentResource) { + constructor(readonly host: AppComponent) { this.manifestReader = AssemblyManifestReader.fromDirectory(host.assemblyDir); } @@ -90,6 +91,7 @@ export class StackConverter extends ArtifactConverter { readonly parameters = new Map(); readonly resources = new Map>(); readonly constructs = new Map(); + private readonly cdkStack: cdk.Stack; private _stackResource?: CdkConstruct; @@ -100,8 +102,9 @@ export class StackConverter extends ArtifactConverter { return this._stackResource; } - constructor(private readonly host: StackComponentResource, readonly stack: StackManifest) { + constructor(host: AppComponent, readonly stack: StackManifest) { super(host); + this.cdkStack = host.stacks[stack.id]; } public convert(dependencies: Set) { @@ -114,18 +117,14 @@ export class StackConverter extends ArtifactConverter { for (const n of dependencyGraphNodes) { if (n.construct.id === this.stack.id) { - this._stackResource = new CdkConstruct( - `${this.stackComponent.name}/${n.construct.path}`, - n.construct.id, - { - parent: this.stackComponent.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 - // we might be able better and have individual resources depend on individual assets, but CDK - // doesn't track asset dependencies at that level - dependsOn: this.stackDependsOn(dependencies), - }, - ); + this._stackResource = new CdkConstruct(`${this.app.name}/${n.construct.path}`, n.construct.id, { + 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 + // we might be able better and have individual resources depend on individual assets, but CDK + // doesn't track asset dependencies at that level + dependsOn: this.stackDependsOn(dependencies), + }); this.constructs.set(n.construct, this._stackResource); continue; } @@ -155,18 +154,13 @@ export class StackConverter extends ArtifactConverter { // // Do something with the condition // } } else { - const r = new CdkConstruct(`${this.stackComponent.name}/${n.construct.path}`, n.construct.type, { + const r = new CdkConstruct(`${this.app.name}/${n.construct.path}`, n.construct.type, { parent, }); this.constructs.set(n.construct, r); } } - // Register the outputs as outputs of the component resource. - for (const [outputId, args] of Object.entries(this.stack.outputs ?? {})) { - this.stackComponent.registerOutput(outputId, this.processIntrinsics(args.Value)); - } - for (let i = dependencyGraphNodes.length - 1; i >= 0; i--) { const n = dependencyGraphNodes[i]; if (!n.resource) { @@ -177,7 +171,7 @@ export class StackConverter extends ArtifactConverter { private stackDependsOn(dependencies: Set): pulumi.Resource[] { const dependsOn: pulumi.Resource[] = []; - dependsOn.push(...this.host.dependencies); + dependsOn.push(...this.app.dependencies); for (const d of dependencies) { if (d instanceof StackConverter) { dependsOn.push(d.stackResource); @@ -209,7 +203,7 @@ export class StackConverter extends ArtifactConverter { return key; } - this.parameters.set(logicalId, parameterValue(this.stackComponent.component)); + this.parameters.set(logicalId, parameterValue(this.app.component)); } private mapResource( @@ -218,8 +212,8 @@ export class StackConverter extends ArtifactConverter { props: any, options: pulumi.ResourceOptions, ): ResourceMapping[] { - if (this.stackComponent.options?.remapCloudControlResource !== undefined) { - const res = this.stackComponent.options.remapCloudControlResource(logicalId, typeName, props, options); + if (this.app.appOptions?.remapCloudControlResource !== undefined) { + const res = this.app.appOptions.remapCloudControlResource(logicalId, typeName, props, options); if (res !== undefined) { debug(`remapped ${logicalId}`); return res; @@ -247,11 +241,11 @@ export class StackConverter extends ArtifactConverter { /** @internal */ asOutputValue(v: T): T { - const value = this.stackComponent.stack.resolve(v); + const value = this.cdkStack.resolve(v); return this.processIntrinsics(value) as T; } - private processIntrinsics(obj: any): any { + public processIntrinsics(obj: any): any { try { debug(`Processing intrinsics for ${JSON.stringify(obj)}`); } catch { @@ -370,15 +364,15 @@ export class StackConverter extends ArtifactConverter { switch (target) { case 'AWS::AccountId': - return getAccountId({ parent: this.stackComponent.component }).then((r) => r.accountId); + return getAccountId({ parent: this.app.component }).then((r) => r.accountId); case 'AWS::NoValue': return undefined; case 'AWS::Partition': - return getPartition({ parent: this.stackComponent.component }).then((p) => p.partition); + return getPartition({ parent: this.app.component }).then((p) => p.partition); case 'AWS::Region': - return getRegion({ parent: this.stackComponent.component }).then((r) => r.region); + return getRegion({ parent: this.app.component }).then((r) => r.region); case 'AWS::URLSuffix': - return getUrlSuffix({ parent: this.stackComponent.component }).then((r) => r.urlSuffix); + return getUrlSuffix({ parent: this.app.component }).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 0725ed0d..1415f9e9 100644 --- a/src/converters/artifact-converter.ts +++ b/src/converters/artifact-converter.ts @@ -1,12 +1,12 @@ import * as cx from 'aws-cdk-lib/cx-api'; import { getAccountId, getPartition, getRegion } from '@pulumi/aws-native'; -import { StackComponentResource } from '../types'; +import { AppComponent } from '../types'; /** * ArtifactConverter */ export abstract class ArtifactConverter { - constructor(protected readonly stackComponent: StackComponentResource) {} + constructor(protected readonly app: AppComponent) {} /** * Takes a string and resolves any CDK environment placeholders (e.g. accountId, region, partition) @@ -15,7 +15,7 @@ export abstract class ArtifactConverter { * @returns The string with the placeholders fully resolved */ protected resolvePlaceholders(s: string): Promise { - const host = this.stackComponent; + const host = this.app; return cx.EnvironmentPlaceholders.replaceAsync(s, { async region(): Promise { return getRegion({ parent: host.component }).then((r) => r.region); diff --git a/src/index.ts b/src/index.ts index 2fa2557c..b3846dbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,4 +17,6 @@ export * from './output'; import * as interop from './interop'; export { interop }; -export { StackOptions } from './types'; +export { AppResourceOptions, AppOptions } from './types'; +import * as synthesizer from './synthesizer'; +export { synthesizer }; diff --git a/src/stack.ts b/src/stack.ts index 90fb7d79..4a70fab4 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -11,112 +11,242 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import * as cdk from 'aws-cdk-lib'; -import * as cx from 'aws-cdk-lib/cx-api'; +import * as cdk from 'aws-cdk-lib/core'; import * as pulumi from '@pulumi/pulumi'; -import { debug } from '@pulumi/pulumi/log'; -import { StackComponentResource, StackOptions } from './types'; +import { AppComponent, AppOptions, AppResourceOptions } from './types'; import { AppConverter, StackConverter } from './converters/app-converter'; -import { PulumiSynthesizer } from './synthesizer'; +import { PulumiSynthesizer, PulumiSynthesizerBase } from './synthesizer'; +import { AwsCdkCli, ICloudAssemblyDirectoryProducer } from '@aws-cdk/cli-lib-alpha'; +import { error } from '@pulumi/pulumi/log'; import { CdkConstruct } from './interop'; +export type AppOutputs = { [outputId: string]: pulumi.Output }; + +const STACK_SYMBOL = Symbol.for('@pulumi/cdk.Stack'); + +interface AppResource { + converter: AppConverter; +} + /** - * StackComponentResource is the underlying pulumi ComponentResource for each pulumicdk.Stack - * This exists because pulumicdk.Stack needs to extend cdk.Stack, but we also want it to represent a - * pulumi ComponentResource so we create this `StackComponentResource` to hold the pulumi logic + * A Pulumi CDK App component. This is the entrypoint to your Pulumi CDK application. + * The second argument is a callback function where all CDK resources must be created. + * + * @example + * import * as s3 from 'aws-cdk-lib/aws-s3'; + * import * as pulumicdk from '@pulumi/cdk'; + * + * const app = new pulumicdk.App('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => { + * // All resources must be created within a Pulumi Stack + * const stack = new pulumicdk.Stack(scope, 'pulumi-stack'); + * const bucket = new s3.Bucket(stack, 'my-bucket'); + * return { + * bucket: stack.asOutput(bucket.bucketName), + * }; + * }); + * + * export const bucket = app.outputs['bucket']; */ -class StackComponent extends pulumi.ComponentResource implements StackComponentResource { - /** @internal */ - name: string; +export class App + extends pulumi.ComponentResource + implements ICloudAssemblyDirectoryProducer, AppComponent +{ + public readonly name: string; + + /** + * 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. + */ + public outputs: { [outputId: string]: pulumi.Output } = {}; + + /** + * @internal + */ + public readonly component: pulumi.ComponentResource; + + /** + * @internal + */ + public readonly stacks: { [artifactId: string]: cdk.Stack } = {}; /** @internal */ - converter: AppConverter; + public converter: Promise; /** * @internal */ - readonly component: pulumi.ComponentResource; + public appOptions?: AppOptions; /** * The directory to which cdk synthesizes the CloudAssembly * @internal */ - public assemblyDir: string; + public assemblyDir!: string; /** - * Any stack options that are supplied by the user * @internal */ - public options?: StackOptions; + readonly dependencies: CdkConstruct[] = []; + + private readonly createFunc: (scope: App) => AppOutputs | void; + private _app?: cdk.App; + private appProps?: cdk.AppProps; + + constructor(id: string, createFunc: (scope: App) => void | AppOutputs, props?: AppResourceOptions) { + super('cdk:index:App', id, props?.appOptions, props); + this.appOptions = props?.appOptions; + this.createFunc = createFunc; + this.component = this; + + this.name = id; + this.appProps = props?.appOptions?.props; + const data = this.getData(); + this.converter = data.then((d) => d.converter); + + // This grabs the outputs off of the stacks themselves after they + // have been converted. This allows us to present the outputs property + // as a plain value instead of an Output value. + const outputs = this.converter.then((converter) => { + const stacks = Array.from(converter.stacks.values()); + return stacks.reduce( + (prev, curr) => { + const o: { [outputId: string]: pulumi.Output } = {}; + for (const [outputId, args] of Object.entries(curr.stack.outputs ?? {})) { + o[outputId] = curr.processIntrinsics(args.Value); + } + return { + ...prev, + ...o, + }; + }, + { ...this.outputs } as pulumi.Output<{ [outputId: string]: pulumi.Output }>, + ); + }); + this.outputs = pulumi.output(outputs); + this.registerOutputs(this.outputs); + } /** * @internal */ - public dependencies: CdkConstruct[] = []; + public get app(): cdk.App { + if (!this._app) { + throw new Error('cdk.App has not been created yet'); + } + return this._app!; + } - constructor(public readonly stack: Stack) { - super('cdk:index:Stack', stack.node.id, {}, stack.options); - this.options = stack.options; - this.dependencies.push(stack.pulumiSynthesizer.stagingStack); + protected async initialize(props: { + name: string; + args?: AppOptions; + opts?: pulumi.ComponentResourceOptions; + }): Promise { + const cli = AwsCdkCli.fromCloudAssemblyDirectoryProducer(this); + this.appProps = props.args?.props; + this.appOptions = props.args; + try { + // TODO: support lookups https://github.com/pulumi/pulumi-cdk/issues/184 + await cli.synth({ quiet: true, lookups: false }); + } catch (e: any) { + if (typeof e.message === 'string' && e.message.includes('Context lookups have been disabled')) { + const message = e.message as string; + const messageParts = message.split('Context lookups have been disabled. '); + const missingParts = messageParts[1].split('Missing context keys: '); + error( + 'Context lookups have been disabled. Make sure all necessary context is already in "cdk.context.json". \n' + + 'Missing context keys: ' + + missingParts[1], + this, + ); + } else { + error(e, this); + } + } - this.name = stack.node.id; + const converter = new AppConverter(this); + converter.convert(); - const assembly = stack.app.synth(); - this.assemblyDir = assembly.directory; - debug(`ASSEMBLY_DIR: ${this.assemblyDir}`); + return { + converter, + }; + } - debug(JSON.stringify(debugAssembly(assembly))); + /** + * produce is called by `AwsCdkCli` as part of the `synth` operation. It will potentially + * be called multiple times if there is any missing context values. + * + * Note: currently lookups are disabled so this will only be executed once + * + * @param context The CDK context collected by the CLI that needs to be passed to the cdk.App + * @returns the path to the CDK Assembly directory + */ + async produce(context: Record): Promise { + const appId = this.appOptions?.appId ?? generateAppId(); + const synthesizer = this.appProps?.defaultStackSynthesizer ?? new PulumiSynthesizer({ appId, parent: this }); - this.converter = new AppConverter(this); - this.converter.convert(); + if (synthesizer instanceof PulumiSynthesizerBase) { + this.dependencies.push(synthesizer.stagingStack); + } - this.registerOutputs(stack.outputs); - this.component = this; - } + const app = new cdk.App({ + ...(this.appProps ?? {}), + autoSynth: false, + analyticsReporting: false, + // We require tree metadata to walk the construct tree + treeMetadata: true, + context, + defaultStackSynthesizer: synthesizer, + }); + this._app = app; + const outputs = this.createFunc(this); + this.outputs = outputs ?? {}; - /** @internal */ - registerOutput(outputId: string, output: any) { - this.stack.outputs[outputId] = pulumi.output(output); + app.node.children.forEach((child) => { + if (Stack.isPulumiStack(child)) { + this.stacks[child.artifactId] = child; + } + }); + + const dir = app.synth().directory; + this.assemblyDir = dir; + return dir; } } +/** + * Options for creating a Pulumi CDK Stack + */ +export interface StackOptions extends pulumi.ComponentResourceOptions { + /** + * The CDK Stack props + */ + props?: cdk.StackProps; +} + /** * A Construct that represents an AWS CDK stack deployed with Pulumi. * - * In order to deploy a CDK stack with Pulumi, it must derive from this class. The `synth` method must be called after - * all CDK resources have been defined in order to deploy the stack (usually, this is done as the last line of the - * subclass's constructor). + * In order to deploy a CDK stack with Pulumi, it must derive from this class. */ export class Stack extends cdk.Stack { - // The URN of the underlying Pulumi component. - urn!: pulumi.Output; - resolveURN!: (urn: pulumi.Output) => void; - rejectURN!: (error: any) => void; - /** - * 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. + * Return whether the given object is a Stack. + * + * We do attribute detection since we can't reliably use 'instanceof'. + * @internal */ - outputs: { [outputId: string]: pulumi.Output } = {}; - - /** @internal */ - app: cdk.App; - - /** @internal */ - options: StackOptions | undefined; - - // The stack's converter. This is used by asOutput in order to convert CDK values to Pulumi Outputs. This is a - // Promise so users are able to call asOutput before they've called synth. Note that this _does_ make forgetting - // to call synth a sharper edge: calling asOutput without calling synth will create outputs that never resolve - // and the program will hang. - converter!: Promise; - resolveConverter!: (converter: StackConverter) => void; - rejectConverter!: (error: any) => void; + public static isPulumiStack(x: any): x is Stack { + return x !== null && typeof x === 'object' && STACK_SYMBOL in x; + } /** + * The stack's converter. This is used by asOutput in order to convert CDK + * values to Pulumi Outputs. This is a Promise so users are able to call + * asOutput before they've called synth. + * * @internal */ - public readonly pulumiSynthesizer: PulumiSynthesizer; + public converter: Promise; /** * Create and register an AWS CDK stack deployed with Pulumi. @@ -124,59 +254,11 @@ export class Stack extends cdk.Stack { * @param name The _unique_ name of the resource. * @param options A bag of options that control this resource's behavior. */ - constructor(name: string, options?: StackOptions) { - const appId = options?.appId ?? generateAppId(); - - // TODO: allow the user to customize this https://github.com/pulumi/pulumi-cdk/issues/180 - const synthesizer = new PulumiSynthesizer({ - appId, - }); - const app = new cdk.App({ - defaultStackSynthesizer: synthesizer, - context: { - // Ask CDK to attach 'aws:asset:*' metadata to resources in generated stack templates. Although this - // metadata is not currently used, it may be useful in the future to map between assets and the - // resources with which they are associated. For example, the lambda.Function L2 construct attaches - // metadata for its Code asset (if any) to its generated CFN resource. - [cx.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT]: true, - - // Ask CDK to embed 'aws:cdk:path' metadata in resources in generated stack templates. Although this - // metadata is not currently used, it provides an aditional mechanism by which we can map between - // constructs and the resources they emit in the CFN template. - [cx.PATH_METADATA_ENABLE_CONTEXT]: true, - }, - }); + constructor(app: App, name: string, options?: StackOptions) { + super(app.app, name, options?.props); + Object.defineProperty(this, STACK_SYMBOL, { value: true }); - super(app, name, options?.props); - this.pulumiSynthesizer = synthesizer; - - this.app = app; - this.options = options; - - const urnPromise = new Promise((resolve, reject) => { - this.resolveURN = resolve; - this.rejectURN = reject; - }); - this.urn = pulumi.output(urnPromise); - - this.converter = new Promise((resolve, reject) => { - this.resolveConverter = resolve; - this.rejectConverter = reject; - }); - } - - /** - * Finalize the stack and deploy its resources. - */ - protected synth() { - try { - const component = new StackComponent(this); - this.resolveURN(component.urn); - this.resolveConverter(component.converter.stacks.get(this.artifactId)!); - } catch (e) { - this.rejectURN(e); - this.rejectConverter(e); - } + this.converter = app.converter.then((converter) => converter.stacks.get(this.artifactId)!); } /** @@ -200,23 +282,6 @@ export class Stack extends cdk.Stack { } } -function debugAssembly(assembly: cx.CloudAssembly): any { - return { - version: assembly.version, - directory: assembly.directory, - runtime: assembly.runtime, - artifacts: assembly.artifacts.map(debugArtifact), - }; -} - -function debugArtifact(artifact: cx.CloudArtifact): any { - return { - dependencies: artifact.dependencies.map((artifact) => artifact.id), - manifest: artifact.manifest, - messages: artifact.messages, - }; -} - /** * Generate a unique app id based on the project and stack. We need some uniqueness * in case multiple stacks/projects are deployed to the same AWS environment. diff --git a/src/synthesizer.ts b/src/synthesizer.ts index 9a58b71c..56479cd7 100644 --- a/src/synthesizer.ts +++ b/src/synthesizer.ts @@ -68,6 +68,24 @@ export interface PulumiSynthesizerOptions { * @default true */ readonly autoDeleteStagingAssets?: boolean; + + /** + * The parent resource for any Pulumi resources created by the Synthesizer + */ + readonly parent?: pulumi.Resource; +} + +/** + * Base Synthesizer class. If you want to implement your own Pulumi Synthesizer which + * creates Pulumi resources then you should extend this class. + */ +export abstract class PulumiSynthesizerBase extends cdk.StackSynthesizer { + /** + * The Pulumi ComponentResource wrapper which contains all of the + * staging resources. This can be added to the `dependsOn` of the main + * stack to ensure the staging assets are created first + */ + public abstract readonly stagingStack: CdkConstruct; } /** @@ -83,7 +101,7 @@ export interface PulumiSynthesizerOptions { * @see Recommended reading https://github.com/aws/aws-cdk/wiki/Security-And-Safety-Dev-Guide#controlling-the-permissions-used-by-cdk-deployments * @see https://docs.aws.amazon.com/cdk/api/v2/docs/app-staging-synthesizer-alpha-readme.html */ -export class PulumiSynthesizer extends cdk.StackSynthesizer implements cdk.IReusableStackSynthesizer { +export class PulumiSynthesizer extends PulumiSynthesizerBase implements cdk.IReusableStackSynthesizer { /** * The Pulumi ComponentResource wrapper which contains all of the * staging resources. This can be added to the `dependsOn` of the main @@ -166,14 +184,13 @@ export class PulumiSynthesizer extends cdk.StackSynthesizer implements cdk.IReus this.autoDeleteStagingAssets = props.autoDeleteStagingAssets ?? true; this.appId = this.validateAppId(props.appId); - // TODO: inherit the provider from the app component https://github.com/pulumi/pulumi-cdk/issues/181 - const account = aws.getCallerIdentity().then((id) => id.accountId); + const account = aws.getCallerIdentity({}, { parent: props.parent }).then((id) => id.accountId); this.pulumiAccount = pulumi.output(account); - const region = aws.getRegion().then((r) => r.name); + const region = aws.getRegion({}, { parent: props.parent }).then((r) => r.name); this.pulumiRegion = pulumi.output(region); const id = `${stackPrefix}-${this.appId}`; // create a wrapper component resource that we can depend on - this.stagingStack = new CdkConstruct(id, 'StagingStack', {}); + this.stagingStack = new CdkConstruct(id, 'StagingStack', { parent: props.parent }); this.stagingStack.done(); } @@ -426,7 +443,7 @@ export class PulumiSynthesizer extends cdk.StackSynthesizer implements cdk.IReus * * This replaces the ! assertions we would need everywhere otherwise. */ -export function assertBound(x: A | undefined): asserts x is NonNullable { +function assertBound(x: A | undefined): asserts x is NonNullable { if (x === null && x === undefined) { throw new Error('You must call bindStack() first'); } diff --git a/src/types.ts b/src/types.ts index 9785ef7c..de58d1a0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,14 +1,15 @@ import * as pulumi from '@pulumi/pulumi'; -import { Stack, StackProps } from 'aws-cdk-lib/core'; +import { Stack, AppProps } from 'aws-cdk-lib/core'; import { CdkConstruct, ResourceMapping } from './interop'; + /** - * Options specific to the Stack component. + * Options for creating a Pulumi CDK App Component */ -export interface StackOptions extends pulumi.ComponentResourceOptions { +export interface AppOptions { /** * Specify the CDK Stack properties to asociate with the stack. */ - props?: StackProps; + props?: AppProps; /** * A unique identifier for the application that the asset staging stack belongs to. @@ -47,7 +48,14 @@ export interface StackOptions extends pulumi.ComponentResourceOptions { } /** - * The pulumi provider to read the schema from + * Options specific to the Pulumi CDK App component. + */ +export interface AppResourceOptions extends pulumi.ComponentResourceOptions { + appOptions?: AppOptions; +} + +/** + * The Pulumi provider to read the schema from */ export enum PulumiProvider { // We currently only support aws-native provider resources @@ -55,16 +63,13 @@ export enum PulumiProvider { } /** - * StackComponentResource is the underlying pulumi ComponentResource for each pulumicdk.Stack - * This exists because pulumicdk.Stack needs to extend cdk.Stack, but we also want it to represent a - * pulumi ComponentResource so we create this `StackComponentResource` to hold the pulumi logic + * AppComponent is the interface representing the Pulumi CDK App Component Resource */ -export interface StackComponentResource { +export interface AppComponent { /** - * The name of the component resource - * @internal + * The name of the component */ - name: string; + readonly name: string; /** * The directory to which cdk synthesizes the CloudAssembly @@ -74,32 +79,29 @@ export interface StackComponentResource { /** * The CDK stack associated with the component resource - */ - readonly stack: Stack; - - /** - * Any stack options that are supplied by the user * @internal */ - options?: StackOptions; + readonly stacks: { [artifactId: string]: Stack }; /** - * The Resources that the component resource depends on - * This will typically be the staging resources - * + * The underlying ComponentResource * @internal */ - readonly dependencies: CdkConstruct[]; + readonly component: pulumi.ComponentResource; /** + * The AppOptions for this component * @internal */ - readonly component: pulumi.ComponentResource; + appOptions?: AppOptions; /** + * The Resources that the component resource depends on + * This will typically be the staging resources + * * @internal */ - registerOutput(outputId: string, outupt: any): void; + readonly dependencies: CdkConstruct[]; } export type Mapping = { diff --git a/tests/assembly/manifest.test.ts b/tests/assembly/manifest.test.ts index a935ee4b..ccce3fd0 100644 --- a/tests/assembly/manifest.test.ts +++ b/tests/assembly/manifest.test.ts @@ -10,7 +10,13 @@ describe('cloud assembly manifest reader', () => { beforeEach(() => { mockfs({ // Recursively loads all node_modules - node_modules: mockfs.load(path.resolve(__dirname, '../../node_modules')), + node_modules: { + 'aws-cdk-lib': mockfs.load(path.resolve(__dirname, '../../node_modules/aws-cdk-lib')), + '@pulumi': { + aws: mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws')), + 'aws-native': mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws-native')), + }, + }, [manifestAssets]: JSON.stringify({ version: '36.0.0', files: { diff --git a/tests/basic.test.ts b/tests/basic.test.ts index 3c788a02..1970347d 100644 --- a/tests/basic.test.ts +++ b/tests/basic.test.ts @@ -13,52 +13,23 @@ // limitations under the License. import * as pulumi from '@pulumi/pulumi'; import * as s3 from 'aws-cdk-lib/aws-s3'; -import { Stack } from '../src/stack'; -import { Construct } from 'constructs'; import * as output from '../src/output'; -import { promiseOf, setMocks } from './mocks'; +import { setMocks, testApp } 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'; - -function testStack(id: string, fn: (scope: Construct) => void): Stack { - class TestStack extends Stack { - constructor(id: string) { - super(id); - - fn(this); - - this.synth(); - } - } - - const s = new TestStack(id); - return s; -} - -beforeAll(() => { - setMocks(); -}); +import { Construct } from 'constructs'; describe('Basic tests', () => { + setMocks(); test('Checking single resource registration', async () => { - const stack = testStack('test1', (adapter) => { - new s3.Bucket(adapter, 'MyFirstBucket', { versioned: true }); + await testApp((scope: Construct) => { + new s3.Bucket(scope, 'MyFirstBucket', { versioned: true }); }); - const urn = await promiseOf(stack.urn); - expect(urn).toEqual('urn:pulumi:stack::project::cdk:index:Stack::test1'); }); - test('Supports Output', async () => { - const o = pulumi.output('the-bucket-name'); - const stack = testStack('test2', (adapter) => { - new s3.Bucket(adapter, 'MyFirstBucket', { bucketName: output.asString(o) }); - }); - const urn = await promiseOf(stack.urn); - expect(urn).toEqual('urn:pulumi:stack::project::cdk:index:Stack::test2'); - }); test('LoadBalancer dnsName attribute does not throw', async () => { - const stack = testStack('test3', (scope) => { + await testApp((scope: Construct) => { const vpc = new Vpc(scope, 'vpc'); const alb = new ApplicationLoadBalancer(scope, 'alb', { vpc, @@ -71,7 +42,11 @@ describe('Basic tests', () => { stringValue: alb.loadBalancerDnsName, }); }); - const urn = await promiseOf(stack.urn); - expect(urn).toEqual('urn:pulumi:stack::project::cdk:index:Stack::test3'); + }); + test('Supports Output', async () => { + const o = pulumi.output('the-bucket-name'); + await testApp((scope: Construct) => { + new s3.Bucket(scope, 'MyFirstBucket', { bucketName: output.asString(o) }); + }); }); }); diff --git a/tests/cdk-resource.test.ts b/tests/cdk-resource.test.ts index 6e4ad78f..9eb520da 100644 --- a/tests/cdk-resource.test.ts +++ b/tests/cdk-resource.test.ts @@ -1,8 +1,9 @@ import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { TableArgs } from '@pulumi/aws-native/dynamodb'; import { Key } from 'aws-cdk-lib/aws-kms'; -import { setMocks, testStack } from './mocks'; +import { setMocks, testApp } from './mocks'; import { MockResourceArgs } from '@pulumi/pulumi/runtime'; +import { Construct } from 'constructs'; describe('CDK Construct tests', () => { // DynamoDB table was previously mapped to the `aws` provider @@ -12,7 +13,7 @@ describe('CDK Construct tests', () => { const resources: MockResourceArgs[] = []; setMocks(resources); - await testStack((scope) => { + await testApp((scope: Construct) => { const key = Key.fromKeyArn(scope, 'key', 'arn:aws:kms:us-west-2:123456789012:key/abcdefg'); const table = new dynamodb.Table(scope, 'Table', { encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED, @@ -41,7 +42,6 @@ describe('CDK Construct tests', () => { }, }); }); - const db = resources.find((res) => res.type === 'aws-native:dynamodb:Table'); expect(db).toBeDefined(); expect(db!.inputs).toEqual({ diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts index 23377f20..e76c8bbf 100644 --- a/tests/converters/app-converter.test.ts +++ b/tests/converters/app-converter.test.ts @@ -1,6 +1,6 @@ import { AppConverter, StackConverter } from '../../src/converters/app-converter'; import { Stack } from 'aws-cdk-lib/core'; -import { StackComponentResource, StackOptions } from '../../src/types'; +import { AppComponent, AppOptions, PulumiStack } from '../../src/types'; import * as path from 'path'; import * as mockfs from 'mock-fs'; import * as pulumi from '@pulumi/pulumi'; @@ -9,20 +9,20 @@ import { createStackManifest } from '../utils'; import { promiseOf, setMocks } from '../mocks'; import { CdkConstruct } from '../../src/interop'; -class MockStackComponent extends pulumi.ComponentResource implements StackComponentResource { +class MockAppComponent extends pulumi.ComponentResource implements AppComponent { public readonly name = 'stack'; public readonly assemblyDir: string; + stacks: { [artifactId: string]: PulumiStack } = {}; + dependencies: CdkConstruct[] = []; + component: pulumi.ComponentResource; public stack: Stack; - public options?: StackOptions | undefined; - public dependencies: CdkConstruct[] = []; + public appOptions?: AppOptions | undefined; constructor(dir: string) { - super('cdk:index:Stack', 'stack', {}, {}); + super('cdk:index:App', 'stack'); this.assemblyDir = dir; this.registerOutputs(); } - - registerOutput(outputId: string, output: any): void {} } beforeAll(() => { @@ -37,7 +37,13 @@ describe('App Converter', () => { beforeEach(() => { mockfs({ // Recursively loads all node_modules - node_modules: mockfs.load(path.resolve(__dirname, '../../node_modules')), + node_modules: { + 'aws-cdk-lib': mockfs.load(path.resolve(__dirname, '../../node_modules/aws-cdk-lib')), + '@pulumi': { + aws: mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws')), + 'aws-native': mockfs.load(path.resolve(__dirname, '../../node_modules/@pulumi/aws-native')), + }, + }, [manifestAssets]: JSON.stringify({ version: '36.0.0', files: { @@ -176,7 +182,7 @@ describe('App Converter', () => { mockfs.restore(); }); test('can convert', async () => { - const mockStackComponent = new MockStackComponent('/tmp/foo/bar/does/not/exist'); + const mockStackComponent = new MockAppComponent('/tmp/foo/bar/does/not/exist'); const converter = new AppConverter(mockStackComponent); converter.convert(); const stacks = Array.from(converter.stacks.values()); @@ -244,7 +250,7 @@ describe('App Converter', () => { ])( 'intrinsics %s', async (_name, stackManifest, expected) => { - const mockStackComponent = new MockStackComponent('/tmp/foo/bar/does/not/exist'); + const mockStackComponent = new MockAppComponent('/tmp/foo/bar/does/not/exist'); const converter = new StackConverter(mockStackComponent, stackManifest); converter.convert(new Set()); const promises = Array.from(converter.resources.values()).flatMap((res) => promiseOf(res.resource.urn)); diff --git a/tests/mocks.ts b/tests/mocks.ts index 9fdcd7bd..50048113 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -1,16 +1,17 @@ import * as pulumi from '@pulumi/pulumi'; -import { Stack } from '../src/stack'; -import { Construct } from 'constructs'; import { MockCallArgs, MockResourceArgs } from '@pulumi/pulumi/runtime'; +import { Construct } from 'constructs'; +import { App, Stack } from '../src/stack'; // Convert a pulumi.Output to a promise of the same type. export function promiseOf(output: pulumi.Output): Promise { return new Promise((resolve) => output.apply(resolve)); } -export async function testStack(fn: (scope: Construct) => void) { + +export async function testApp(fn: (scope: Construct) => void) { class TestStack extends Stack { - constructor(id: string) { - super(id, { + constructor(app: App, id: string) { + super(app, id, { props: { env: { region: 'us-east-1', @@ -20,16 +21,24 @@ export async function testStack(fn: (scope: Construct) => void) { }); fn(this); + } - this.synth(); + get availabilityZones(): string[] { + return ['us-east-1a', 'us-east-1b']; } } - const s = new TestStack('teststack'); - const converter = await s.converter; - await Promise.all(Array.from(converter.constructs.values()).flatMap((v) => promiseOf(v.urn))); - await promiseOf(s.urn); - await promiseOf(s.pulumiSynthesizer.stagingStack.urn); + const app = new App('testapp', (scope: App) => { + new TestStack(scope, 'teststack'); + }); + const converter = await app.converter; + await Promise.all( + Array.from(converter.stacks.values()).flatMap((stackConverter) => { + return Array.from(stackConverter.constructs.values()).flatMap((v) => promiseOf(v.urn)); + }), + ); + await promiseOf(app.urn); + await Promise.all(app.dependencies.flatMap((d) => promiseOf(d.urn))); } export function setMocks(resources?: MockResourceArgs[]) { diff --git a/tests/synthesizer.test.ts b/tests/synthesizer.test.ts index b9082868..4ef2d54c 100644 --- a/tests/synthesizer.test.ts +++ b/tests/synthesizer.test.ts @@ -1,6 +1,6 @@ import * as path from 'path'; import { Asset } from 'aws-cdk-lib/aws-s3-assets'; -import { setMocks, testStack } from './mocks'; +import { setMocks, testApp } from './mocks'; import { MockResourceArgs } from '@pulumi/pulumi/runtime'; import { CfnBucket } from 'aws-cdk-lib/aws-s3'; @@ -9,7 +9,7 @@ describe('Synthesizer', () => { const resources: MockResourceArgs[] = []; setMocks(resources); - await testStack((scope) => { + await testApp((scope) => { new CfnBucket(scope, 'Bucket'); }); expect(resources).toEqual([ @@ -28,7 +28,7 @@ describe('Synthesizer', () => { const resources: MockResourceArgs[] = []; setMocks(resources); - await testStack((scope) => { + await testApp((scope) => { new CfnBucket(scope, 'Bucket'); new Asset(scope, 'asset', { path: path.join(__dirname, 'synthesizer.test.ts'), @@ -74,7 +74,7 @@ describe('Synthesizer', () => { const resources: MockResourceArgs[] = []; setMocks(resources); - await testStack((scope) => { + await testApp((scope) => { new CfnBucket(scope, 'Bucket'); new Asset(scope, 'deploy-time-asset', { deployTime: true, diff --git a/yarn.lock b/yarn.lock index 532d08d0..9fbaa233 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30,6 +30,11 @@ resolved "https://registry.yarnpkg.com/@aws-cdk/aws-apprunner-alpha/-/aws-apprunner-alpha-2.20.0-alpha.0.tgz#66ae8b2795281bf46163872f450d9163cf4beb39" integrity sha512-Eno+FXxa7k0Irx9ssl0ML44rlBg2THo8WMqxO3dKZpAZeZbfd8s8T3/UjP1Fq22TCKn+psDJ+wiUAd9r/BI2ig== +"@aws-cdk/cli-lib-alpha@^2.161.1-alpha.0": + version "2.161.1-alpha.0" + resolved "https://registry.yarnpkg.com/@aws-cdk/cli-lib-alpha/-/cli-lib-alpha-2.161.1-alpha.0.tgz#f00f5190f7da2e8f62807c5a01fb629298c767f4" + integrity sha512-HCokBr85Msv0tXiKth/3ZJZaQLzMmydk3NNEEA9fD/tzBh1zUcnlsBQnclOBmd0uKMNSZQertrroJmZv3mBOeg== + "@aws-cdk/cloud-assembly-schema@^38.0.1": version "38.0.1" resolved "https://registry.yarnpkg.com/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-38.0.1.tgz#cdf4684ae8778459e039cd44082ea644a3504ca9"