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..6589cc55 --- /dev/null +++ b/examples/lookups/index.ts @@ -0,0 +1,50 @@ +import * as aws from '@pulumi/aws-native'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as pulumicdk from '@pulumi/cdk'; +import { CfnOutput } from 'aws-cdk-lib'; + +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, + }, + ], + }); + 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 param = new aws.ssm.Parameter('param', { + value: this.asOutput(instance.instanceId), + type: 'String', + }); + + 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'); +}); + +export const imageId = app.outputs.then((output) => output['imageId']); +export const instanceId = app.outputs.then((output) => output['instanceId']); diff --git a/examples/lookups/package.json b/examples/lookups/package.json new file mode 100644 index 00000000..ff88a461 --- /dev/null +++ b/examples/lookups/package.json @@ -0,0 +1,14 @@ +{ + "name": "pulumi-aws-cdk", + "devDependencies": { + "@types/node": "^10.0.0" + }, + "dependencies": { + "@pulumi/aws": "^4.6.0", + "@pulumi/aws-native": "^1.0.2", + "@pulumi/cdk": "^0.5.0", + "@pulumi/pulumi": "^3.0.0", + "aws-cdk-lib": "2.149.0", + "constructs": "^10.0.111" + } +} 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/package.json b/package.json index 5df2c484..51311cbf 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,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 f4b5a36c..54569d97 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -1,7 +1,7 @@ import * as pulumi from '@pulumi/pulumi'; import { AssemblyManifestReader, StackManifest } from '../assembly'; import { ConstructInfo, GraphBuilder } from '../graph'; -import { StackComponentResource, lift, Mapping } from '../types'; +import { lift, Mapping, AppComponent, PulumiStack } from '../types'; import { ArtifactConverter, FileAssetManifestConverter } from './artifact-converter'; import { CdkConstruct, ResourceMapping } from '../interop'; import { debug } from '@pulumi/pulumi/log'; @@ -30,7 +30,7 @@ export class AppConverter { public readonly manifestReader: AssemblyManifestReader; - constructor(readonly host: StackComponentResource) { + constructor(readonly host: AppComponent) { this.manifestReader = AssemblyManifestReader.fromDirectory(host.assemblyDir); } @@ -69,9 +69,11 @@ export class StackConverter extends ArtifactConverter { readonly parameters = new Map(); readonly resources = new Map>(); readonly constructs = new Map(); + private readonly cdkStack: PulumiStack; - constructor(host: StackComponentResource, readonly stack: StackManifest) { + constructor(host: AppComponent, readonly stack: StackManifest) { super(host); + this.cdkStack = host.stacks[stack.id]; } public convert(dependencies: Set) { @@ -84,18 +86,14 @@ export class StackConverter extends ArtifactConverter { for (const n of dependencyGraphNodes) { if (n.construct.id === this.stack.id) { - const stackResource = new CdkConstruct( - `${this.stackComponent.name}/${n.construct.path}`, - n.construct.id, - { - parent: this.stackComponent, - // 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), - }, - ); + const stackResource = new CdkConstruct(`${this.app.name}/${n.construct.path}`, n.construct.id, { + parent: this.app, + // 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, stackResource); continue; } @@ -123,18 +121,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) { @@ -178,7 +171,7 @@ export class StackConverter extends ArtifactConverter { return key; } - this.parameters.set(logicalId, parameterValue(this.stackComponent)); + this.parameters.set(logicalId, parameterValue(this.app)); } private mapResource( @@ -187,8 +180,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; @@ -216,11 +209,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 { @@ -339,15 +332,15 @@ export class StackConverter extends ArtifactConverter { switch (target) { case 'AWS::AccountId': - return getAccountId({ parent: this.stackComponent }).then((r) => r.accountId); + return getAccountId({ parent: this.app }).then((r) => r.accountId); case 'AWS::NoValue': return undefined; case 'AWS::Partition': - return getPartition({ parent: this.stackComponent }).then((p) => p.partition); + return getPartition({ parent: this.app }).then((p) => p.partition); case 'AWS::Region': - return getRegion({ parent: this.stackComponent }).then((r) => r.region); + return getRegion({ parent: this.app }).then((r) => r.region); case 'AWS::URLSuffix': - return getUrlSuffix({ parent: this.stackComponent }).then((r) => r.urlSuffix); + return getUrlSuffix({ parent: this.app }).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 224b5a7c..c5e2d924 100644 --- a/src/converters/artifact-converter.ts +++ b/src/converters/artifact-converter.ts @@ -4,13 +4,13 @@ import { getAccountId, getPartition, getRegion } from '@pulumi/aws-native'; import { FileAssetManifest } from '../assembly'; import { FileAssetPackaging } from 'aws-cdk-lib/cloud-assembly-schema'; import { zipDirectory } from '../zip'; -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) @@ -19,7 +19,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 }).then((r) => r.region); @@ -44,7 +44,7 @@ export class FileAssetManifestConverter extends ArtifactConverter { public _id?: string; public resourceType: string = 'aws:s3:BucketObjectv2'; - constructor(host: StackComponentResource, readonly manifest: FileAssetManifest) { + constructor(host: AppComponent, readonly manifest: FileAssetManifest) { super(host); } @@ -71,7 +71,7 @@ export class FileAssetManifestConverter extends ArtifactConverter { public convert(): void { const name = this.manifest.id.assetId; const id = this.manifest.id.destinationId; - this._id = `${this.stackComponent.name}/${name}/${id}`; + this._id = `${this.app.name}/${name}/${id}`; const outputPath = this.manifest.packaging === FileAssetPackaging.FILE @@ -85,7 +85,7 @@ export class FileAssetManifestConverter extends ArtifactConverter { bucket: this.resolvePlaceholders(this.manifest.destination.bucketName), key: this.resolvePlaceholders(this.manifest.destination.objectKey), }, - { parent: this.stackComponent }, + { parent: this.app }, ); } } diff --git a/src/stack.ts b/src/stack.ts index b919748e..f4b9f7d5 100644 --- a/src/stack.ts +++ b/src/stack.ts @@ -14,46 +14,99 @@ import * as cdk from 'aws-cdk-lib'; import * as cx from 'aws-cdk-lib/cx-api'; import * as pulumi from '@pulumi/pulumi'; -import { debug } from '@pulumi/pulumi/log'; -import { StackComponentResource, StackOptions } from './types'; +import { AppComponent, AppOptions, PulumiStack } from './types'; import { AppConverter, StackConverter } from './converters/app-converter'; +import { AwsCdkCli, ICloudAssemblyDirectoryProducer } from '@aws-cdk/cli-lib-alpha'; -class StackComponent extends pulumi.ComponentResource implements StackComponentResource { +const STACK_SYMBOL = Symbol.for('@pulumi/cdk.Stack'); +export type create = (scope: App) => void; + +export class App extends AppComponent implements ICloudAssemblyDirectoryProducer { /** @internal */ name: string; - /** @internal */ - converter: AppConverter; + public assemblyDir!: string; - /** @internal */ - assemblyDir: string; + // /** @internal */ + converter: Promise; - options?: StackOptions; + private _app?: cdk.App; - constructor(public readonly stack: Stack) { - super('cdk:index:Stack', stack.node.id, {}, stack.options); - this.options = stack.options; + public appOptions?: AppOptions; - this.name = stack.node.id; + public get app(): cdk.App { + if (!this._app) { + throw new Error('cdk.App has not been created yet'); + } + return this._app!; + } - const assembly = stack.app.synth(); - this.assemblyDir = assembly.directory; - debug(`ASSEMBLY_DIR: ${this.assemblyDir}`); + /** + * 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: Promise<{ [outputId: string]: pulumi.Output }>; + + private appProps?: cdk.AppProps; + + constructor(id: string, private readonly createFunc: create, props?: AppOptions) { + super(id, props); + this.appOptions = props; + + this.name = id; + this.appProps = props?.props; + this.converter = this.getData(); + + this.outputs = this.converter.then((converter) => { + const stacks = Array.from(converter.stacks.values()); + return stacks.reduce((prev, curr) => { + const outputs: { [outputId: string]: pulumi.Output } = {}; + for (const [outputId, args] of Object.entries(curr.stack.outputs ?? {})) { + outputs[outputId] = curr.processIntrinsics(args.Value); + } + return { + ...prev, + ...outputs, + }; + }, {}); + }); + this.registerOutputs(this.outputs); + } - debug(JSON.stringify(debugAssembly(assembly))); + protected async initialize(): Promise { + const cli = AwsCdkCli.fromCloudAssemblyDirectoryProducer(this); + await cli.synth({ quiet: true }); - this.converter = new AppConverter(this); - this.converter.convert(); + const converter = new AppConverter(this); + converter.convert(); - this.registerOutputs(stack.outputs); + return converter; } - /** @internal */ - registerOutput(outputId: string, output: any) { - this.stack.outputs[outputId] = pulumi.output(output); + async produce(context: Record): Promise { + const app = new cdk.App({ + outdir: 'cdk.out', + ...(this.appProps ?? {}), + autoSynth: false, + analyticsReporting: false, + context, + }); + this._app = app; + this.assemblyDir = app.outdir; + this.createFunc(this); + app.node.children.forEach((child) => { + if (Stack.isPulumiStack(child)) { + this.stacks[child.artifactId] = child; + } + }); + return app.synth().directory; } } +export interface StackOptions extends pulumi.ComponentResourceOptions { + props: cdk.StackProps; +} + /** * A Construct that represents an AWS CDK stack deployed with Pulumi. * @@ -61,31 +114,40 @@ class StackComponent extends pulumi.ComponentResource implements StackComponentR * 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). */ -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; - +export class Stack extends PulumiStack { /** - * 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 } = {}; + public static isPulumiStack(x: any): x is Stack { + return x !== null && typeof x === 'object' && STACK_SYMBOL in x; + } - /** @internal */ - app: cdk.App; + // // The URN of the underlying Pulumi component. + // urn!: pulumi.Output; + // resolveURN!: (urn: pulumi.Output) => void; + // rejectURN!: (error: any) => void; /** @internal */ - options: StackOptions | undefined; + app: cdk.App; // 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; + + /** + * @internal + */ + public options?: StackOptions; + + /** @internal */ + public outdir: string; + + private pulumiApp: App; /** * Create and register an AWS CDK stack deployed with Pulumi. @@ -93,51 +155,16 @@ 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 app = new cdk.App({ - 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, - }, - }); - - super(app, name, options?.props); - - this.app = app; + constructor(app: App, name: string, options?: StackOptions) { + super(app.app, name, options?.props); + Object.defineProperty(this, STACK_SYMBOL, { value: true }); + this.pulumiApp = 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; - }); - } + this.outdir = app.assemblyDir; + this.app = app.app; - /** - * 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 = this.pulumiApp.converter.then((converter) => converter.stacks.get(this.artifactId)!); } /** diff --git a/src/types.ts b/src/types.ts index 0cb3ccaa..e15432e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,53 @@ import * as pulumi from '@pulumi/pulumi'; -import { Stack, StackProps } from 'aws-cdk-lib/core'; +import { Stack, StackProps, AppProps, App } from 'aws-cdk-lib/core'; import { ResourceMapping } from './interop'; + +export abstract class PulumiStack extends Stack { + /** + * 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 readonly outputs: { [outputId: string]: pulumi.Output } = {}; + + constructor(app: App, name: string, options?: StackProps) { + super(app, name, options); + } + /** @internal */ + registerOutput(outputId: string, output: any) { + this.outputs[outputId] = pulumi.output(output); + } +} +/** + * Options specific to the Stack component. + */ +export interface AppOptions extends pulumi.ComponentResourceOptions { + /** + * Specify the CDK Stack properties to asociate with the stack. + */ + props?: AppProps; + + /** + * Defines 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. + * + * @param logicalId The logical ID of the resource being mapped. + * @param typeName The CloudFormation type name of the resource being mapped. + * @param props The bag of input properties to the CloudFormation resource being mapped. + * @param 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. + */ + remapCloudControlResource?( + logicalId: string, + typeName: string, + props: any, + options: pulumi.ResourceOptions, + ): ResourceMapping | undefined; +} /** * Options specific to the Stack component. */ @@ -46,7 +93,7 @@ export enum PulumiProvider { * 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 */ -export abstract class StackComponentResource extends pulumi.ComponentResource { +export abstract class AppComponent extends pulumi.ComponentResource { public abstract name: string; /** @@ -57,22 +104,15 @@ export abstract class StackComponentResource extends pulumi.ComponentResource { /** * The Stack that creates this component */ - public abstract stack: Stack; - - /** - * Any stack options that are supplied by the user - * @internal - */ - public abstract options?: StackOptions; + public readonly stacks: { [artifactId: string]: PulumiStack } = {}; /** - * Register pulumi outputs to the stack * @internal */ - abstract registerOutput(outputId: string, output: any): void; + public abstract appOptions?: AppOptions; - constructor(id: string, options?: pulumi.ComponentResourceOptions) { - super('cdk:index:Stack', id, {}, options); + constructor(id: string, options?: AppOptions) { + super('cdk:index:App', id, options?.props, options); } } export type Mapping = { diff --git a/yarn.lock b/yarn.lock index 7c54b8c4..0ae0c083 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"