diff --git a/src/cfn.ts b/src/cfn.ts index 544b6083..37b92377 100644 --- a/src/cfn.ts +++ b/src/cfn.ts @@ -14,11 +14,26 @@ import { CfnDeletionPolicy } from 'aws-cdk-lib/core'; +/** + * Represents a CF parameter declaration from the Parameters template section. + * + * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html + */ export interface CloudFormationParameter { + /** + * DataType such as 'String'. + */ readonly Type: string; + readonly Default?: any; } +export type CloudFormationParameterLogicalId = string; + +export interface CloudFormationParameterWithId extends CloudFormationParameter { + id: CloudFormationParameterLogicalId; +} + export interface CloudFormationResource { readonly Type: string; readonly Properties: any; diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index f69ae395..30125beb 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -25,6 +25,9 @@ import { getPartition } from '@pulumi/aws-native/getPartition'; import { mapToCustomResource } from '../custom-resource-mapping'; import { processSecretsManagerReferenceValue } from './secrets-manager-dynamic'; import * as intrinsics from './intrinsics'; +import { CloudFormationParameter, CloudFormationParameterLogicalId, CloudFormationParameterWithId } from '../cfn'; +import { Metadata, PulumiResource } from '../pulumi-metadata'; +import { PulumiProvider } from '../types'; /** * AppConverter will convert all CDK resources into Pulumi resources. @@ -92,7 +95,7 @@ export class AppConverter { * StackConverter converts all of the resources in a CDK stack to Pulumi resources */ export class StackConverter extends ArtifactConverter implements intrinsics.IntrinsicContext { - readonly parameters = new Map(); + readonly parameters = new Map(); readonly resources = new Map>(); readonly constructs = new Map(); private readonly cdkStack: cdk.Stack; @@ -377,7 +380,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr const ref = obj.Ref; if (ref) { - return this.resolveRef(ref); + return intrinsics.ref.evaluate(this, [ref]); } const keys = Object.keys(obj); @@ -417,8 +420,15 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr return obj?.Ref === 'AWS::NoValue'; } - private resolveOutput(repr: OutputRepr): pulumi.Output { - return OutputMap.instance().lookupOutput(repr)!; + /** + * @internal + */ + public resolveOutput(repr: OutputRepr): pulumi.Output { + const result = OutputMap.instance().lookupOutput(repr); + if (result === undefined) { + throw new Error(`@pulumi/pulumi-cdk internal failure: unable to resolveOutput ${repr.PulumiOutput}`); + } + return result; } private resolveIntrinsic(fn: string, params: any) { @@ -475,7 +485,8 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr const [template, _vars] = typeof params === 'string' ? [params, undefined] : [params[0] as string, params[1]]; - const parts: string[] = []; + // parts may contain pulumi.Output values. + const parts: any[] = []; for (const part of parseSub(template)) { parts.push(part.str); @@ -483,7 +494,7 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr if (part.ref.attr !== undefined) { parts.push(this.resolveAtt(part.ref.id, part.ref.attr!)); } else { - parts.push(this.resolveRef(part.ref.id)); + parts.push(intrinsics.ref.evaluate(this, [part.ref.id])); } } } @@ -557,47 +568,6 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr } } - private resolveRef(target: any): any { - if (typeof target !== 'string') { - return this.resolveOutput(target); - } - - switch (target) { - case 'AWS::AccountId': - return getAccountId({ parent: this.app.component }).then((r) => r.accountId); - case 'AWS::NoValue': - return undefined; - case 'AWS::Partition': - return getPartition({ parent: this.app.component }).then((p) => p.partition); - case 'AWS::Region': - return getRegion({ parent: this.app.component }).then((r) => r.region); - case 'AWS::URLSuffix': - return getUrlSuffix({ parent: this.app.component }).then((r) => r.urlSuffix); - case 'AWS::NotificationARNs': - case 'AWS::StackId': - case 'AWS::StackName': - // These are typically used in things like names or descriptions so I think - // the stack node id is a good substitute. - return this.cdkStack.node.id; - } - - const mapping = this.lookup(target); - if ((mapping).value !== undefined) { - return (mapping).value; - } - // Due to https://github.com/pulumi/pulumi-cdk/issues/173 we have some - // resource which we have to special case the `id` attribute. The `Resource.id` - // will not contain the correct value - const map = >mapping; - if (map.attributes && 'id' in map.attributes) { - return map.attributes.id; - } else if (aws.cloudformation.CustomResourceEmulator.isInstance(map.resource)) { - // Custom resources have a `physicalResourceId` that is used for Ref - return map.resource.physicalResourceId; - } - return ((>mapping).resource).id; - } - private lookup(logicalId: string): Mapping | { value: any } { const targetParameter = this.parameters.get(logicalId); if (targetParameter !== undefined) { @@ -666,4 +636,46 @@ export class StackConverter extends ArtifactConverter implements intrinsics.Intr apply(result: intrinsics.Result, fn: (value: U) => intrinsics.Result): intrinsics.Result { return lift(fn, result); } + + findParameter(parameterLogicalId: CloudFormationParameterLogicalId): CloudFormationParameterWithId | undefined { + const p: CloudFormationParameter | undefined = (this.stack.parameters || {})[parameterLogicalId]; + return p ? { ...p, id: parameterLogicalId } : undefined; + } + + evaluateParameter(param: CloudFormationParameterWithId): intrinsics.Result { + const value = this.parameters.get(param.id); + if (value === undefined) { + throw new Error(`No value for the CloudFormation "${param.id}" parameter`); + } + return value; + } + + findResourceMapping(resourceLogicalID: string): Mapping | undefined { + return this.resources.get(resourceLogicalID); + } + + tryFindResource(cfnType: string): PulumiResource | undefined { + const m = new Metadata(PulumiProvider.AWS_NATIVE); + return m.tryFindResource(cfnType); + } + + getStackNodeId(): intrinsics.Result { + return this.cdkStack.node.id; + } + + getAccountId(): intrinsics.Result { + return getAccountId({ parent: this.app.component }).then((r) => r.accountId); + } + + getRegion(): intrinsics.Result { + return getRegion({ parent: this.app.component }).then((r) => r.region); + } + + getPartition(): intrinsics.Result { + return getPartition({ parent: this.app.component }).then((p) => p.partition); + } + + getURLSuffix(): intrinsics.Result { + return getUrlSuffix({ parent: this.app.component }).then((r) => r.urlSuffix); + } } diff --git a/src/converters/intrinsics.ts b/src/converters/intrinsics.ts index bcd05294..68ddf87a 100644 --- a/src/converters/intrinsics.ts +++ b/src/converters/intrinsics.ts @@ -12,7 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. +import * as aws from '@pulumi/aws-native'; import * as equal from 'fast-deep-equal'; +import * as pulumi from '@pulumi/pulumi'; +import { debug } from '@pulumi/pulumi/log'; +import { CloudFormationParameterWithId } from '../cfn'; +import { Mapping } from '../types'; +import { PulumiResource } from '../pulumi-metadata'; +import { toSdkName } from '../naming'; +import { OutputRepr, isOutputReprInstance } from '../output-map'; /** * Models a CF Intrinsic Function. @@ -84,6 +92,26 @@ export interface IntrinsicContext { */ evaluate(expression: Expression): Result; + /** + * Resolves a logical parameter ID to a parameter, or indicates that no such parameter is defined on the template. + */ + findParameter(parameterLogicalID: string): CloudFormationParameterWithId | undefined; + + /** + * Resolves a logical resource ID to a Mapping. + */ + findResourceMapping(resourceLogicalID: string): Mapping | undefined; + + /** + * Find the current value of a given Cf parameter. + */ + evaluateParameter(param: CloudFormationParameterWithId): Result; + + /** + * Find the value of an `OutputRepr`. + */ + resolveOutput(repr: OutputRepr): Result; + /** * If result succeeds, use its value to call `fn` and proceed with what it returns. * @@ -99,7 +127,41 @@ export interface IntrinsicContext { /** * Succeed with a given value. */ - succeed(r: T): Result; + succeed(r: pulumi.Input): Result; + + /** + * Pulumi metadata source that may inform the intrinsic evaluation. + */ + tryFindResource(cfnType: string): PulumiResource | undefined; + + /** + * Gets the CDK Stack Node ID. + */ + getStackNodeId(): Result; + + /** + * The AWS account ID. + */ + getAccountId(): Result; + + /** + * The AWS Region. + */ + getRegion(): Result; + + /** + * The AWS partition. + */ + getPartition(): Result; + + /** + * The URL suffix. + * + * Quoting the docs: "The suffix is typically amazonaws.com, but might differ by Region". + * + * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html#cfn-pseudo-param-urlsuffix + */ + getURLSuffix(): Result; } /** @@ -251,6 +313,131 @@ export const fnEquals: Intrinsic = { }, }; +/** + * Ref intrinsic resolves pseudo-parameters, parameter logical IDs or resource logical IDs to their values. + * + * If the argument to a Ref intrinsic is not a string literal, it may be another CF expression with intrinsic functions + * that needs to be evaluated first. + * + * See also: + * + * - https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-ref.html + */ +export const ref: Intrinsic = { + name: 'Ref', + evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + if (params.length != 1) { + return ctx.fail(`Ref intrinsic expects exactly 1 param, got ${params.length}`); + } + const param = params[0]; + + // Although not part of the CF spec, Output values are passed through CDK tokens as Ref structures; therefore + // Pulumi Ref intrinsic receives them and has to handle them. + if (isOutputReprInstance(param)) { + return ctx.resolveOutput(param); + } + + // Unless the parameter is a literal string, it may be another expression. + // + // CF docs: "When the AWS::LanguageExtensions transform is used, you can use intrinsic functions..". + if (typeof param !== 'string') { + const s = ctx.apply(ctx.evaluate(param), (p) => mustBeString(ctx, p)); + return ctx.apply(s, (name) => evaluateRef(ctx, name)); + } + return evaluateRef(ctx, param); + }, +}; + +/** + * See `ref`. + */ +function evaluateRef(ctx: IntrinsicContext, param: string): Result { + // Handle pseudo-parameters. + // See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html + switch (param) { + case 'AWS::AccountId': + return ctx.getAccountId(); + case 'AWS::NoValue': + return ctx.succeed(undefined); + case 'AWS::Partition': + return ctx.getPartition(); + case 'AWS::Region': + return ctx.getRegion(); + case 'AWS::URLSuffix': + return ctx.getURLSuffix(); + case 'AWS::NotificationARNs': + return ctx.fail('AWS::NotificationARNs pseudo-parameter is not yet supported in pulumi-cdk'); + case 'AWS::StackId': + case 'AWS::StackName': { + // TODO[pulumi/pulumi-cdk#246]: these pseudo-parameters are typically used in things like names or descriptions + // so it should be safe to substitute with a stack node ID for most applications. + const stackNodeId = ctx.getStackNodeId(); + debug(`pulumi-cdk is replacing a Ref to a CF pseudo-parameter ${param} with the stack node ID`); + return stackNodeId; + } + } + + // Handle Cf template parameters. + const cfParam = ctx.findParameter(param); + if (cfParam !== undefined) { + return ctx.evaluateParameter(cfParam); + } + + // Handle references to resources. + const map = ctx.findResourceMapping(param); + if (map !== undefined) { + if (map.attributes && 'id' in map.attributes) { + // Users may override the `id` in a custom-supplied mapping, respect this. + return ctx.succeed(map.attributes.id); + } + if (aws.cloudformation.CustomResourceEmulator.isInstance(map.resource)) { + // Custom resources have a `physicalResourceId` that is used for Ref + return ctx.succeed(map.resource.physicalResourceId); + } + const resMeta = ctx.tryFindResource(map.resourceType); + + // If there is no metadata to suggest otherwise, assume that we can use the Pulumi id which typically will be + // the primaryIdentifier from CloudControl. + if (resMeta === undefined || !resMeta.cfRef || resMeta.cfRef.notSupportedYet) { + const cr = map.resource; // assume we have a custom resource. + return ctx.succeed(cr.id); + } + + // Respect metadata if it suggests Ref is not supported. + if (resMeta.cfRef.notSupported) { + return ctx.fail(`Ref intrinsic is not supported for the ${map.resourceType} resource type`); + } + + // At this point metadata should indicate which properties to extract from the resource to compute the ref. + const propNames: string[] = (resMeta.cfRef.properties || []) + .concat(resMeta.cfRef.property ? [resMeta.cfRef.property] : []) + .map((x) => toSdkName(x)); + + const propValues: any[] = []; + for (const p of propNames) { + if (!Object.prototype.hasOwnProperty.call(map.resource, p)) { + return ctx.fail(`Pulumi metadata notes a property "${p}" but no such property was found on a resource`); + } + propValues.push((map.resource)[p]); + } + + const delim: string = resMeta.cfRef!.delimiter || '|'; + + return ctx.apply(ctx.succeed(propValues), (resolvedValues) => { + let i = 0; + for (const v of resolvedValues) { + if (typeof v !== 'string') { + return ctx.fail(`Expected property "${propNames[i]}" to resolve to a string, got ${typeof v}`); + } + i++; + } + return ctx.succeed(resolvedValues.join(delim)); + }); + } + + return ctx.fail(`Ref intrinsic unable to resolve ${param}: not a known logical resource or parameter reference`); +} + /** * Recognize forms such as {"Condition" : "SomeOtherCondition"}. If recognized, returns the conditionName. */ @@ -285,6 +472,14 @@ function mustBeBoolean(ctx: IntrinsicContext, r: any): Result { } } +function mustBeString(ctx: IntrinsicContext, r: any): Result { + if (typeof r === 'string') { + return ctx.succeed(r); + } else { + return ctx.fail(`Expected a string, got ${typeof r}`); + } +} + function evaluateCondition(ctx: IntrinsicContext, conditionName: string): Result { const conditionExpr = ctx.findCondition(conditionName); if (conditionExpr === undefined) { diff --git a/src/output-map.ts b/src/output-map.ts index 575c009e..f3d25a6e 100644 --- a/src/output-map.ts +++ b/src/output-map.ts @@ -15,15 +15,51 @@ import * as pulumi from '@pulumi/pulumi'; const glob = global as any; +/** + * A serializable reference to an output. + * + * @internal + */ export interface OutputRef { + /** + * The name of this field has to be `Ref` so that `Token.asString` CDK functionality can be called on an `OutputRef` + * and it can travel through the CDK internals. An alternative to this special encoding could be implementing CDK + * `IResolvable` on these values. + */ Ref: OutputRepr; } +/** + * See `OutputRef`. + * + * @internal + */ export interface OutputRepr { + /** + * An arbitrary integer identifying the output. + */ PulumiOutput: number; } +/** + * Recognize if something is an `OutputRepr`. + * + * @internal + */ +export function isOutputReprInstance(x: any): boolean { + return typeof x === 'object' && Object.prototype.hasOwnProperty.call(x, 'PulumiOutput'); +} + +/** + * Stores Pulumi Output values in memory so that they can be encoded into serializable `OutputRef` values with unique + * integers for CDK interop. + * + * @internal + */ export class OutputMap { + /** + * Get the global instance. + */ public static instance(): OutputMap { if (glob.__pulumiOutputMap === undefined) { glob.__pulumiOutputMap = new OutputMap(); @@ -34,12 +70,18 @@ export class OutputMap { private readonly outputMap = new Map>(); private outputId = 0; + /** + * Stores a reference to a Pulumi Output in the map and returns a serializable reference. + */ public registerOutput(o: pulumi.Output): OutputRef { const id = this.outputId++; this.outputMap.set(id, o); return { Ref: { PulumiOutput: id } }; } + /** + * Tries to look up an output reference in the map and find the original value. + */ public lookupOutput(o: OutputRepr): pulumi.Output | undefined { return this.outputMap.get(o.PulumiOutput); } diff --git a/src/pulumi-metadata.ts b/src/pulumi-metadata.ts index 4a4fdd43..2699c9b2 100644 --- a/src/pulumi-metadata.ts +++ b/src/pulumi-metadata.ts @@ -45,11 +45,22 @@ export class Metadata { * @throws UnknownCfnType if the resource is not found */ public findResource(cfnType: string): PulumiResource { + const r = this.tryFindResource(cfnType); + if (r === undefined) { + throw new UnknownCfnType(cfnType); + } + return r; + } + + /** + * Non-throwing version of `findResource`. + */ + public tryFindResource(cfnType: string): PulumiResource | undefined { const pType = typeToken(cfnType); if (pType in this.pulumiMetadata.resources) { return this.pulumiMetadata.resources[pType]; } - throw new UnknownCfnType(cfnType); + return undefined; } public types(): { [key: string]: PulumiType } { @@ -120,11 +131,51 @@ export interface PulumiProperty extends PulumiPropertyItems { items?: PulumiPropertyItems; } +/** + * The schema for an individual resource. + */ export interface PulumiResource { + cfRef?: CfRefBehavior; inputs: { [key: string]: PulumiProperty }; outputs: { [key: string]: PulumiProperty }; } +/** + * Metadata predicting the behavior of CF Ref intrinsic for a given resource. + * + * @internal + */ +export interface CfRefBehavior { + /** + * If set, indicates that Ref will return the value of the given Resource property directly. + * + * The property name is a CF name such as "GroupId". + */ + property?: string; + + /** + * If set, indicates that Ref will return a string value obtained by joining several Resource properties with a + * delimiter, typically "|". + */ + properties?: string[]; + + /** + * Delimiter for `properties`, typically "|". + */ + delimiter?: string; + + /** + * If set, Ref is not supported for this resource in CF. + */ + notSupported?: boolean; + + /** + * If set, Ref is supported in CF but this metadata is not yet available in the Pulumi aws-native provider but might + * be added in a later version. + */ + notSupportedYet?: boolean; +} + /** * If a property is a JSON type then we need provide * the value as is, without further processing. diff --git a/tests/converters/intrinsics.test.ts b/tests/converters/intrinsics.test.ts index f8a66ab5..6e71aac6 100644 --- a/tests/converters/intrinsics.test.ts +++ b/tests/converters/intrinsics.test.ts @@ -1,4 +1,13 @@ +import * as aws from '@pulumi/aws-native'; +import * as pulumi from '@pulumi/pulumi'; import * as intrinsics from '../../src/converters/intrinsics'; +import { + CloudFormationParameter, + CloudFormationParameterLogicalId, + CloudFormationParameterWithId +} from '../../src/cfn'; +import { Mapping } from '../../src/types'; +import { PulumiResource } from '../../src/pulumi-metadata'; describe('Fn::If', () => { test('picks true', async () => { @@ -102,7 +111,6 @@ describe('Fn::And', () => { }); }) - describe('Fn::Not', () => { test('inverts false', async () => { const tc = new TestContext({}); @@ -167,6 +175,160 @@ describe('Fn::Equals', () => { }); }) +describe('Ref', () => { + test('resolves a parameter by its logical ID', async () => { + const tc = new TestContext({parameters: { + 'MyParam': {id: 'MyParam', Type: 'String', Default: 'MyParamValue'} + }}); + const result = runIntrinsic(intrinsics.ref, tc, ['MyParam']); + expect(result).toEqual(ok('MyParamValue')); + }); + + test('respects "id" resource mapping provided by the user', async () => { + const tc = new TestContext({resources: { + 'MyRes': { + resource: {}, + resourceType: 'AWS::S3::Bucket', + attributes: { + 'id': 'myID' + }, + }, + }}); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + expect(result).toEqual(ok('myID')); + }); + + test('resolves a CustomResource to its physical ID', async () => { + const tc = new TestContext({resources: { + 'MyRes': { + resource: { + __pulumiType: (aws.cloudformation.CustomResourceEmulator).__pulumiType, + physicalResourceId: 'physicalID', + }, + resourceType: 'AWS::CloudFormation::CustomResource', + }, + }}); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + expect(result).toEqual(ok('physicalID')); + }); + + test('fails if Pulumi metadata indicates Ref is not supported', async () => { + const tc = new TestContext({ + resources: { + 'MyRes': { + resource: {}, + resourceType: 'AWS::S3::Bucket', + }, + }, + pulumiMetadata: { + 'AWS::S3::Bucket': { + inputs: {}, + outputs: {}, + cfRef: { + notSupported: true, + } + }, + } + }); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + expect(result).toEqual(failed('Ref intrinsic is not supported for the AWS::S3::Bucket resource type')); + }); + + test('resolves to a property value indicated by Pulumi metadata', async () => { + const tc = new TestContext({ + resources: { + 'MyRes': { + resource: {stageName: 'my-stage'}, + resourceType: 'AWS::ApiGateway::Stage', + }, + }, + pulumiMetadata: { + 'AWS::ApiGateway::Stage': { + inputs: {}, + outputs: {}, + cfRef: { + property: 'StageName', + } + }, + } + }); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + expect(result).toEqual(ok('my-stage')); + }); + + test('resolves to a join of several property values indicated by Pulumi metadata', async () => { + const tc = new TestContext({ + resources: { + 'MyRes': { + resource: {roleName: 'my-role', policyName: 'my-policy'}, + resourceType: 'AWS::IAM::RolePolicy', + }, + }, + pulumiMetadata: { + 'AWS::IAM::RolePolicy': { + inputs: {}, + outputs: {}, + cfRef: { + properties: ['PolicyName', 'RoleName'], + delimiter: '|', + } + }, + } + }); + const result = runIntrinsic(intrinsics.ref, tc, ['MyRes']); + expect(result).toEqual(ok('my-policy|my-role')); + }); + + test('fails if called with an ID that does not resolve', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.ref, tc, ['MyParam']); + expect(result).toEqual(failed('Ref intrinsic unable to resolve MyParam: not a known logical resource or parameter reference')); + }); + + test('evaluates inner expressions before resolving', async () => { + const tc = new TestContext({ + parameters: { + 'MyParam': {id: 'MyParam', Type: 'String', Default: 'MyParamValue'} + }, + conditions: { + 'MyCondition': true, + }, + }); + const result = runIntrinsic(intrinsics.ref, tc, [{'Fn::If': ['MyCondition', 'MyParam', 'MyParam2']}]); + expect(result).toEqual(ok('MyParamValue')); + }); + + test('resolves pseudo-parameters', async () => { + const stackNodeId = 'stackNodeId'; + const tc = new TestContext({ + parameters: { + 'MyParam': {id: 'MyParam', Type: 'String', Default: 'MyParamValue'} + }, + conditions: { + 'MyCondition': true, + }, + accountId: '012345678901', + region: 'us-west-2', + partition: 'aws-us-gov', + urlSuffix: 'amazonaws.com.cn', + stackNodeId: stackNodeId, + }); + + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::AccountId'])).toEqual(ok('012345678901')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::Region'])).toEqual(ok('us-west-2')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::Partition'])).toEqual(ok('aws-us-gov')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::URLSuffix'])).toEqual(ok('amazonaws.com.cn')); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::NoValue'])).toEqual(ok(undefined)); + + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::NotificationARNs'])) + .toEqual(failed('AWS::NotificationARNs pseudo-parameter is not yet supported in pulumi-cdk')); + + // These are approximations; testing the current behavior for completeness sake. + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackId'])).toEqual(ok(stackNodeId)); + expect(runIntrinsic(intrinsics.ref, tc, ['AWS::StackName'])).toEqual(ok(stackNodeId)); + }); +}) + function runIntrinsic(fn: intrinsics.Intrinsic, tc: TestContext, args: intrinsics.Expression[]): TestResult { const result: TestResult = (fn.evaluate(tc, args)); return result; @@ -185,23 +347,84 @@ function failed(errorMessage: string): TestResult { } class TestContext implements intrinsics.IntrinsicContext { + accountId: string; + region: string; + partition: string; + urlSuffix: string; + stackNodeId: string; conditions: { [id: string]: intrinsics.Expression }; + parameters: { [id: CloudFormationParameterLogicalId]: CloudFormationParameterWithId }; + resources: { [id: string]: Mapping }; + pulumiMetadata: { [cfnType: string]: PulumiResource }; + + constructor(args: { + accountId?: string; + region?: string; + partition?: string; + urlSuffix?: string; + stackNodeId?: string; + conditions?: { [id: string]: intrinsics.Expression }, + parameters?: { [id: CloudFormationParameterLogicalId]: CloudFormationParameterWithId }, + resources?: { [id: string]: Mapping }, + pulumiMetadata?: { [cfnType: string]: PulumiResource }, + }) { + this.stackNodeId = args.stackNodeId || ''; + this.accountId = args.accountId || ''; + this.partition = args.partition || ''; + this.region = args.region || ''; + this.urlSuffix = args.urlSuffix || ''; + this.conditions = args.conditions || {}; + this.parameters = args.parameters || {}; + this.resources = args.resources || {}; + this.pulumiMetadata = args.pulumiMetadata || {}; + } - constructor(args: {conditions?: { [id: string]: intrinsics.Expression }}) { - if (args.conditions) { - this.conditions = args.conditions; - } else { - this.conditions = {}; + tryFindResource(cfnType: string): PulumiResource|undefined { + if (this.pulumiMetadata.hasOwnProperty(cfnType)) { + return this.pulumiMetadata[cfnType]; + } + }; + + findParameter(parameterLogicalID: string): CloudFormationParameterWithId | undefined { + if (this.parameters.hasOwnProperty(parameterLogicalID)) { + return this.parameters[parameterLogicalID]; } } + evaluateParameter(param: CloudFormationParameter): intrinsics.Result { + // Simplistic but sufficient for this test suite. + return this.succeed(param.Default!); + } + findCondition(conditionName: string): intrinsics.Expression|undefined { if (this.conditions.hasOwnProperty(conditionName)) { return this.conditions[conditionName]; } } + findResourceMapping(resourceLogicalID: string): Mapping | undefined { + if (this.resources.hasOwnProperty(resourceLogicalID)) { + return this.resources[resourceLogicalID]; + } + } + evaluate(expression: intrinsics.Expression): intrinsics.Result { + // Evaluate known heuristics. + const known = [intrinsics.fnAnd, + intrinsics.fnEquals, + intrinsics.fnIf, + intrinsics.fnNot, + intrinsics.fnOr, + intrinsics.ref]; + if (typeof expression === 'object' && Object.keys(expression).length == 1) { + for (const k of known) { + if (k.name === Object.keys(expression)[0]) { + const args = expression[k.name]; + return k.evaluate(this, args) + } + } + } + // Self-evaluate the expression. This is very incomplete. const result: TestResult = {'ok': true, value: expression}; return result; @@ -225,4 +448,24 @@ class TestContext implements intrinsics.IntrinsicContext { const result: TestResult = {'ok': true, value: r}; return result; } + + getAccountId(): intrinsics.Result { + return this.succeed(this.accountId); + } + + getRegion(): intrinsics.Result { + return this.succeed(this.region); + } + + getPartition(): intrinsics.Result { + return this.succeed(this.partition); + } + + getURLSuffix(): intrinsics.Result { + return this.succeed(this.urlSuffix); + } + + getStackNodeId(): intrinsics.Result { + return this.succeed(this.stackNodeId); + } }