Repository: pulumi/pulumi-cdk (ts)

Issue:
AWS CCAPI resources should be invoked from the SDK
### What happened?

When we create `aws-native` resources via the mapping, we use a `pulumi.CustomResource` instead of the appropriate class from the aws-native sdk.

https://github.com/pulumi/pulumi-cdk/blob/5912f830788319b8e9b66f6b1c9922baa6b4d454/src/interop.ts?plain=1#L87

This means that we don't get any of the generated sdk stuff like `replaceOnChanges`.

### Example

Deploy the `examples/fargate` example and then change the taskDefinition image. The TaskDefinition should be replaced because `containerDefinitions` is `replaceOnChange`.

### Output of `pulumi about`

n/a

### Additional context

_No response_

### Contributing

Vote on this issue by adding a 👍 reaction. 
To contribute a fix for this issue, leave a comment (and link to your pull request, if you've opened one already). 


Pull request description:
Create aws ccapi resources by using the SDK
This PR updates our mapping to create resources using the SDK directly
instead of creating a `pulumi.CustomResource`. By using a
`pulumi.CustomResource` we were missing out on all the resource specific
things from the generated SDK. (`replaceOnChanges`, required property
validation, etc).

closes #216

Patch:
diff --git a/examples/fargate/app/Dockerfile b/examples/fargate/app/Dockerfile
new file mode 100644
index 0000000..d0fb5db
--- /dev/null
+++ b/examples/fargate/app/Dockerfile
@@ -0,0 +1,14 @@
+FROM node:lts AS builder
+
+WORKDIR /app
+
+COPY package.json yarn.lock ./
+COPY app ./
+
+RUN yarn install --frozen-lockfile
+RUN npx esbuild --bundle index.ts --target="node18" --platform="node" --outfile="index.js"
+
+FROM node:18-alpine
+COPY --from=builder /app/index.js /app/index.js
+EXPOSE 80
+CMD ["node", "/app/index.js"]
diff --git a/examples/fargate/app/index.ts b/examples/fargate/app/index.ts
new file mode 100644
index 0000000..84432a1
--- /dev/null
+++ b/examples/fargate/app/index.ts
@@ -0,0 +1,14 @@
+import express, { Request, Response } from 'express';
+const app = express();
+
+app.get('/', async (_req: Request, res: Response) => {
+    res.status(200).send('Hello, world!');
+});
+
+app.get('/health', (_req: Request, res: Response) => {
+    res.status(200).send(JSON.stringify({ message: 'OK' }));
+});
+
+app.listen(80, () => {
+    console.log('Listening on port 8080');
+});
diff --git a/examples/fargate/index.ts b/examples/fargate/index.ts
index 372bc81..e62662f 100644
--- a/examples/fargate/index.ts
+++ b/examples/fargate/index.ts
@@ -1,3 +1,4 @@
+import * as path from 'path';
 import * as pulumi from '@pulumi/pulumi';
 import * as pulumicdk from '@pulumi/cdk';
 
@@ -6,6 +7,7 @@ import * as ec2 from 'aws-cdk-lib/aws-ec2';
 import * as ecs from 'aws-cdk-lib/aws-ecs';
 import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns';
 import { CfnTargetGroup } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
+import { Platform } from 'aws-cdk-lib/aws-ecr-assets';
 
 class FargateStack extends pulumicdk.Stack {
     loadBalancerDNS: pulumi.Output<string>;
@@ -22,7 +24,13 @@ class FargateStack extends pulumicdk.Stack {
         const fargateService = new ecs_patterns.NetworkLoadBalancedFargateService(this, 'sample-app', {
             cluster,
             taskImageOptions: {
-                image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
+                image: ecs.ContainerImage.fromAsset(path.join(__dirname, './'), {
+                    file: 'app/Dockerfile',
+                    exclude: ['cdk.out', 'node_modules'],
+                    // assetName is now required and is used in the name of the ecr repository that is created
+                    assetName: 'cdk-fargate-example',
+                    platform: Platform.LINUX_AMD64,
+                }),
             },
         });
 
@@ -51,12 +59,31 @@ class FargateStack extends pulumicdk.Stack {
 
 class MyApp extends pulumicdk.App {
     constructor() {
-        super('app', (scope: pulumicdk.App): pulumicdk.AppOutputs => {
-            const stack = new FargateStack(scope, 'fargatestack');
-            return { loadBalancerURL: stack.loadBalancerDNS };
-        });
+        super(
+            'app',
+            (scope: pulumicdk.App): pulumicdk.AppOutputs => {
+                const stack = new FargateStack(scope, 'fargatestack');
+                return { loadBalancerURL: stack.loadBalancerDNS };
+            },
+            {
+                // TODO[pulumi/aws-native#1318]
+                transforms: [
+                    (args: pulumi.ResourceTransformArgs): pulumi.ResourceTransformResult => {
+                        if (args.type === 'aws-native:ecs:TaskDefinition') {
+                            args.opts.replaceOnChanges = ['containerDefinitions'];
+                        }
+                        return {
+                            opts: {
+                                ...args.opts,
+                            },
+                            props: args.props,
+                        };
+                    },
+                ],
+            },
+        );
     }
 }
 
 const app = new MyApp();
-export const loadBalancerURL = app.outputs['loadBalancerURL'];
+export const loadBalancerURL = pulumi.interpolate`${app.outputs['loadBalancerURL']}/`;
diff --git a/examples/fargate/package.json b/examples/fargate/package.json
index e5403e2..5bac77a 100644
--- a/examples/fargate/package.json
+++ b/examples/fargate/package.json
@@ -5,10 +5,14 @@
     },
     "dependencies": {
         "@pulumi/aws": "^6.0.0",
+        "@pulumi/cdk": "^0.5.0",
+        "@pulumi/docker-build": "^0.0.7",
         "@pulumi/aws-native": "^1.7.0",
         "@pulumi/pulumi": "^3.0.0",
         "aws-cdk-lib": "2.156.0",
+        "@types/express": "^5.0.0",
         "constructs": "10.3.0",
-        "@pulumi/cdk": "^0.5.0"
+        "esbuild": "^0.24.0",
+        "express": "^4.21.1"
     }
 }
diff --git a/integration/replace-on-changes/Pulumi.yaml b/integration/replace-on-changes/Pulumi.yaml
new file mode 100644
index 0000000..433e4be
--- /dev/null
+++ b/integration/replace-on-changes/Pulumi.yaml
@@ -0,0 +1,3 @@
+name: pulumi-aws-replaceOnChanges
+runtime: nodejs
+description: replaceOnChanges integration test
diff --git a/integration/replace-on-changes/index.ts b/integration/replace-on-changes/index.ts
new file mode 100644
index 0000000..b3d44f8
--- /dev/null
+++ b/integration/replace-on-changes/index.ts
@@ -0,0 +1,24 @@
+import * as aws from '@pulumi/aws';
+import * as pulumicdk from '@pulumi/cdk';
+import * as ec2 from 'aws-cdk-lib/aws-ec2';
+
+class ReplaceOnChangesStack extends pulumicdk.Stack {
+    constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
+        super(app, id, options);
+        const vpc = aws.ec2.getVpcOutput({
+            default: true,
+        }).id;
+        const azs = aws.getAvailabilityZonesOutput({}).names;
+        new ec2.SecurityGroup(this, 'sg', {
+            description: 'Some Description',
+            vpc: ec2.Vpc.fromVpcAttributes(this, 'vpc', {
+                vpcId: pulumicdk.asString(vpc),
+                availabilityZones: pulumicdk.asList(azs),
+            }),
+        });
+    }
+}
+
+new pulumicdk.App('app', (scope: pulumicdk.App) => {
+    new ReplaceOnChangesStack(scope, 'teststack');
+});
diff --git a/integration/replace-on-changes/package.json b/integration/replace-on-changes/package.json
new file mode 100644
index 0000000..888282a
--- /dev/null
+++ b/integration/replace-on-changes/package.json
@@ -0,0 +1,15 @@
+{
+    "name": "pulumi-aws-cdk",
+    "devDependencies": {
+        "@types/node": "^10.0.0"
+    },
+    "dependencies": {
+        "@pulumi/aws": "^6.0.0",
+        "@pulumi/aws-native": "^1.5.0",
+        "@pulumi/cdk": "^0.5.0",
+        "@pulumi/pulumi": "^3.0.0",
+        "aws-cdk-lib": "2.149.0",
+        "constructs": "10.3.0",
+        "esbuild": "^0.24.0"
+    }
+}
diff --git a/integration/replace-on-changes/step2/index.ts b/integration/replace-on-changes/step2/index.ts
new file mode 100644
index 0000000..9070bb4
--- /dev/null
+++ b/integration/replace-on-changes/step2/index.ts
@@ -0,0 +1,26 @@
+import * as aws from '@pulumi/aws';
+import * as pulumicdk from '@pulumi/cdk';
+import * as ec2 from 'aws-cdk-lib/aws-ec2';
+
+class ReplaceOnChangesStack extends pulumicdk.Stack {
+    constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) {
+        super(app, id, options);
+        const vpc = aws.ec2.getVpcOutput({
+            default: true,
+        }).id;
+        const azs = aws.getAvailabilityZonesOutput({}).names;
+        new ec2.SecurityGroup(this, 'sg', {
+            // description is a createOnlyProperty which means this would fail
+            // if `replaceOnChanges` was not working
+            description: 'Some New Description',
+            vpc: ec2.Vpc.fromVpcAttributes(this, 'vpc', {
+                vpcId: pulumicdk.asString(vpc),
+                availabilityZones: pulumicdk.asList(azs),
+            }),
+        });
+    }
+}
+
+new pulumicdk.App('app', (scope: pulumicdk.App) => {
+    new ReplaceOnChangesStack(scope, 'teststack');
+});
diff --git a/integration/replace-on-changes/tsconfig.json b/integration/replace-on-changes/tsconfig.json
new file mode 100644
index 0000000..eac442c
--- /dev/null
+++ b/integration/replace-on-changes/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 61bd498..b1c1c27 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,7 @@
     }
   },
   "resolutions": {
-    "@pulumi/pulumi": "3.136.0", 
+    "@pulumi/pulumi": "3.136.0",
     "wrap-ansi": "7.0.0",
     "string-width": "4.1.0"
   },
@@ -30,6 +30,7 @@
     "@pulumi/aws": "^6.56.0",
     "@pulumi/aws-native": "^1.7.0",
     "@pulumi/docker": "^4.5.0",
+    "@pulumi/docker-build": "^0.0.7",
     "@pulumi/pulumi": "3.136.0",
     "@types/archiver": "^6.0.2",
     "@types/fs-extra": "^11.0.4",
diff --git a/src/cfn-resource-mappings.ts b/src/cfn-resource-mappings.ts
index 6187cf6..3618434 100644
--- a/src/cfn-resource-mappings.ts
+++ b/src/cfn-resource-mappings.ts
@@ -14,11 +14,9 @@
 
 import * as pulumi from '@pulumi/pulumi';
 import * as aws from '@pulumi/aws-native';
-import { CfnResource, ResourceMapping, normalize } from './interop';
+import { ResourceMapping, normalize } from './interop';
 import { debug } from '@pulumi/pulumi/log';
-import { toSdkName } from './naming';
-import { Metadata } from './pulumi-metadata';
-import { PulumiProvider } from './types';
+import { toSdkName, typeName as pulumiTypeName, moduleName } from './naming';
 
 export function mapToCfnResource(
     logicalId: string,
@@ -115,14 +113,10 @@ export function mapToCfnResource(
         }
 
         default: {
-            // When creating a generic `CfnResource` we don't have any information on the
-            // attributes attached to the resource. We need to populate them by looking up the
-            // `output` in the metadata
-            const metadata = new Metadata(PulumiProvider.AWS_NATIVE);
-            const resource = metadata.findResource(typeName);
-            const attributes = Object.keys(resource.outputs);
-
-            return new CfnResource(logicalId, typeName, props, attributes, options);
+            const mName = moduleName(typeName).toLowerCase();
+            const pType = pulumiTypeName(typeName);
+            const awsModule = aws as any;
+            return new awsModule[mName][pType](logicalId, props, options);
         }
     }
 }
diff --git a/src/interop.ts b/src/interop.ts
index 452e0da..919fbe9 100644
--- a/src/interop.ts
+++ b/src/interop.ts
@@ -13,9 +13,8 @@
 // limitations under the License.
 
 import * as pulumi from '@pulumi/pulumi';
-import { debug } from '@pulumi/pulumi/log';
 import { normalizeObject } from './pulumi-metadata';
-import { toSdkName, typeToken } from './naming';
+import { toSdkName } from './naming';
 import { PulumiProvider } from './types';
 import { PulumiResourceType } from './graph';
 
@@ -84,31 +83,6 @@ export type ResourceAttributeMappingArray = (ResourceAttributeMapping & { logica
 
 export type ResourceMapping = ResourceAttributeMapping | pulumi.Resource | ResourceAttributeMappingArray;
 
-export class CfnResource extends pulumi.CustomResource {
-    constructor(
-        name: string,
-        type: string,
-        properties: any,
-        attributes: string[],
-        opts?: pulumi.CustomResourceOptions,
-    ) {
-        const resourceName = typeToken(type);
-
-        debug(`Constructing CfnResource ${name} of type ${resourceName} with attributes=${JSON.stringify(attributes)}`);
-        const propertiesDebugString = pulumi.output(properties).apply(JSON.stringify);
-        pulumi.interpolate`CfnResource ${name} input properties: ${propertiesDebugString}`.apply(debug);
-
-        // Prepare an args bag with placeholders for output attributes.
-        const args: any = {};
-        for (const k of attributes) {
-            args[k] = undefined;
-        }
-        Object.assign(args, properties);
-
-        super(resourceName, name, args, opts);
-    }
-}
-
 export class CdkConstruct extends pulumi.ComponentResource {
     constructor(public readonly name: PulumiResourceType, type?: string, options?: pulumi.ComponentResourceOptions) {
         const constructType = type ?? 'Construct';
diff --git a/src/naming.ts b/src/naming.ts
index 862cde3..0fb6431 100644
--- a/src/naming.ts
+++ b/src/naming.ts
@@ -24,7 +24,7 @@ export function moduleName(resourceType: string): string {
     return lowerAcronyms(mName);
 }
 
-function typeName(typ: string): string {
+export function typeName(typ: string): string {
     const resourceTypeComponents = typ.split('::');
     if (resourceTypeComponents.length !== 3) {
         throw new Error(`expected three parts in type ${resourceTypeComponents}`);
diff --git a/src/stack.ts b/src/stack.ts
index 859e107..e7f6fbc 100644
--- a/src/stack.ts
+++ b/src/stack.ts
@@ -289,6 +289,7 @@ export class Stack extends cdk.Stack {
 function generateAppId(): string {
     const stack = pulumi.runtime.getStack();
     const project = pulumi.runtime.getProject();
+
     return `${project}-${stack}`
         .toLowerCase()
         .replace(/[^a-z0-9-.]/g, '-')
diff --git a/src/synthesizer.ts b/src/synthesizer.ts
index 56479cd..48080ed 100644
--- a/src/synthesizer.ts
+++ b/src/synthesizer.ts
@@ -1,10 +1,13 @@
 import * as path from 'path';
+import * as docker from '@pulumi/docker-build';
+import { NetworkMode } from '@pulumi/docker-build';
 import * as pulumi from '@pulumi/pulumi';
 import * as cdk from 'aws-cdk-lib/core';
 import { translateCfnTokenToAssetToken } from 'aws-cdk-lib/core/lib/helpers-internal';
 import * as aws from '@pulumi/aws';
 import { CdkConstruct } from './interop';
 import { zipDirectory } from './zip';
+import { asString } from './output';
 
 /**
  * Deploy time assets will be put in this S3 bucket prefix
@@ -69,6 +72,18 @@ export interface PulumiSynthesizerOptions {
      */
     readonly autoDeleteStagingAssets?: boolean;
 
+    /**
+     * The maximum number of image versions to store in a repository.
+     *
+     * Previous versions of an image can be stored for rollback purposes.
+     * Once a repository has more than 3 image versions stored, the oldest
+     * version will be discarded. This allows for sensible garbage collection
+     * while maintaining a few previous versions for rollback scenarios.
+     *
+     * @default - up to 3 versions stored
+     */
+    readonly imageAssetVersionCount?: number;
+
     /**
      * The parent resource for any Pulumi resources created by the Synthesizer
      */
@@ -88,6 +103,21 @@ export abstract class PulumiSynthesizerBase extends cdk.StackSynthesizer {
     public abstract readonly stagingStack: CdkConstruct;
 }
 
+/**
+ * Information on the created ECR repository
+ */
+interface CreateRepoResponse {
+    /**
+     * The name of the created repository
+     */
+    repoName: string;
+
+    /**
+     * The ECR repository that was created
+     */
+    repo: aws.ecr.Repository;
+}
+
 /**
  * This is a custom synthesizer that determines how the CDK stack should be synthesized.
  *
@@ -126,6 +156,7 @@ export class PulumiSynthesizer extends PulumiSynthesizerBase implements cdk.IReu
      */
     private readonly _stagingBucketName?: string;
     private readonly autoDeleteStagingAssets: boolean;
+    private readonly imageAssetVersionCount: number;
 
     /**
      * The final CDK name of the asset S3 bucket. This may contain CDK tokens
@@ -177,12 +208,20 @@ export class PulumiSynthesizer extends PulumiSynthesizerBase implements cdk.IReu
      */
     private readonly seenFileAssets = new Set<string>();
 
+    /**
+     * Map of `${assetName}:${assetHash}` to docker.Image that have already been created.
+     * The same asset could be registered multiple times, but we
+     * only want to upload it a single time
+     */
+    private readonly seenImageAssets = new Map<string, docker.Image>();
+
     constructor(props: PulumiSynthesizerOptions) {
         super();
         const stackPrefix = props.stagingStackNamePrefix ?? 'staging-stack';
         this._stagingBucketName = props.stagingBucketName;
         this.autoDeleteStagingAssets = props.autoDeleteStagingAssets ?? true;
         this.appId = this.validateAppId(props.appId);
+        this.imageAssetVersionCount = props.imageAssetVersionCount ?? 3;
 
         const account = aws.getCallerIdentity({}, { parent: props.parent }).then((id) => id.accountId);
         this.pulumiAccount = pulumi.output(account);
@@ -212,6 +251,67 @@ export class PulumiSynthesizer extends PulumiSynthesizerBase implements cdk.IReu
         return id;
     }
 
+    /**
+     * This will create a unique ECR repository for each asset. Creating a separate repository for each
+     * asset allows us to have a lifecycle policy on the repository to delete old images.
+     *
+     * @param asset - The cdk asset to create a repo for
+     * @returns Information about the created repo
+     */
+    private getCreateRepo(asset: cdk.DockerImageAssetSource): CreateRepoResponse {
+        if (!asset.assetName) {
+            throw new Error("Docker image assets must include 'assetName' in the asset source definition");
+        }
+        const repoName = `${this.appId}/${asset.assetName}`
+            .toLocaleLowerCase()
+            .replace('.', '-')
+            // it can only start with letters or numbers
+            .replace(/^[^a-z0-9]+/, '');
+
+        if (!this.stagingRepos[repoName]) {
+            const repo = new aws.ecr.Repository(
+                repoName,
+                {
+                    name: repoName,
+                    // prevents you from pushing an image with a tag that already exists in the repository
+                    // @see https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-tag-mutability.html
+                    imageTagMutability: 'IMMUTABLE',
+                    forceDelete: this.autoDeleteStagingAssets,
+                },
+                {
+                    retainOnDelete: !this.autoDeleteStagingAssets,
+                    parent: this.stagingStack,
+                },
+            );
+            new aws.ecr.LifecyclePolicy(
+                'lifecycle-policy',
+                {
+                    repository: repo.name,
+                    policy: {
+                        rules: [
+                            {
+                                action: { type: 'expire' },
+                                selection: {
+                                    countType: 'imageCountMoreThan',
+                                    tagStatus: 'any',
+                                    countNumber: this.imageAssetVersionCount,
+                                },
+                                rulePriority: 1,
+                                description: 'Garbage collect old image versions',
+                            },
+                        ],
+                    },
+                },
+                { parent: this.stagingStack },
+            );
+            this.stagingRepos[repoName] = repo;
+        }
+        return {
+            repoName,
+            repo: this.stagingRepos[repoName],
+        };
+    }
+
     /**
      * Create a S3 Bucket which will be used to store any file assets that are created in the
      * CDK application.
@@ -417,8 +517,116 @@ export class PulumiSynthesizer extends PulumiSynthesizerBase implements cdk.IReu
         return this.cloudFormationLocationFromFileAsset(location);
     }
 
+    /**
+     * Gets registry credentials for the given ECR repository
+     *
+     * @param repo - The ECR repository to get the credentials for
+     * @returns The registry credentials for the ECR repository
+     */
+    private getEcrCredentialsOutput(repo: aws.ecr.Repository): docker.types.input.RegistryArgs {
+        const ecrCredentials = aws.ecr.getCredentialsOutput(
+            {
+                registryId: repo.registryId,
+            },
+            { parent: this.stagingStack },
+        );
+        return ecrCredentials.authorizationToken.apply((token) => {
+            const decodedCredentials = Buffer.from(token, 'base64').toString();
+            const [username, password] = decodedCredentials.split(':');
+            if (!password || !username) {
+                throw new Error('Invalid credentials');
+            }
+            return {
+                address: ecrCredentials.proxyEndpoint,
+                username: username,
+                password: password,
+            };
+        });
+    }
+
+    /**
+     * This method is called by CDK constructs to add an image asset to the stack
+     * Usually the default synthesizers will then take the data and add it to the asset manifest
+     * for the stack. In our case we can just directly push the images and then we don't have to
+     * later post-process the assets from the manifest
+     *
+     * @param asset - The cdk asset to add
+     * @returns The location of the asset. This will be the reference to the image ref
+     */
     addDockerImageAsset(asset: cdk.DockerImageAssetSource): cdk.DockerImageAssetLocation {
-        throw new Error('Docker image assets are not supported yet');
+        assertBound(this.outdir);
+        if (asset.executable || !asset.directoryName) {
+            throw new Error(`Docker image assets produced by commands are not yet supported`);
+        }
+
+        const { repoName, repo } = this.getCreateRepo(asset);
+        const imageTag = asset.sourceHash;
+        const canonicalImageName = pulumi.interpolate`${repo.repositoryUrl}:${imageTag}`;
+        const context = path.join(this.outdir, asset.directoryName);
+        const dockerFile = path.join(context, asset.dockerFile ?? 'Dockerfile');
+        const assetKey = `${asset.assetName}:${asset.sourceHash}`;
+
+        // Assets can be registered multiple times, but we should only create the resource once
+        if (this.seenImageAssets.has(assetKey)) {
+            return {
+                repositoryName: repoName,
+                imageUri: asString(this.seenImageAssets.get(assetKey)!.ref),
+            };
+        }
+
+        const registryCredentials = this.getEcrCredentialsOutput(repo);
+
+        const cacheFrom = fromCdkCacheFrom(asset.dockerCacheFrom);
+        const cacheTo = fromCdkCacheTo(asset.dockerCacheTo);
+
+        const image = new docker.Image(
+            `${this.stagingStack.name}/${asset.assetName}`,
+            {
+                cacheFrom,
+                cacheTo,
+                push: true,
+                dockerfile: {
+                    location: dockerFile,
+                },
+                network: asNetworkMode(asset.networkMode),
+                ssh: asset.dockerBuildSsh
+                    ? [
+                          {
+                              id: 'default',
+                              paths: [asset.dockerBuildSsh],
+                          },
+                      ]
+                    : undefined,
+                target: asset.dockerBuildTarget,
+                platforms: asPlatforms(asset.platform),
+                // TODO: add support for dockerOutputs
+                // this will require parsing strings into the export type
+                // exports: asset.dockerOutputs,
+                secrets: asset.dockerBuildSecrets,
+                tags: [canonicalImageName],
+                buildArgs: asset.dockerBuildArgs,
+                noCache: asset.dockerCacheDisabled,
+                context: {
+                    location: context,
+                },
+                registries: [registryCredentials],
+            },
+            {
+                parent: this.stagingStack,
+                // we have a lifecycle policy on the ECR repository to delete old images
+                retainOnDelete: true,
+            },
+        );
+
+        this.assetManifest.defaultAddDockerImageAsset(this.boundStack, asset, {
+            repositoryName: repoName,
+        });
+        this.seenImageAssets.set(assetKey, image);
+
+        return {
+            repositoryName: repoName,
+            imageUri: asString(image.ref),
+        };
     }
 
     /**
@@ -448,3 +656,64 @@ function assertBound<A>(x: A | undefined): asserts x is NonNullable<A> {
         throw new Error('You must call bindStack() first');
     }
 }
+
+/**
+ * Converts the CDK DockerCacheOption to the Pulumi Docker.CacheToArgs
+ */
+function fromCdkCacheTo(cacheTo?: cdk.DockerCacheOption): docker.types.input.CacheToArgs[] | undefined {
+    if (!cacheTo) {
+        return undefined;
+    }
+    return [{ [cacheTo.type]: cacheTo.params }];
+}
+
+/**
+ * Converts the CDK DockerCacheOption to the Pulumi Docker.CacheFromArgs
+ */
+function fromCdkCacheFrom(cacheFrom?: cdk.DockerCacheOption[]): docker.types.input.CacheFromArgs[] | undefined {
+    if (!cacheFrom) {
+        return undefined;
+    }
+    return cacheFrom.map((cache) => {
+        return {
+            [cache.type]: cache.params,
+        };
+    });
+}
+
+/**
+ * Converts the CDK NetworkMode to the Pulumi Docker.NetworkMode
+ *
+ * @param networkMode - The cdk network mode
+ * @returns The docker network mode
+ */
+export function asNetworkMode(networkMode?: string): docker.NetworkMode | undefined {
+    if (networkMode === undefined) {
+        return undefined;
+    }
+    const acceptedModes = Object.values(NetworkMode) as string[];
+    if (!acceptedModes.includes(networkMode)) {
+        throw new Error(`Unsupported network mode: ${networkMode}`);
+    }
+
+    return networkMode as NetworkMode;
+}
+
+/**
+ * Converts the CDK Platform to the Pulumi Docker.Platform
+ *
+ * @param platform - The cdk platform
+ * @returns The docker platform
+ */
+export function asPlatforms(platform?: string): docker.Platform[] | undefined {
+    if (!platform) {
+        return undefined;
+    }
+
+    const acceptedPlatforms = Object.values(docker.Platform) as string[];
+    if (!acceptedPlatforms.includes(platform)) {
+        throw new Error(`Unsupported platform: ${platform}`);
+    }
+
+    return [platform as docker.Platform];
+}
diff --git a/yarn.lock b/yarn.lock
index e220841..f8a74c5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1054,6 +1054,13 @@
     mime "^2.0.0"
     resolve "^1.7.1"
 
+"@pulumi/docker-build@^0.0.7":
+  version "0.0.7"
+  resolved "https://registry.yarnpkg.com/@pulumi/docker-build/-/docker-build-0.0.7.tgz#c56582dbc2d6f6a07ceebb9201a7904cd2278e6e"
+  integrity sha512-yiPvQefRS3Z+5NtG5VK2pYTds0pCQh21INpoWY8gKFrqs166ZFVrPrMXelmsjABnmkG5ZNlkHanU7HqAExn55Q==
+  dependencies:
+    "@pulumi/pulumi" "^3.136.0"
+
 "@pulumi/docker@^4.5.0":
   version "4.5.7"
   resolved "https://registry.yarnpkg.com/@pulumi/docker/-/docker-4.5.7.tgz#56ec0782a1a52b5a1dc16230dcd7e07c0b94be01"


Test patch:
diff --git a/examples/examples_nodejs_test.go b/examples/examples_nodejs_test.go
index e7589c4..091c00c 100644
--- a/examples/examples_nodejs_test.go
+++ b/examples/examples_nodejs_test.go
@@ -70,6 +70,11 @@ func TestFargate(t *testing.T) {
 			Dir:              filepath.Join(getCwd(t), "fargate"),
 			NoParallel:       true,
 			RetryFailedSteps: true, // Workaround for https://github.com/pulumi/pulumi-aws-native/issues/1186
+			ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
+				integration.AssertHTTPResultWithRetry(t, stack.Outputs["loadBalancerURL"], nil, time.Duration(time.Minute*1), func(s string) bool {
+					return s == "Hello, world!"
+				})
+			},
 		})
 
 	integration.ProgramTest(t, &test)
diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go
index 2a40ec4..8abf56b 100644
--- a/integration/examples_nodejs_test.go
+++ b/integration/examples_nodejs_test.go
@@ -132,6 +132,21 @@ func TestErrors(t *testing.T) {
 	assert.Containsf(t, buf.String(), "Error: Event Bus policy statements must have a sid", "Expected error message not found in pulumi up output")
 }
 
+func TestReplaceOnChanges(t *testing.T) {
+	test := getJSBaseOptions(t).
+		With(integration.ProgramTestOptions{
+			Dir: filepath.Join(getCwd(t), "replace-on-changes"),
+			EditDirs: []integration.EditDir{
+				{
+					Dir:      filepath.Join(getCwd(t), "replace-on-changes/step2"),
+					Additive: true,
+				},
+			},
+		})
+
+	integration.ProgramTest(t, &test)
+}
+
 func getJSBaseOptions(t *testing.T) integration.ProgramTestOptions {
 	base := getBaseOptions(t)
 	baseJS := base.With(integration.ProgramTestOptions{
diff --git a/tests/cdk-resource.test.ts b/tests/cdk-resource.test.ts
index 5894af7..2712c32 100644
--- a/tests/cdk-resource.test.ts
+++ b/tests/cdk-resource.test.ts
@@ -1,3 +1,5 @@
+import * as path from 'path';
+import * as ecs from 'aws-cdk-lib/aws-ecs';
 import * as route53 from 'aws-cdk-lib/aws-route53';
 import * as events from 'aws-cdk-lib/aws-events';
 import * as iam from 'aws-cdk-lib/aws-iam';
@@ -170,4 +172,28 @@ describe('CDK Construct tests', () => {
             }),
         );
     });
+
+    test('task definition references image ref', async () => {
+        await testApp((scope: Construct) => {
+            const taskDef = new ecs.FargateTaskDefinition(scope, 'Task');
+            taskDef.addContainer('app', {
+                image: ecs.ContainerImage.fromAsset(path.join(__dirname, 'test-data', 'app'), {
+                    assetName: 'testapp',
+                }),
+            });
+        });
+        const task = resources.find((res) => res.type === 'aws-native:ecs:TaskDefinition');
+        const image = resources.find((res) => res.type === 'docker-build:index:Image');
+        expect(image).toBeDefined();
+        expect(task).toBeDefined();
+        expect(task?.inputs).toMatchObject({
+            containerDefinitions: expect.arrayContaining([
+                expect.objectContaining({
+                    image: expect.stringMatching(
+                        /^12345678910.dkr.ecr.us-east-1.amazonaws.com\/project-stack\/testapp:[a-z0-9]+@sha256:abcdefghijk1023$/,
+                    ),
+                }),
+            ]),
+        });
+    });
 });
diff --git a/tests/cfn-resource-mappings.test.ts b/tests/cfn-resource-mappings.test.ts
index 3b935ad..636270d 100644
--- a/tests/cfn-resource-mappings.test.ts
+++ b/tests/cfn-resource-mappings.test.ts
@@ -1,6 +1,7 @@
 import { CustomResource } from '@pulumi/pulumi';
 import { mapToCfnResource } from '../src/cfn-resource-mappings';
 import * as aws from '@pulumi/aws-native';
+import { moduleName, typeName } from '../src/naming';
 
 class MockResource {
     constructor(args: { [key: string]: any }) {
@@ -31,6 +32,27 @@ jest.mock('@pulumi/aws-native', () => {
                 return {};
             }),
         },
+        apprunner: {
+            Service: jest.fn().mockImplementation(() => {
+                return {};
+            }),
+        },
+        ecs: {
+            Cluster: jest.fn().mockImplementation(() => {
+                return {};
+            }),
+            TaskDefinition: jest.fn().mockImplementation(() => {
+                return {};
+            }),
+            Service: jest.fn().mockImplementation(() => {
+                return {};
+            }),
+        },
+        ec2: {
+            Vpc: jest.fn().mockImplementation(() => {
+                return {};
+            }),
+        },
         iam: {
             Role: jest.fn().mockImplementation(() => {
                 return {};
@@ -118,8 +140,7 @@ describe('Cfn Resource Mappings', () => {
         // WHEN
         mapToCfnResource(logicalId, cfnType, cfnProps, {});
         // THEN
-        expect(CustomResource).toHaveBeenCalledWith(
-            'aws-native:s3objectlambda:AccessPoint',
+        expect(aws.s3objectlambda.AccessPoint).toHaveBeenCalledWith(
             logicalId,
             {
                 objectLambdaConfiguration: {
@@ -154,8 +175,7 @@ describe('Cfn Resource Mappings', () => {
         // WHEN
         mapToCfnResource(logicalId, cfnType, cfnProps, {});
         // THEN
-        expect(CustomResource).toHaveBeenCalledWith(
-            'aws-native:lambda:Function',
+        expect(aws.lambda.Function).toHaveBeenCalledWith(
             logicalId,
             {
                 environment: {
@@ -207,8 +227,7 @@ describe('Cfn Resource Mappings', () => {
         // WHEN
         mapToCfnResource(logicalId, cfnType, cfnProps, {});
         // THEN
-        expect(CustomResource).toHaveBeenCalledWith(
-            'aws-native:iam:Role',
+        expect(aws.iam.Role).toHaveBeenCalledWith(
             logicalId,
             {
                 description: 'desc',
@@ -268,8 +287,7 @@ describe('Cfn Resource Mappings', () => {
         // WHEN
         mapToCfnResource(logicalId, cfnType, cfnProps, {});
         // THEN
-        expect(CustomResource).toHaveBeenCalledWith(
-            'aws-native:s3:AccessPoint',
+        expect(aws.s3.AccessPoint).toHaveBeenCalledWith(
             logicalId,
             {
                 policy: {
@@ -300,8 +318,7 @@ describe('Cfn Resource Mappings', () => {
         // WHEN
         mapToCfnResource(logicalId, cfnType, cfnProps, {});
         // THEN
-        expect(CustomResource).toHaveBeenCalledWith(
-            'aws-native:ec2:Vpc',
+        expect(aws.ec2.Vpc).toHaveBeenCalledWith(
             logicalId,
             {
                 cidrBlock: '10.0.0.0/16',
@@ -367,10 +384,10 @@ describe('Cfn Resource Mappings', () => {
     });
 
     test.each([
-        ['AWS::AppRunner::Service', 'aws-native:apprunner:Service'],
-        ['AWS::ECS::Cluster', 'aws-native:ecs:Cluster'],
-        ['AWS::ECS::TaskDefinition', 'aws-native:ecs:TaskDefinition'],
-    ])('successfully maps %p to %p', (cfnType, pulumiType) => {
+        ['AWS::AppRunner::Service', aws.apprunner.Service],
+        ['AWS::ECS::Cluster', aws.ecs.Cluster],
+        ['AWS::ECS::TaskDefinition', aws.ecs.TaskDefinition],
+    ])('successfully maps %p to %p', (cfnType, called) => {
         // GIVEN
         const logicalId = 'my-resource';
         const cfnProps = {};
@@ -379,7 +396,7 @@ describe('Cfn Resource Mappings', () => {
         mapToCfnResource(logicalId, cfnType, cfnProps, {});
 
         // THEN
-        expect(CustomResource).toHaveBeenCalledWith(pulumiType, logicalId, {}, {});
+        expect(called).toHaveBeenCalledWith(logicalId, {}, {});
     });
 
     test('successfully maps ECS Service resource', () => {
@@ -404,8 +421,7 @@ describe('Cfn Resource Mappings', () => {
         // WHEN
         mapToCfnResource(logicalId, cfnType, cfnProps, {});
         // THEN
-        expect(CustomResource).toHaveBeenCalledWith(
-            'aws-native:ecs:Service',
+        expect(aws.ecs.Service).toHaveBeenCalledWith(
             logicalId,
             {
                 tags: [
diff --git a/tests/converters/app-converter.test.ts b/tests/converters/app-converter.test.ts
index 9dc6596..60faf81 100644
--- a/tests/converters/app-converter.test.ts
+++ b/tests/converters/app-converter.test.ts
@@ -114,6 +114,7 @@ describe('App Converter', () => {
                     examplebucketPolicyE09B485E: {
                         Type: 'AWS::S3::BucketPolicy',
                         Properties: {
+                            PolicyDocument: {},
                             Bucket: {
                                 Ref: 'examplebucketC9DFA43E',
                             },
@@ -310,6 +311,7 @@ describe('Stack Converter', () => {
                     other: {
                         Type: 'AWS::EC2::Subnet',
                         Properties: {
+                            VpcId: { Ref: 'vpc' },
                             Ipv6CidrBlock: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc', 'Ipv6CidrBlocks'] }] },
                         },
                     },
@@ -404,6 +406,7 @@ describe('Stack Converter', () => {
                     other: {
                         Type: 'AWS::EC2::Subnet',
                         Properties: {
+                            VpcId: { Ref: 'vpc' },
                             Ipv6CidrBlock: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc', 'Ipv6CidrBlocks'] }] },
                         },
                     },
@@ -421,6 +424,7 @@ describe('Stack Converter', () => {
                     other2: {
                         Type: 'AWS::EC2::Subnet',
                         Properties: {
+                            VpcId: { Ref: 'vpc2' },
                             Ipv6CidrBlock: { 'Fn::Select': [0, { 'Fn::GetAtt': ['vpc2', 'Ipv6CidrBlocks'] }] },
                         },
                     },
diff --git a/tests/mocks.ts b/tests/mocks.ts
index fa19c45..5c095c5 100644
--- a/tests/mocks.ts
+++ b/tests/mocks.ts
@@ -101,6 +101,11 @@ export function setMocks(resources?: MockResourceArgs[]) {
                                   })
                                 : 'abcd',
                     };
+                case 'aws:ecr/getCredentials:getCredentials':
+                    return {
+                        authorizationToken: btoa('user:password'),
+                        proxyEndpoint: 'https://12345678910.dkr.ecr.us-east-1.amazonaws.com',
+                    };
                 default:
                     return {};
             }
@@ -117,6 +122,26 @@ export function setMocks(resources?: MockResourceArgs[]) {
                     return { id: '', state: {} };
                 case 'cdk:index:Component':
                     return { id: '', state: {} };
+                case 'docker-build:index:Image':
+                    resources?.push(args);
+                    return {
+                        id: args.inputs.name + '_id',
+                        state: {
+                            ...args.inputs,
+                            id: args.inputs.name + '_id',
+                            ref: args.inputs.tags[0] + '@sha256:abcdefghijk1023',
+                        },
+                    };
+                case 'aws:ecr/repository:Repository':
+                    resources?.push(args);
+                    return {
+                        id: args.inputs.name + '_id',
+                        state: {
+                            ...args.inputs,
+                            arn: args.name + '_arn',
+                            repositoryUrl: '12345678910.dkr.ecr.us-east-1.amazonaws.com/' + args.inputs.name,
+                        },
+                    };
                 default:
                     resources?.push(args);
                     return {
diff --git a/tests/synthesizer.test.ts b/tests/synthesizer.test.ts
index 4ef2d54..6fd6ca4 100644
--- a/tests/synthesizer.test.ts
+++ b/tests/synthesizer.test.ts
@@ -3,8 +3,11 @@ import { Asset } from 'aws-cdk-lib/aws-s3-assets';
 import { setMocks, testApp } from './mocks';
 import { MockResourceArgs } from '@pulumi/pulumi/runtime';
 import { CfnBucket } from 'aws-cdk-lib/aws-s3';
+import { asNetworkMode, asPlatforms } from '../src/synthesizer';
+import { NetworkMode, Platform as DockerPlatform } from '@pulumi/docker-build';
+import { DockerImageAsset, Platform, NetworkMode as Network } from 'aws-cdk-lib/aws-ecr-assets';
 
-describe('Synthesizer', () => {
+describe('Synthesizer File Assets', () => {
     test('no assets = no staging resources', async () => {
         const resources: MockResourceArgs[] = [];
         setMocks(resources);
@@ -92,4 +95,228 @@ describe('Synthesizer', () => {
             ]),
         );
     });
+
+    test('asNetworkMode works', () => {
+        expect(asNetworkMode('host')).toEqual(NetworkMode.Host);
+    });
+
+    test('asNetworkMode throws', () => {
+        expect(() => {
+            asNetworkMode('abc');
+        }).toThrow(/Unsupported network mode: abc/);
+    });
+
+    test('asPlatforms works', () => {
+        expect(asPlatforms('linux/amd64')).toEqual([DockerPlatform.Linux_amd64]);
+    });
+
+    test('asPlatforms throws', () => {
+        expect(() => {
+            asPlatforms('abc');
+        }).toThrow(/Unsupported platform: abc/);
+    });
+});
+
+describe('Synthesizer Docker Assets', () => {
+    test('basic', async () => {
+        const resources: MockResourceArgs[] = [];
+        setMocks(resources);
+
+        await testApp((scope) => {
+            new CfnBucket(scope, 'Bucket');
+            new DockerImageAsset(scope, 'asset', {
+                directory: path.join(__dirname, 'test-data', 'app'),
+                assetName: 'test-asset',
+            });
+        });
+
+        expect(resources).toEqual(
+            expect.arrayContaining([
+                expect.objectContaining({
+                    name: 'staging-stack-project-stack',
+                    type: 'cdk:construct:StagingStack',
+                }),
+                expect.objectContaining({
+                    name: 'project-stack/test-asset',
+                    type: 'aws:ecr/repository:Repository',
+                }),
+                expect.objectContaining({
+                    name: 'lifecycle-policy',
+                    type: 'aws:ecr/lifecyclePolicy:LifecyclePolicy',
+                }),
+                expect.objectContaining({
+                    name: 'staging-stack-project-stack/test-asset',
+                    type: 'docker-build:index:Image',
+                    inputs: {
+                        buildOnPreview: true,
+                        context: {
+                            location: expect.stringMatching(/.*\/asset.[a-z0-9]+$/),
+                        },
+                        dockerfile: {
+                            location: expect.stringMatching(/.*\/asset.[a-z0-9]+\/Dockerfile$/),
+                        },
+                        network: 'default',
+                        push: true,
+                        registries: [
+                            {
+                                address: 'https://12345678910.dkr.ecr.us-east-1.amazonaws.com',
+                                password: 'password',
+                                username: 'user',
+                            },
+                        ],
+                        tags: [
+                            expect.stringMatching(
+                                /^12345678910.dkr.ecr.us-east-1.amazonaws.com\/project-stack\/test-asset:[a-z0-9]+/,
+                            ),
+                        ],
+                    },
+                }),
+            ]),
+        );
+    });
+
+    test('all configs', async () => {
+        const resources: MockResourceArgs[] = [];
+        setMocks(resources);
+
+        await testApp((scope) => {
+            new CfnBucket(scope, 'Bucket');
+            new DockerImageAsset(scope, 'asset', {
+                directory: path.join(__dirname, 'test-data'),
+                assetName: 'test-asset',
+                file: 'app/Dockerfile',
+                target: 'target',
+                cacheTo: {
+                    type: 'registry',
+                    params: {
+                        ref: '12345678910.dkr.ecr.us-east-1.amazonaws.com/project-stack/test-asset:cache',
+                    },
+                },
+                outputs: ['output'],
+                buildSsh: 'ssh',
+                platform: Platform.LINUX_AMD64,
+                cacheFrom: [
+                    {
+                        type: 'inline',
+                        params: {},
+                    },
+                ],
+                buildArgs: {
+                    key: 'value',
+                },
+                networkMode: Network.HOST,
+                buildSecrets: {
+                    secret: 'value',
+                },
+            });
+        });
+
+        expect(resources).toEqual(
+            expect.arrayContaining([
+                expect.objectContaining({
+                    name: 'staging-stack-project-stack',
+                    type: 'cdk:construct:StagingStack',
+                }),
+                expect.objectContaining({
+                    name: 'project-stack/test-asset',
+                    type: 'aws:ecr/repository:Repository',
+                }),
+                expect.objectContaining({
+                    name: 'lifecycle-policy',
+                    type: 'aws:ecr/lifecyclePolicy:LifecyclePolicy',
+                }),
+                expect.objectContaining({
+                    name: 'staging-stack-project-stack/test-asset',
+                    type: 'docker-build:index:Image',
+                    inputs: {
+                        buildArgs: {
+                            key: 'value',
+                        },
+                        buildOnPreview: true,
+                        cacheFrom: [
+                            {
+                                inline: {},
+                            },
+                        ],
+                        cacheTo: [
+                            {
+                                registry: {
+                                    ref: '12345678910.dkr.ecr.us-east-1.amazonaws.com/project-stack/test-asset:cache',
+                                },
+                            },
+                        ],
+                        context: {
+                            location: expect.stringMatching(/.*\/asset.[a-z0-9]+$/),
+                        },
+                        dockerfile: {
+                            location: expect.stringMatching(/.*\/asset.[a-z0-9]+\/app\/Dockerfile$/),
+                        },
+                        network: 'host',
+                        platforms: ['linux/amd64'],
+                        push: true,
+                        registries: [
+                            {
+                                address: 'https://12345678910.dkr.ecr.us-east-1.amazonaws.com',
+                                password: 'password',
+                                username: 'user',
+                            },
+                        ],
+                        secrets: {
+                            secret: 'value',
+                        },
+                        ssh: [
+                            {
+                                id: 'default',
+                                paths: ['ssh'],
+                            },
+                        ],
+                        tags: [
+                            expect.stringMatching(
+                                /^12345678910.dkr.ecr.us-east-1.amazonaws.com\/project-stack\/test-asset:[a-z0-9]+/,
+                            ),
+                        ],
+                        target: 'target',
+                    },
+                }),
+            ]),
+        );
+    });
+
+    test('images are deduplicated', async () => {
+        const resources: MockResourceArgs[] = [];
+        setMocks(resources);
+
+        await testApp((scope) => {
+            new CfnBucket(scope, 'Bucket');
+            new DockerImageAsset(scope, 'asset', {
+                directory: path.join(__dirname, 'test-data', 'app'),
+                assetName: 'test-asset',
+            });
+            new DockerImageAsset(scope, 'asset2', {
+                directory: path.join(__dirname, 'test-data', 'app'),
+                assetName: 'test-asset',
+            });
+        });
+        const images = resources.filter((r) => r.type === 'docker-build:index:Image');
+        expect(images.length).toEqual(1);
+    });
+
+    test('images with different assetNames are not deduplicated', async () => {
+        const resources: MockResourceArgs[] = [];
+        setMocks(resources);
+
+        await testApp((scope) => {
+            new CfnBucket(scope, 'Bucket');
+            new DockerImageAsset(scope, 'asset', {
+                directory: path.join(__dirname, 'test-data', 'app'),
+                assetName: 'test-asset',
+            });
+            new DockerImageAsset(scope, 'asset2', {
+                directory: path.join(__dirname, 'test-data', 'app'),
+                assetName: 'test-asset-2',
+            });
+        });
+        const images = resources.filter((r) => r.type === 'docker-build:index:Image');
+        expect(images.length).toEqual(2);
+    });
 });
diff --git a/tests/test-data/app/Dockerfile b/tests/test-data/app/Dockerfile
new file mode 100644
index 0000000..e69de29
diff --git a/tests/utils.ts b/tests/utils.ts
index e760a99..7168081 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -42,7 +42,10 @@ export function createStackManifest(
                 },
                 resource2: {
                     Type: 'AWS::S3::BucketPolicy',
-                    Properties: resource2Props,
+                    Properties: {
+                        policyDocument: {},
+                        ...resource2Props,
+                    },
                     DependsOn: resource2DependsOn,
                 },
             },