Repository: pulumi/pulumi-cdk
Pull request #222 (2024-11-15 18:46:58)

Support Condition Functions
Support [Condition Functions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html).

Fixes https://github.com/pulumi/pulumi-cdk/issues/206

With this change, if CDK emits CF templates with Condition Functions, pulumi-cdk evaluates these. This unlocks support for new constructs such as the Kinesis Stream construct.


t0yv0: I'm running into casing problems here now:

```
{
  "method": "/pulumirpc.ResourceProvider/Check",
  "request": {
    "urn": "urn:pulumi:cdk-kinesis-test-1::pulumi-kinesis::cdk:index:App$cdk:construct:teststack$cdk:construct:aws-cdk-lib/aws_kinesis:Stream$aws-native:kinesis:Stream::mystreamF3EDC6B7",
    "olds": {},
    "news": {
      "name": "my-stream",
      "retentionPeriodHours": 24,
      "shardCount": 3,
      "streamEncryption": {
        "EncryptionType": "KMS",
        "KeyId": "alias/aws/kinesis"
      }
    },
    "randomSeed": "o0Ct4XOAo2WKrbNOCMK+NEErwjdWTNaIIhT8jsOFk0Q="
  },
  "response": {
    "failures": [
      {
        "property": "streamEncryption.EncryptionType",
        "reason": "unknown property streamEncryption.EncryptionType"
      },
      {
        "property": "streamEncryption.KeyId",
        "reason": "unknown property streamEncryption.KeyId"
      }
    ]
  },
  "metadata": {
    "kind": "resource",
    "mode": "client",
    "name": "aws-native"
  }
}

```


I think we're sending incorrect-cased sub-properties to aws-native. Digging in a bit, not sure if preexisting problem or something introduced just here. 
t0yv0: This depends on https://github.com/pulumi/pulumi-cdk/pull/228 but once that is in hopefully can pass integration tests here.

Patch:
diff --git a/integration/kinesis/Pulumi.yaml b/integration/kinesis/Pulumi.yaml
new file mode 100644
index 0000000..0767e2a
--- /dev/null
+++ b/integration/kinesis/Pulumi.yaml
@@ -0,0 +1,3 @@
+name: pulumi-kinesis
+runtime: nodejs
+description: kinesis integration test
diff --git a/integration/kinesis/index.ts b/integration/kinesis/index.ts
new file mode 100644
index 0000000..0289a73
--- /dev/null
+++ b/integration/kinesis/index.ts
@@ -0,0 +1,28 @@
+import * as pulumi from '@pulumi/pulumi';
+import * as kinesis from 'aws-cdk-lib/aws-kinesis';
+import * as pulumicdk from '@pulumi/cdk';
+import { Duration } from 'aws-cdk-lib/core';
+
+class KinesisStack extends pulumicdk.Stack {
+    kinesisStreamName: pulumi.Output<string>;
+
+    constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
+        super(app, id, options);
+
+        const kStream = new kinesis.Stream(this, 'my-stream', {
+            shardCount: 3,
+            retentionPeriod: Duration.hours(24),
+        })
+
+        this.kinesisStreamName = this.asOutput(kStream.streamName);
+    }
+}
+
+const app = new pulumicdk.App('app', (scope: pulumicdk.App) => {
+    const stack = new KinesisStack(scope, 'teststack');
+    return {
+        kinesisStreamName: stack.kinesisStreamName,
+    };
+});
+
+export const kinesisStreamName = app.outputs['kinesisStreamName'];
diff --git a/integration/kinesis/package.json b/integration/kinesis/package.json
new file mode 100644
index 0000000..18e6e26
--- /dev/null
+++ b/integration/kinesis/package.json
@@ -0,0 +1,15 @@
+{
+    "name": "pulumi-kinesis",
+    "devDependencies": {
+        "@types/node": "^10.0.0"
+    },
+    "dependencies": {
+        "@pulumi/aws": "^6.0.0",
+        "@pulumi/aws-native": "^1.8.0",
+        "@pulumi/cdk": "^0.5.0",
+        "@pulumi/pulumi": "^3.0.0",
+        "aws-cdk-lib": "2.156.0",
+        "constructs": "10.3.0",
+        "esbuild": "^0.24.0"
+    }
+}
diff --git a/integration/kinesis/tsconfig.json b/integration/kinesis/tsconfig.json
new file mode 100644
index 0000000..eac442c
--- /dev/null
+++ b/integration/kinesis/tsconfig.json
@@ -0,0 +1,18 @@
+{
+    "compilerOptions": {
+        "strict": true,
+        "outDir": "bin",
+        "target": "es2019",
+        "module": "commonjs",
+        "moduleResolution": "node",
+        "sourceMap": true,
+        "experimentalDecorators": true,
+        "pretty": true,
+        "noFallthroughCasesInSwitch": true,
+        "noImplicitReturns": true,
+        "forceConsistentCasingInFileNames": true
+    },
+    "include": [
+        "./*.ts"
+    ]
+}
diff --git a/package.json b/package.json
index a1ff75e..2a51545 100644
--- a/package.json
+++ b/package.json
@@ -63,7 +63,8 @@
     "@types/glob": "^8.1.0",
     "archiver": "^7.0.1",
     "cdk-assets": "^2.154.8",
-    "fs-extra": "^11.2.0"
+    "fs-extra": "^11.2.0",
+    "fast-deep-equal": "^3.1.3"
   },
   "scripts": {
     "set-version": "sed -i.bak -e \"s/\\${VERSION}/$(pulumictl get version --language javascript)/g\" package.json && rm package.json.bak",
diff --git a/src/assembly/stack.ts b/src/assembly/stack.ts
index f3e5538..7c119e0 100644
--- a/src/assembly/stack.ts
+++ b/src/assembly/stack.ts
@@ -1,6 +1,12 @@
 import * as path from 'path';
 import { DestinationIdentifier, FileManifestEntry } from 'cdk-assets';
-import { CloudFormationMapping, CloudFormationParameter, CloudFormationResource, CloudFormationTemplate } from '../cfn';
+import {
+    CloudFormationMapping,
+    CloudFormationParameter,
+    CloudFormationResource,
+    CloudFormationTemplate,
+    CloudFormationCondition,
+} from '../cfn';
 import { ConstructTree, StackMetadata } from './types';
 import { FileAssetPackaging, FileDestination } from 'aws-cdk-lib/cloud-assembly-schema';
 
@@ -107,7 +113,7 @@ export class StackManifest {
     /**
      * Map of resource logicalId to CloudFormation template resource fragment
      */
-    private readonly resources: { [logicalId: string]: CloudFormationResource };
+    public readonly resources: { [logicalId: string]: CloudFormationResource };
 
     /**
      * The Mappings from the CFN Stack
@@ -116,10 +122,15 @@ export class StackManifest {
     public readonly mappings?: CloudFormationMapping;
 
     /**
+     * CloudFormation conditions from the template.
      *
+     * @internal
      */
+    public readonly conditions?: { [id: string]: CloudFormationCondition };
+
     private readonly metadata: StackMetadata;
     public readonly dependencies: string[];
+
     constructor(props: StackManifestProps) {
         this.dependencies = props.dependencies;
         this.outputs = props.template.Outputs;
@@ -133,6 +144,7 @@ export class StackManifest {
             throw new Error('CloudFormation template has no resources!');
         }
         this.resources = props.template.Resources;
+        this.conditions = props.template.Conditions;
     }
 
     /**
diff --git a/src/cfn.ts b/src/cfn.ts
index 95b8ff3..544b608 100644
--- a/src/cfn.ts
+++ b/src/cfn.ts
@@ -32,10 +32,21 @@ export type CloudFormationMappingValue = string | string[];
 export type TopLevelMapping = { [key: string]: SecondLevelMapping };
 export type SecondLevelMapping = { [key: string]: CloudFormationMappingValue };
 
+/**
+ * Models CF conditions. These are possibly nested expressions evaluating to a boolean.
+ *
+ * Example value:
+ *
+ *     {"Fn::Equals": [{"Ref": "EnvType"}, "prod"]}
+ *
+ * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html
+ */
+export interface CloudFormationCondition {}
+
 export interface CloudFormationTemplate {
     Parameters?: { [id: string]: CloudFormationParameter };
     Resources?: { [id: string]: CloudFormationResource };
-    Conditions?: { [id: string]: any };
+    Conditions?: { [id: string]: CloudFormationCondition };
     Mappings?: CloudFormationMapping;
     Outputs?: { [id: string]: any };
 }
diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts
index 22cc65f..08b4656 100644
--- a/src/converters/app-converter.ts
+++ b/src/converters/app-converter.ts
@@ -24,6 +24,7 @@ import { parseSub } from '../sub';
 import { getPartition } from '@pulumi/aws-native/getPartition';
 import { mapToCustomResource } from '../custom-resource-mapping';
 import { processSecretsManagerReferenceValue } from './secrets-manager-dynamic';
+import * as intrinsics from "./intrinsics";
 
 /**
  * AppConverter will convert all CDK resources into Pulumi resources.
@@ -90,7 +91,7 @@ export class AppConverter {
 /**
  * StackConverter converts all of the resources in a CDK stack to Pulumi resources
  */
-export class StackConverter extends ArtifactConverter {
+export class StackConverter extends ArtifactConverter implements intrinsics.IntrinsicContext {
     readonly parameters = new Map<string, any>();
     readonly resources = new Map<string, Mapping<pulumi.Resource>>();
     readonly constructs = new Map<ConstructInfo, pulumi.Resource>();
@@ -543,6 +544,18 @@ export class StackConverter extends ArtifactConverter {
                 }, this.processIntrinsics(params));
             }
 
+            case 'Fn::Equals': {
+                return intrinsics.fnEquals.evaluate(this, params);
+            }
+
+            case 'Fn::If': {
+                return intrinsics.fnIf.evaluate(this, params);
+            }
+
+            case 'Fn::Or': {
+                return intrinsics.fnOr.evaluate(this, params);
+            }
+
             default:
                 throw new Error(`unsupported intrinsic function ${fn} (params: ${JSON.stringify(params)})`);
         }
@@ -633,4 +646,28 @@ export class StackConverter extends ArtifactConverter {
         }
         return d.value;
     }
+
+    findCondition(conditionName: string): intrinsics.Expression|undefined {
+        if (conditionName in (this.stack.conditions||{})) {
+            return this.stack.conditions![conditionName];
+        } else {
+            return undefined;
+        }
+    }
+
+    evaluate(expression: intrinsics.Expression): intrinsics.Result<any> {
+        return this.processIntrinsics(expression);
+    }
+
+    fail(msg: string): intrinsics.Result<any> {
+        throw new Error(msg);
+    }
+
+    succeed<T>(r: T): intrinsics.Result<T> {
+        return <any>r;
+    }
+
+    apply<T,U>(result: intrinsics.Result<T>, fn: (value: U) => intrinsics.Result<U>): intrinsics.Result<U> {
+        return lift(fn, result);
+    }
 }
diff --git a/src/converters/intrinsics.ts b/src/converters/intrinsics.ts
new file mode 100644
index 0000000..b0daeb1
--- /dev/null
+++ b/src/converters/intrinsics.ts
@@ -0,0 +1,295 @@
+// Copyright 2016-2024, Pulumi Corporation.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// 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 equal from 'fast-deep-equal';
+
+/**
+ * Models a CF Intrinsic Function.
+ *
+ * CloudFormation (CF) intrinsic functions need to be implemented for @pulumi/pulumi-cdk since CDK may emit them in the
+ * synthesized CF template.
+ *
+ * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html
+ *
+ * @internal
+ */
+export interface Intrinsic {
+    /**
+     * The name of the intrinsic function such as 'Fn::If'.
+     */
+    name: string;
+
+    /**
+     * Executes the logic to evaluate CF expressions and compute the result.
+     *
+     * Most intrinsics need to use IntrinsicContext.evaluate right away to find the values of parameters before
+     * processing them. Conditional intrinsics such as 'Fn::If' or 'Fn::Or' are an exception to this and need to
+     * evaluate their parameters only when necessary.
+     */
+    evaluate(ctx: IntrinsicContext, params: Expression[]): Result<any>;
+}
+
+/**
+ * Models a CF expression. Currently this is just 'any' but eventually adding more structure and a separate
+ * parse/evaluate steps can help keeping the error messages tractable.
+ *
+ * See also CfnParse for inspiration:
+ *
+ * https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/helpers-internal/cfn-parse.ts#L347
+ *
+ * @internal
+ */
+export interface Expression {}
+
+/**
+ * Production code may have intermediate values occasionally wrapped in pulumi.Output<T>; this is currently somewhat
+ * difficult to test, so the essentials of pulumi.Output<T> are abstracted into a Result<T>.
+ *
+ * @internal
+ */
+// eslint-disable-next-line
+export interface Result<T> {}
+
+/**
+ * Context available when evaluating CF expressions.
+ *
+ * Note that `succeed`, `fail`, `apply` and `Result` expressions are abstracting the use of `pulumi.Input` to facilitate
+ * testing over a simpler structure without dealing with async evaluation.
+ *
+ * @internal
+ */
+export interface IntrinsicContext {
+
+    /**
+     * Lookup a CF Condition by its logical ID.
+     *
+     * If the condition is found, return the CF Expression with intrinsic function calls inside.
+     *
+     * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html
+     */
+    findCondition(conditionName: string): Expression|undefined;
+
+    /**
+     * Finds the value of a CF expression evaluating any intrinsic functions or references within.
+     */
+    evaluate(expression: Expression): Result<any>;
+
+    /**
+     * If result succeeds, use its value to call `fn` and proceed with what it returns.
+     *
+     * If result fails, do not call `fn` and proceed with the error message from `result`.
+     */
+    apply<T,U>(result: Result<T>, fn: (value: U) => Result<U>): Result<U>;
+
+    /**
+     * Fail with a given error message.
+     */
+    fail(msg: string): Result<any>;
+
+    /**
+     * Succeed with a given value.
+     */
+    succeed<T>(r: T): Result<T>;
+}
+
+/**
+ * "Fn::If": [condition_name, value_if_true, value_if_false]
+ *
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-i
+ *
+ * @internal
+ */
+export const fnIf: Intrinsic = {
+    name: 'Fn::If',
+    evaluate: (ctx: IntrinsicContext, params: Expression[]): Result<any> => {
+        if (params.length !== 3) {
+            return ctx.fail(`Expected 3 parameters, got ${ params.length }`);
+        }
+
+        if (typeof params[0] !== 'string') {
+            return ctx.fail('Expected the first parameter to be a condition name string literal');
+        }
+
+        const conditionName: string = params[0];
+        const exprIfTrue = params[1];
+        const exprIfFalse = params[2];
+
+        return ctx.apply(evaluateCondition(ctx, conditionName), ok => {
+            if (ok) {
+                return ctx.evaluate(exprIfTrue);
+            } else {
+                return ctx.evaluate(exprIfFalse);
+            }
+        });
+    }
+}
+
+/**
+ *
+ * From the docs: the minimum number of conditions that you can include is 2, and the maximum is 10.
+ *
+ * Example invocation:
+ *
+ *   "MyOrCondition": {
+ *     "Fn::Or" : [
+ *       {"Fn::Equals" : ["sg-mysggroup", {"Ref" : "ASecurityGroup"}]},
+ *       {"Condition" : "SomeOtherCondition"}
+ *     ]
+ *   }
+ *
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-or
+ */
+export const fnOr: Intrinsic = {
+    name: 'Fn::Or',
+    evaluate: (ctx: IntrinsicContext, params: Expression[]): Result<any> => {
+        if (params.length < 2) {
+            return ctx.fail(`Fn::Or expects at least 2 params, got ${params.length}`)
+        }
+        const reducer = (acc: Result<boolean>, expr: Expression) => ctx.apply(acc, ok => {
+            if (ok) {
+                return ctx.succeed(true);
+            } else {
+                return evaluateConditionSubExpression(ctx, expr);
+            }
+        })
+        return params.reduce(reducer, ctx.succeed(false));
+    }
+}
+
+
+/**
+ *
+ * From the docs: the minimum number of conditions that you can include is 2, and the maximum is 10.
+ *
+ * Example invocation:
+ *
+ *     "MyAndCondition": {
+ *        "Fn::And": [
+ *           {"Fn::Equals": ["sg-mysggroup", {"Ref": "ASecurityGroup"}]},
+ *           {"Condition": "SomeOtherCondition"}
+ *        ]
+ *     }
+ *
+ * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-and
+ */
+export const fnAnd: Intrinsic = {
+    name: 'Fn::And',
+    evaluate: (ctx: IntrinsicContext, params: Expression[]): Result<any> => {
+        if (params.length < 2) {
+            return ctx.fail(`Fn::And expects at least 2 params, got ${params.length}`)
+        }
+        const reducer = (acc: Result<boolean>, expr: Expression) => ctx.apply(acc, ok => {
+            if (!ok) {
+                return ctx.succeed(false);
+            } else {
+                return evaluateConditionSubExpression(ctx, expr);
+            }
+        })
+        return params.reduce(reducer, ctx.succeed(true));
+    }
+}
+
+
+/**
+ * Boolean negation. Expects exactly one argument.
+ *
+ * Example invocation:
+ *
+ *     "MyNotCondition" : {
+ *       "Fn::Not" : [{
+ *          "Fn::Equals" : [
+ *             {"Ref" : "EnvironmentType"},
+ *             "prod"
+ *          ]
+ *       }]
+ *     }
+ */
+export const fnNot: Intrinsic = {
+    name: 'Fn::Not',
+    evaluate: (ctx: IntrinsicContext, params: Expression[]): Result<any> => {
+        if (params.length != 1) {
+            return ctx.fail(`Fn::Not expects exactly 1 param, got ${params.length}`)
+        }
+        const x = evaluateConditionSubExpression(ctx, params[0]);
+        return ctx.apply(x, v => ctx.succeed(!v));
+    }
+}
+
+
+/**
+ * From the docs: Compares if two values are equal. Returns true if the two values are equal or false if they aren't.
+ *
+ * Fn::Equals: [value_1, value_2]
+ *
+ * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-not
+ *
+ */
+export const fnEquals: Intrinsic = {
+    name: 'Fn::Equals',
+    evaluate: (ctx: IntrinsicContext, params: Expression[]): Result<any> => {
+        if (params.length != 2) {
+            return ctx.fail(`Fn::Equals expects exactly 2 params, got ${params.length}`)
+        }
+        return ctx.apply(ctx.evaluate(params[0]), x =>
+            ctx.apply(ctx.evaluate(params[1]), y => {
+                if (equal(x, y)) {
+                    return ctx.succeed(true);
+                } else {
+                    return ctx.succeed(false);
+                }
+            }));
+    }
+}
+
+/**
+ * Recognize forms such as {"Condition" : "SomeOtherCondition"}. If recognized, returns the conditionName.
+ */
+function parseConditionExpr(raw: Expression): string|undefined {
+    if (typeof raw !== 'object' || !('Condition' in raw)) {
+        return undefined;
+    }
+    const cond = (<any>raw)['Condition'];
+    if (typeof cond !== 'string') {
+        return undefined;
+    }
+    return cond;
+}
+
+/**
+ * Like `ctx.evaluate` but also recognizes Condition sub-expressions as required by `Fn::Or`.
+ */
+function evaluateConditionSubExpression(ctx: IntrinsicContext, expr: Expression): Result<boolean> {
+    const firstExprConditonName = parseConditionExpr(expr);
+    if (firstExprConditonName !== undefined) {
+        return evaluateCondition(ctx, firstExprConditonName)
+    } else {
+        return ctx.apply(ctx.evaluate(expr), r => mustBeBoolean(ctx, r));
+    }
+}
+
+function mustBeBoolean(ctx: IntrinsicContext, r: any): Result<boolean> {
+    if (typeof r === "boolean") {
+        return ctx.succeed(r);
+    } else {
+        return ctx.fail(`Expected a boolean, got ${typeof r}`);
+    }
+}
+
+function evaluateCondition(ctx: IntrinsicContext, conditionName: string): Result<boolean> {
+    const conditionExpr = ctx.findCondition(conditionName);
+    if (conditionExpr === undefined) {
+        return ctx.fail(`No condition '${conditionName}' found`);
+    }
+    return ctx.apply(ctx.evaluate(conditionExpr), r => mustBeBoolean(ctx, r));
+}
diff --git a/src/graph.ts b/src/graph.ts
index 1d73101..597ef92 100644
--- a/src/graph.ts
+++ b/src/graph.ts
@@ -11,7 +11,7 @@
 // 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 { debug } from '@pulumi/pulumi/log';
+import { debug, warn } from '@pulumi/pulumi/log';
 import { CloudFormationResource } from './cfn';
 import { parseSub } from './sub';
 import { ConstructTree, StackManifest } from './assembly';
@@ -241,14 +241,27 @@ export class GraphBuilder {
     private _build(): Graph {
         // passes
         // 1. collect all constructs into a map from construct name to DAG node, converting CFN elements to fragments
-        // 2. hook up dependency edges
-        // 3. sort the dependency graph
+        // 2. validate that all CDK resources are mapped to Pulumi resources
+        // 3. hook up dependency edges
+        // 4. sort the dependency graph
 
         // Create graph nodes and associate them with constructs and CFN logical IDs.
         //
         // NOTE: this doesn't handle cross-stack references. We'll likely need to do so, at least for nested stacks.
         this.parseTree(this.stack.constructTree);
 
+        const unmappedResources: string[] = [];
+        Object.entries(this.stack.resources).forEach(([logicalId, resource]) => {
+            if (!this.cfnElementNodes.has(logicalId)) {
+                warn(`CDK resource ${logicalId} (${resource.Type}) was not mapped to a Pulumi resource.`);
+                unmappedResources.push(logicalId);
+            }
+        });
+        if (unmappedResources.length > 0) {
+            const total = Object.keys(this.stack.resources).length;
+            throw new Error(`${unmappedResources.length} out of ${total} CDK resources failed to map to Pulumi resources.`);
+        }
+
         // parseTree does not guarantee that the VPC resource will be parsed before the VPCCidrBlock resource
         // so we need to process this separately after
         if (Object.keys(this._vpcCidrBlockNodes).length) {


Test patch:
diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go
index 77a8262..d367be5 100644
--- a/integration/examples_nodejs_test.go
+++ b/integration/examples_nodejs_test.go
@@ -264,3 +264,16 @@ func bucketExists(ctx context.Context, client *s3.Client, bucketName string) (bo
 	}
 	return true, nil
 }
+
+func TestKinesis(t *testing.T) {
+	test := getJSBaseOptions(t).
+		With(integration.ProgramTestOptions{
+			Dir: filepath.Join(getCwd(t), "kinesis"),
+			ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
+				kinesisStreamName := stack.Outputs["kinesisStreamName"].(string)
+				assert.Containsf(t, kinesisStreamName, "mystream", "Kinesis stream name should contain 'mystream'")
+			},
+		})
+
+	integration.ProgramTest(t, &test)
+}
diff --git a/tests/converters/intrinsics.test.ts b/tests/converters/intrinsics.test.ts
new file mode 100644
index 0000000..f8a66ab
--- /dev/null
+++ b/tests/converters/intrinsics.test.ts
@@ -0,0 +1,228 @@
+import * as intrinsics from '../../src/converters/intrinsics';
+
+describe('Fn::If', () => {
+    test('picks true', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': true}});
+        const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']);
+        expect(result).toEqual(ok('yes'));
+    });
+
+    test('picks false', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': false}});
+        const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']);
+        expect(result).toEqual(ok('no'));
+    });
+
+    test('errors if condition is not found', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']);
+        expect(result).toEqual(failed(`No condition 'MyCondition' found`));
+    });
+
+    test('errors if condition evaluates to a non-boolean', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': 'OOPS'}});
+        const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']);
+        expect(result).toEqual(failed(`Expected a boolean, got string`));
+    });
+});
+
+describe('Fn::Or', () => {
+    test('picks true', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnOr, tc, [true, false, true]);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('picks false', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnOr, tc, [false, false, false]);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('picks true from inner Condition', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': true}});
+        const result = runIntrinsic(intrinsics.fnOr, tc, [false, {'Condition': 'MyCondition'}]);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('picks false with inner Condition', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': false}});
+        const result = runIntrinsic(intrinsics.fnOr, tc, [false, {'Condition': 'MyCondition'}]);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('has to have at least two arguments', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnOr, tc, [false]);
+        expect(result).toEqual(failed(`Fn::Or expects at least 2 params, got 1`));
+    });
+
+    test('short-cirtcuits evaluation if true is found', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnOr, tc, [true, {'Condition': 'DoesNotExist'}]);
+        expect(result).toEqual(ok(true));
+    });
+})
+
+describe('Fn::And', () => {
+    test('picks true', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnAnd, tc, [true, true, true]);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('picks false', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnAnd, tc, [true, false, true]);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('picks true from inner Condition', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': true}});
+        const result = runIntrinsic(intrinsics.fnAnd, tc, [true, {'Condition': 'MyCondition'}]);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('picks false with inner Condition', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': false}});
+        const result = runIntrinsic(intrinsics.fnAnd, tc, [true, {'Condition': 'MyCondition'}]);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('has to have at least two arguments', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnAnd, tc, [false]);
+        expect(result).toEqual(failed(`Fn::And expects at least 2 params, got 1`));
+    });
+
+    test('short-cirtcuits evaluation if false is found', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnAnd, tc, [false, {'Condition': 'DoesNotExist'}]);
+        expect(result).toEqual(ok(false));
+    });
+})
+
+
+describe('Fn::Not', () => {
+    test('inverts false', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnNot, tc, [true]);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('inverts true', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnNot, tc, [false]);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('inverts a false Condition', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': false}});
+        const result = runIntrinsic(intrinsics.fnNot, tc, [{'Condition': 'MyCondition'}]);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('inverts a true Condition', async () => {
+        const tc = new TestContext({conditions: {'MyCondition': true}});
+        const result = runIntrinsic(intrinsics.fnNot, tc, [{'Condition': 'MyCondition'}]);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('requires a boolean', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnNot, tc, ['ok']);
+        expect(result).toEqual(failed(`Expected a boolean, got string`));
+    });
+})
+
+describe('Fn::Equals', () => {
+    test('detects equal strings', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'a']);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('detects unequal strings', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'b']);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('detects equal objects', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnEquals, tc, [{x: 'a'}, {'x': 'a'}]);
+        expect(result).toEqual(ok(true));
+    });
+
+    test('detects unequal objects', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnEquals, tc, [{x: 'a'}, {'x': 'b'}]);
+        expect(result).toEqual(ok(false));
+    });
+
+    test('insists on two arguments', async () => {
+        const tc = new TestContext({});
+        const result = runIntrinsic(intrinsics.fnEquals, tc, [1]);
+        expect(result).toEqual(failed(`Fn::Equals expects exactly 2 params, got 1`));
+    });
+})
+
+function runIntrinsic(fn: intrinsics.Intrinsic, tc: TestContext, args: intrinsics.Expression[]): TestResult<any> {
+    const result: TestResult<any> = <any>(fn.evaluate(tc, args));
+    return result;
+};
+
+type TestResult<T> =
+    | {'ok': true, value: T}
+    | {'ok': false, errorMessage: string};
+
+function ok<T>(result: T): TestResult<T> {
+    return {'ok': true, value: result};
+}
+
+function failed<T>(errorMessage: string): TestResult<T> {
+    return {'ok': false, errorMessage: errorMessage};
+}
+
+class TestContext implements intrinsics.IntrinsicContext {
+    conditions: { [id: string]: intrinsics.Expression };
+
+    constructor(args: {conditions?: { [id: string]: intrinsics.Expression }}) {
+        if (args.conditions) {
+            this.conditions = args.conditions;
+        } else {
+            this.conditions = {};
+        }
+    }
+
+    findCondition(conditionName: string): intrinsics.Expression|undefined {
+        if (this.conditions.hasOwnProperty(conditionName)) {
+            return this.conditions[conditionName];
+        }
+    }
+
+    evaluate(expression: intrinsics.Expression): intrinsics.Result<any> {
+        // Self-evaluate the expression. This is very incomplete.
+        const result: TestResult<any> = {'ok': true, value: expression};
+        return result;
+    }
+
+    apply<T, U>(result: intrinsics.Result<T>, fn: (x: T) => intrinsics.Result<U>): intrinsics.Result<U> {
+        const t: TestResult<T> = <any>result; // assume result is a TestResult
+        if (t.ok) {
+            return fn(t.value);
+        } else {
+            return {'ok': false, errorMessage: t.errorMessage};
+        }
+    }
+
+    fail(msg: string): intrinsics.Result<any> {
+        const result: TestResult<any> = {'ok': false, errorMessage: msg};
+        return result;
+    }
+
+    succeed<T>(r: T): intrinsics.Result<T> {
+        const result: TestResult<any> = {'ok': true, value: r};
+        return result;
+    }
+}
diff --git a/tests/graph.test.ts b/tests/graph.test.ts
index 9703f6c..bf20a71 100644
--- a/tests/graph.test.ts
+++ b/tests/graph.test.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import { GraphBuilder, GraphNode } from '../src/graph';
-import { StackManifest, StackManifestProps } from '../src/assembly';
+import { ConstructTree, StackManifest, StackManifestProps } from '../src/assembly';
 import { createStackManifest } from './utils';
 import * as fs from 'fs';
 import * as path from 'path';
@@ -519,6 +519,29 @@ test('parses custom resources', () => {
     });
 });
 
+test('validates that all resources are mapped', () => {
+    const stackManifestPath = path.join(__dirname, 'test-data/custom-resource-stack/stack-manifest.json');
+    const props: StackManifestProps = JSON.parse(fs.readFileSync(stackManifestPath, 'utf-8'));
+    const metadata = props.metadata;
+    const resourceToDelete = 's3deployment/WebsiteBucket/Resource';
+    delete metadata[resourceToDelete];
+
+    const deleteResourceFromTree = (tree: ConstructTree, path: string): ConstructTree => {
+        if (tree.children) {
+            tree.children = Object.fromEntries(
+                Object.entries(tree.children)
+                    .filter(([_, value]) => value.path !== path)
+                    .map(([key, value]) => [key, deleteResourceFromTree(value, path)])
+            );
+        }
+        return tree;
+    }
+    const constructTree = deleteResourceFromTree(props.tree, resourceToDelete);
+    const stackManifest = new StackManifest({ ...props, tree: constructTree, metadata });
+
+    expect(() => GraphBuilder.build(stackManifest)).toThrow('1 out of 11 CDK resources failed to map to Pulumi resources.');
+});
+
 function edgesToArray(edges: Set<GraphNode>): string[] {
     return Array.from(edges).flatMap((value) => value.construct.path);
 }