The question
The asset pipeline work (#339, #371) adds the first substantial capability to cdktn that isn't a mapping of existing Terraform functionality. That makes two things worth settling before much more code lands:
- How much belongs in the core
cdktn library, versus in standalone packages or in the higher-level construct libraries built on top of it.
- When each part runs — synth, plan, or apply.
Both matter more than usual here. Everything public in cdktn goes through JSII to TypeScript, Python, Go, Java, and C#, so the API surface is expensive to change once released. And assets have a lifecycle beyond creation — something has to decide when an artifact is built, when it reaches its destination, and what happens to it on destroy.
This proposal argues for a small core (identity, staging, and two extension interfaces), with bundling and publishing implemented outside it.
Phases: build at synth, publish at apply
Why AWS CDK's shape doesn't transfer
AWS CDK's asset architecture is driven by two properties of CloudFormation:
- CloudFormation cannot reference local files. Everything must already be in S3 or ECR when the template is submitted.
- CloudFormation executes remotely. The deploying machine hands off a template; the engine that acts on it cannot see your disk.
Those two constraints require publishing to happen before deployment, which requires a manifest describing what to publish, which requires a cloud assembly to carry that manifest, which requires cdk-assets to act on it, and cdk bootstrap to have created somewhere to put it. The whole structure follows from those two facts.
Terraform has neither property. Providers read local files directly — source on aws_s3_object is exactly that feature — and apply runs where the files are, or where they were uploaded alongside the configuration. Adopting CDK's structure would mean paying its costs without having its constraints.
Pulumi is the closer comparison: the same "write infrastructure in a real language" premise, but on a Terraform-shaped resource model rather than CloudFormation. Pulumi models assets as values (FileAsset, FileArchive, AssetArchive) passed as inputs to resources, packages them during the update, and has no separate publish phase at all.
The proposal
- Build and bundle at synth. This isn't merely permitted, it's required:
plan can only produce an honest diff if the artifact's identity is already fixed when it runs.
- Publish at apply, as ordinary Terraform resources, so publishing is planned, state-tracked, and destroyable.
- No new CLI phase.
synth → plan → apply stays as it is.
Concretely, within a synth:
| when |
what happens |
| construct time |
hash computed, staged path determined, publisher emits resources |
onSynthesize |
build runs, artifact written to the staged path |
toTerraform() |
configuration rendered, referencing that path |
terraform apply |
resources publish the artifact |
The artifact's path is known at construct time while the file doesn't exist until synth. That's only legal because the name is content-addressed — a resource can reference a file that doesn't exist yet precisely because its name derives from a hash of its contents rather than from its existence.
What belongs in core
Three things: identity, staging, and two interfaces.
/** Input to a publisher. A struct — callers construct these. */
export interface StagedAsset {
readonly assetHash: string;
readonly path: string; // relative to the stack directory
readonly packaging: FileAssetPackaging;
}
/**
* What a publisher hands back. A class rather than a struct, so the location
* map is real storage that typed subclasses read from rather than duplicate.
*/
export class AssetReference {
public readonly assetHash: string;
/** Resources the consumer must depend on. */
public readonly dependencies: ITerraformDependable[];
private readonly bag: { [key: string]: string };
public tryGetLocation(key: string): string | undefined { return this.bag[key]; }
public get location(): { [key: string]: string } { return { ...this.bag }; }
}
/** Publishes a staged artifact and returns a reference to it. */
export interface IAssetPublisher {
publish(id: string, asset: StagedAsset): AssetReference;
}
/** Transforms a source tree into an artifact. Runs at synth. */
export interface IAssetBundler {
bundle(options: BundleOptions): string; // returns the directory holding output
}
And the construct that produces a StagedAsset:
export class Asset extends Construct {
public readonly assetHash: string;
public readonly path: string; // stack-relative, known immediately
public readonly packaging: FileAssetPackaging;
constructor(scope: Construct, id: string, props: AssetProps) {
super(scope, id);
this.source = path.resolve(props.sourcePath);
this.packaging = declaredPackaging(props);
this.assetHash = hashSource(this.source, props);
this.path = path.posix.join("assets", `asset.${this.assetHash}${ext}`);
this.bundler = props.bundler;
addCustomSynthesis(this, { onSynthesize: (s) => this.stage(s) });
}
/** The only place this construct touches the filesystem. */
private stage(session: ISynthesisSession) {
if (skipBundling(session, this)) return;
const produced = this.bundler
? this.bundler.bundle({ source: this.source, scratch: mkScratch() })
: this.source;
writeInto(session, this.path, produced);
}
public get staged(): StagedAsset {
return { assetHash: this.assetHash, path: this.path, packaging: this.packaging };
}
}
Two things about this split are worth calling out.
Hash in the constructor, filesystem work in onSynthesize. This is already the idiom TerraformAsset uses — it computes its hash eagerly and does its copy in _onSynthesize. Following it keeps assets composable with the rest of the tree and puts the side effect in the one window where it's safe: after validation, before configuration is emitted.
Deferring the build makes it skippable. A build that runs in the constructor runs on every diff, every plan, and every test that instantiates the tree. In onSynthesize, the synthesizer can decide — which is how AWS CDK avoids bundling every asset in an app when you're deploying one stack. ISynthesisSession already carries arbitrary context, so targeted stacks can be passed through without new API.
There is a trade-off: deferring only works when the hash and packaging are knowable without building.
|
build result needed at construct time? |
SOURCE hash + declared output type |
no — deferrable and skippable |
AssetHashType.OUTPUT |
yes |
BundlingOutput.AUTO_DISCOVER |
yes — packaging is discovered by inspecting output |
Suggestion: make a declared output type the default and AUTO_DISCOVER opt-in, so the skippable path is what people get without thinking about it. OUTPUT hashing stays supported, documented as forcing an eager build.
What lives outside core
|
where |
why |
Bundlers — Docker, esbuild, pip, go build |
@cdktn/bundler-* packages |
Builds aren't cloud-specific and the set is open-ended. AWS CDK's aws-lambda-nodejs / -python / -go are the same pattern: separate packages over one shared bundling primitive. |
| Publishers — S3, Blob Storage, GCS, registries |
the cloud construct libraries |
They need provider bindings, and the destination details are inherently provider-specific. |
AWS CDK asset vocabulary — FileAssetSource, FileAssetLocation, and similar |
a compatibility layer |
These exist to satisfy ported AWS CDK L2/L3 constructs. There's precedent for keeping that separate: cfncompat already carries CloudFormation compatibility semantics as its own provider rather than folding them into core. |
Publishing: two worked examples
The point of IAssetPublisher is that publishing to the same destination can legitimately happen more than one way. A Terraform resource is the obvious mechanism, but plenty of destinations have no suitable resource, and for large or numerous files a CLI tool is often the better option. Both should be available without the consuming construct caring which is in use.
Backed by a Terraform resource
export class S3ObjectPublisher extends Construct implements IS3AssetPublisher {
public publish(id: string, asset: StagedAsset): S3AssetReference {
const obj = new S3Object(this, id, {
bucket: this.bucket.id,
key: `assets/${asset.assetHash}`,
source: asset.path,
sourceHash: asset.assetHash,
});
return new S3AssetReference(asset.assetHash, [obj], this.bucket.id, obj.key);
}
}
Backed by a CLI tool
export class CliBlobPublisher extends Construct implements IBlobAssetPublisher {
public publish(id: string, asset: StagedAsset): BlobAssetReference {
const blobName = `assets/${asset.assetHash}.zip`;
const url = `https://${this.account}.blob.core.windows.net/${this.container}/${blobName}`;
const upload = new DataResource(this, id, {
triggersReplace: {
assetHash: asset.assetHash,
contentHash: Fn.filebase64sha256(asset.path),
},
provisioners: [{
type: "local-exec",
command: [
"az storage blob upload",
`--account-name ${this.account}`,
`--container-name ${this.container}`,
`--name ${blobName}`,
`--file ${asset.path}`,
"--overwrite --auth-mode login",
].join(" "),
}],
});
return new BlobAssetReference(asset.assetHash, [upload], url);
}
}
Both emit their resources at construct time and do their work at apply, so they're interchangeable from the caller's side:
const asset = new Asset(this, "Code", { sourcePath: props.codePath, bundler: props.bundler });
const ref = props.publisher.publish("Code", asset.staged);
new LambdaFunction(this, "Resource", {
s3Bucket: ref.bucket,
s3Key: ref.key,
sourceCodeHash: Fn.filebase64sha256(asset.path),
dependsOn: ref.dependencies,
});
The CLI variant has real limitations, and they should be documented rather than hidden: no drift detection (deleting the blob out of band goes unnoticed), no destroy without a when = "destroy" provisioner and its awkward self.input restrictions, and no way to read back anything the destination assigns, since local-exec output isn't captured into state.
That last one implies a constraint on the API:
AssetReference should carry only what a publisher can compute, never what a destination assigns. Anything assigned (a registry digest, an S3 version id) belongs on a cloud-specific subclass that only resource-backed publishers return.
Without that rule, CLI-backed publishing can't satisfy the interface and the whole "more than one mechanism" premise collapses.
Hashing: two different jobs
Assets need two hashes, and only one of them is cdktn's to compute.
Source hash — computed at construct time, names the artifact, drives caching and the skip decision. Because the staged path contains it, a change in source is already visible to Terraform as a changed source argument.
Content hash — what a resource takes as an argument, and it varies by target in both algorithm and encoding: aws_lambda_function.source_code_hash wants base64-encoded SHA256, aws_s3_object.etag and azurerm_storage_blob.content_md5 want MD5. No single value satisfies all of them.
Rather than computing one, publishers should emit a Terraform expression:
sourceCodeHash: Fn.filebase64sha256(asset.path)
Terraform evaluates it at plan time, when the file exists. This avoids the ordering problem entirely — the publisher runs at construct time, the file appears at synth, Terraform reads it at plan.
It also fixes a real correctness gap. Bundling isn't always deterministic. If a dependency install produces different bytes from identical sources, the source hash is unchanged, the content-addressed path is unchanged, and a source-hash-only design shows no diff while the artifact has actually changed. A hash computed over the real file catches that; a hash over the inputs structurally cannot.
Constraints from JSII
The design is shaped by a JSII rule worth stating explicitly, since the natural approach doesn't work. Given a cloud-specific publisher that narrows the return type:
export interface IS3AssetPublisher extends IAssetPublisher {
publish(id: string, asset: StagedAsset): S3AssetReference;
}
jsii 5.9 rejects it:
error JSII5015: Interface "IS3AssetPublisher" re-declares member "publish".
This is not supported as it results in invalid C#.
[language-compatibility/redeclared-interface-member]
So covariant return narrowing is unavailable. What does work — verified by compiling it — is:
- Publisher interfaces are siblings, not a hierarchy.
IS3AssetPublisher does not extend IAssetPublisher. In practice this costs nothing: a Lambda construct accepts an S3 publisher and an Azure Function accepts a blob publisher, and they never need to be substituted for one another.
- Reference classes do form a hierarchy.
S3AssetReference extends AssetReference is fine, and that's where sharing actually matters — anything downstream can read assetHash and dependencies off any reference.
- References are classes, not structs. The usual argument for structs is construction ergonomics, which doesn't apply to a value that's only ever returned. Making it a class lets typed accessors read from the location map instead of duplicating it, so
bucket and location["bucket"] cannot drift.
One consequence worth being upfront about: the shared vocabulary (StagedAsset, AssetReference) is the load-bearing part of what core provides. The base IAssetPublisher interface is thinner — useful for callers that only need the hash and the ordering.
Opting out
Terraform-native publishing suits many cases and not all of them. Each level hands more responsibility back to the user, and all of them should work from the start rather than being deferred:
| level |
|
|
| 0 |
Different destination |
Use a different publisher, or emit the resource directly. No core API involved. |
| 1 |
Publish out of band, cdktn keeps identity |
cdktn writes a manifest of staged artifacts; an external pipeline publishes them; constructs reference by hash. |
| 2 |
cdktn owns nothing |
fromUri() / fromDigest() style factories on consuming constructs, for artifacts published entirely elsewhere. |
| 3 |
Bring your own build |
Point at prebuilt output, or supply an IAssetBundler. |
Level 1 is worth designing for explicitly. A CI pipeline that already publishes to ECR or Artifactory — often with signing or scanning between build and deploy — is a common setup, and if that case is awkward the model has failed regardless of how well level 0 works. The manifest it needs is a small file:
[
{ "id": "...", "hash": "...", "path": "assets/asset.ABC123.zip",
"packaging": "zip", "stack": "my-stack" }
]
Open questions
Container images. Files fit this model cleanly; images don't yet. Build at synth and push at apply requires moving the image between phases, which isn't practical at realistic sizes. Building and pushing both at synth works but makes synth perform authenticated writes. Building at apply requires Docker on the apply machine, which rules out hosted runners. One partial idea: tag images with the content hash of the build context rather than referencing them by digest, which makes the reference resolvable at synth and decouples push timing from reference resolution — but that interacts with registry tag-immutability settings and needs checking. Input especially welcome here.
Shared artifacts. An artifact consumed by several stacks — who publishes it once, and how is that ordering expressed across workspaces?
Cleanup. Superseded artifacts accumulate. For resource-backed publishing, bucket lifecycle rules are the natural answer. For CLI-backed publishing, there isn't one yet.
Does the base IAssetPublisher earn its place, given cloud libraries will define sibling interfaces rather than extending it?
Relationship to #339 and #371
The existing work builds identity, staging, and bundling — which is what this proposal argues belongs in core. Nothing needs to be discarded. Three changes would follow from adopting it:
- Stage inside the stack directory.
AssetStaging currently resolves its output to <outdir>/assets, a sibling of stacks/. A resource whose source points there references a path outside its own module directory, which works locally and breaks as soon as the configuration is uploaded to a hosted runner.
- Move bundling from the constructor to
onSynthesize, so it can be skipped, and add the skip.
- Hold back
FileAssetLocation and DockerImageAssetLocation. They model locations resolved at synth; under this model those values are Terraform tokens, and imageTag would want to be a digest. If they're needed for AWS CDK compatibility, a compatibility package is the better home.
Feedback on the phase model and the core/non-core split is most useful — those decide everything downstream. The specific type shapes are easier to adjust later.
The question
The asset pipeline work (#339, #371) adds the first substantial capability to cdktn that isn't a mapping of existing Terraform functionality. That makes two things worth settling before much more code lands:
cdktnlibrary, versus in standalone packages or in the higher-level construct libraries built on top of it.Both matter more than usual here. Everything public in
cdktngoes through JSII to TypeScript, Python, Go, Java, and C#, so the API surface is expensive to change once released. And assets have a lifecycle beyond creation — something has to decide when an artifact is built, when it reaches its destination, and what happens to it on destroy.This proposal argues for a small core (identity, staging, and two extension interfaces), with bundling and publishing implemented outside it.
Phases: build at synth, publish at apply
Why AWS CDK's shape doesn't transfer
AWS CDK's asset architecture is driven by two properties of CloudFormation:
Those two constraints require publishing to happen before deployment, which requires a manifest describing what to publish, which requires a cloud assembly to carry that manifest, which requires
cdk-assetsto act on it, andcdk bootstrapto have created somewhere to put it. The whole structure follows from those two facts.Terraform has neither property. Providers read local files directly —
sourceonaws_s3_objectis exactly that feature — and apply runs where the files are, or where they were uploaded alongside the configuration. Adopting CDK's structure would mean paying its costs without having its constraints.Pulumi is the closer comparison: the same "write infrastructure in a real language" premise, but on a Terraform-shaped resource model rather than CloudFormation. Pulumi models assets as values (
FileAsset,FileArchive,AssetArchive) passed as inputs to resources, packages them during the update, and has no separate publish phase at all.The proposal
plancan only produce an honest diff if the artifact's identity is already fixed when it runs.synth → plan → applystays as it is.Concretely, within a synth:
onSynthesizetoTerraform()terraform applyThe artifact's path is known at construct time while the file doesn't exist until synth. That's only legal because the name is content-addressed — a resource can reference a file that doesn't exist yet precisely because its name derives from a hash of its contents rather than from its existence.
What belongs in core
Three things: identity, staging, and two interfaces.
And the construct that produces a
StagedAsset:Two things about this split are worth calling out.
Hash in the constructor, filesystem work in
onSynthesize. This is already the idiomTerraformAssetuses — it computes its hash eagerly and does its copy in_onSynthesize. Following it keeps assets composable with the rest of the tree and puts the side effect in the one window where it's safe: after validation, before configuration is emitted.Deferring the build makes it skippable. A build that runs in the constructor runs on every
diff, everyplan, and every test that instantiates the tree. InonSynthesize, the synthesizer can decide — which is how AWS CDK avoids bundling every asset in an app when you're deploying one stack.ISynthesisSessionalready carries arbitrary context, so targeted stacks can be passed through without new API.There is a trade-off: deferring only works when the hash and packaging are knowable without building.
SOURCEhash + declared output typeAssetHashType.OUTPUTBundlingOutput.AUTO_DISCOVERSuggestion: make a declared output type the default and
AUTO_DISCOVERopt-in, so the skippable path is what people get without thinking about it.OUTPUThashing stays supported, documented as forcing an eager build.What lives outside core
go build@cdktn/bundler-*packagesaws-lambda-nodejs/-python/-goare the same pattern: separate packages over one shared bundling primitive.FileAssetSource,FileAssetLocation, and similarcfncompatalready carries CloudFormation compatibility semantics as its own provider rather than folding them into core.Publishing: two worked examples
The point of
IAssetPublisheris that publishing to the same destination can legitimately happen more than one way. A Terraform resource is the obvious mechanism, but plenty of destinations have no suitable resource, and for large or numerous files a CLI tool is often the better option. Both should be available without the consuming construct caring which is in use.Backed by a Terraform resource
Backed by a CLI tool
Both emit their resources at construct time and do their work at apply, so they're interchangeable from the caller's side:
The CLI variant has real limitations, and they should be documented rather than hidden: no drift detection (deleting the blob out of band goes unnoticed), no destroy without a
when = "destroy"provisioner and its awkwardself.inputrestrictions, and no way to read back anything the destination assigns, sincelocal-execoutput isn't captured into state.That last one implies a constraint on the API:
Without that rule, CLI-backed publishing can't satisfy the interface and the whole "more than one mechanism" premise collapses.
Hashing: two different jobs
Assets need two hashes, and only one of them is cdktn's to compute.
Source hash — computed at construct time, names the artifact, drives caching and the skip decision. Because the staged path contains it, a change in source is already visible to Terraform as a changed
sourceargument.Content hash — what a resource takes as an argument, and it varies by target in both algorithm and encoding:
aws_lambda_function.source_code_hashwants base64-encoded SHA256,aws_s3_object.etagandazurerm_storage_blob.content_md5want MD5. No single value satisfies all of them.Rather than computing one, publishers should emit a Terraform expression:
Terraform evaluates it at plan time, when the file exists. This avoids the ordering problem entirely — the publisher runs at construct time, the file appears at synth, Terraform reads it at plan.
It also fixes a real correctness gap. Bundling isn't always deterministic. If a dependency install produces different bytes from identical sources, the source hash is unchanged, the content-addressed path is unchanged, and a source-hash-only design shows no diff while the artifact has actually changed. A hash computed over the real file catches that; a hash over the inputs structurally cannot.
Constraints from JSII
The design is shaped by a JSII rule worth stating explicitly, since the natural approach doesn't work. Given a cloud-specific publisher that narrows the return type:
jsii 5.9 rejects it:
So covariant return narrowing is unavailable. What does work — verified by compiling it — is:
IS3AssetPublisherdoes not extendIAssetPublisher. In practice this costs nothing: a Lambda construct accepts an S3 publisher and an Azure Function accepts a blob publisher, and they never need to be substituted for one another.S3AssetReference extends AssetReferenceis fine, and that's where sharing actually matters — anything downstream can readassetHashanddependenciesoff any reference.bucketandlocation["bucket"]cannot drift.One consequence worth being upfront about: the shared vocabulary (
StagedAsset,AssetReference) is the load-bearing part of what core provides. The baseIAssetPublisherinterface is thinner — useful for callers that only need the hash and the ordering.Opting out
Terraform-native publishing suits many cases and not all of them. Each level hands more responsibility back to the user, and all of them should work from the start rather than being deferred:
fromUri()/fromDigest()style factories on consuming constructs, for artifacts published entirely elsewhere.IAssetBundler.Level 1 is worth designing for explicitly. A CI pipeline that already publishes to ECR or Artifactory — often with signing or scanning between build and deploy — is a common setup, and if that case is awkward the model has failed regardless of how well level 0 works. The manifest it needs is a small file:
[ { "id": "...", "hash": "...", "path": "assets/asset.ABC123.zip", "packaging": "zip", "stack": "my-stack" } ]Open questions
Container images. Files fit this model cleanly; images don't yet. Build at synth and push at apply requires moving the image between phases, which isn't practical at realistic sizes. Building and pushing both at synth works but makes synth perform authenticated writes. Building at apply requires Docker on the apply machine, which rules out hosted runners. One partial idea: tag images with the content hash of the build context rather than referencing them by digest, which makes the reference resolvable at synth and decouples push timing from reference resolution — but that interacts with registry tag-immutability settings and needs checking. Input especially welcome here.
Shared artifacts. An artifact consumed by several stacks — who publishes it once, and how is that ordering expressed across workspaces?
Cleanup. Superseded artifacts accumulate. For resource-backed publishing, bucket lifecycle rules are the natural answer. For CLI-backed publishing, there isn't one yet.
Does the base
IAssetPublisherearn its place, given cloud libraries will define sibling interfaces rather than extending it?Relationship to #339 and #371
The existing work builds identity, staging, and bundling — which is what this proposal argues belongs in core. Nothing needs to be discarded. Three changes would follow from adopting it:
AssetStagingcurrently resolves its output to<outdir>/assets, a sibling ofstacks/. A resource whosesourcepoints there references a path outside its own module directory, which works locally and breaks as soon as the configuration is uploaded to a hosted runner.onSynthesize, so it can be skipped, and add the skip.FileAssetLocationandDockerImageAssetLocation. They model locations resolved at synth; under this model those values are Terraform tokens, andimageTagwould want to be a digest. If they're needed for AWS CDK compatibility, a compatibility package is the better home.Feedback on the phase model and the core/non-core split is most useful — those decide everything downstream. The specific type shapes are easier to adjust later.