Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/aws/aws-asset-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import {
registryImage as dockerRegistryImage,
} from "@cdktn/provider-docker";
import {
Annotations,
AssetType,
Fn,
TerraformAsset,
Token,
// ref,
} from "cdktn";
import { Construct } from "constructs";
Expand All @@ -28,6 +31,7 @@ import {
FileAssetPackaging,
FileAssetSource,
} from "../assets";
import { BuildkitImage, BuildkitProvider } from "./private/buildkit-provider";

export interface AwsAssetManagerOptions {
/**
Expand Down Expand Up @@ -63,6 +67,33 @@ export interface AwsAssetManagerOptions {
* @default - "" (no prefix)
*/
readonly dockerTagPrefix?: string;
/**
* How Docker image assets are built and pushed.
*
* @default DockerAssetBuilder.DOCKER
*/
readonly dockerBuilder?: DockerAssetBuilder;
/**
* BuildKit daemon address used with `DockerAssetBuilder.BUILDKIT`.
*
* @default "unix:///run/buildkit/buildkitd.sock"
*/
readonly buildkitAddress?: string;
}

/**
* Build backend for Docker image assets.
*/
export enum DockerAssetBuilder {
/**
* kreuzwerker/docker `docker_image` + `docker_registry_image`. Requires a Docker Engine.
*/
DOCKER = "docker",
/**
* cruxstack/buildkit `buildkit_image`: solves on a running buildkitd and pushes from it.
* Registry credentials come from `~/.docker/config.json` (e.g. `credHelpers` ecr-login).
*/
BUILDKIT = "buildkit",
}

/**
Expand Down Expand Up @@ -102,6 +133,7 @@ export class AwsAssetManager implements IAssetManager {
| ecrRepository.EcrRepository
| dataAwsEcrRepository.DataAwsEcrRepository;
private dockerProvider?: dockerProvider.DockerProvider;
private buildkitProvider?: BuildkitProvider;
/**
* Map of Terraform assets registered by this manager.
*/
Expand Down Expand Up @@ -217,6 +249,16 @@ export class AwsAssetManager implements IAssetManager {
// type: <auto-infer>,
});

if (this.props.dockerBuilder === DockerAssetBuilder.BUILDKIT) {
const location: DockerImageAssetLocation = {
imageUri: this.addBuildkitImage(id, asset, tfAsset.path, imageTag),
repositoryName: this.repository!.name,
imageTag,
};
this.dockerAssetMap.set(imageUri, location);
return location;
}

const imageAsset = new dockerImage.Image(this.scope, `${id}_Image`, {
// https://github.com/kreuzwerker/terraform-provider-docker/blob/v3.6.2/internal/provider/docker_buildx_build.go#L216
name: imageUri,
Expand Down Expand Up @@ -279,6 +321,71 @@ export class AwsAssetManager implements IAssetManager {
return location;
}

/**
* Build + push on buildkitd via `buildkit_image`; returns the pushed tag reference.
*/
private addBuildkitImage(
id: string,
asset: DockerImageAssetSource,
contextPath: string,
imageTag: string,
): string {
if (!asset.platform) {
throw new Error(
"DockerAssetBuilder.BUILDKIT requires an explicit platform (e.g. Platform.LINUX_ARM64)",
);
}
if (
asset.dockerBuildSsh ||
asset.dockerOutputs?.length ||
asset.networkMode
) {
throw new Error(
"DockerAssetBuilder.BUILDKIT does not support buildSsh, outputs or networkMode",
);
}
if (asset.dockerCacheDisabled) {
Annotations.of(this.scope).addWarning(
"cacheDisabled is ignored by DockerAssetBuilder.BUILDKIT",
);
}
this.buildkitProvider ??= new BuildkitProvider(this.scope, "Buildkit", {
buildkitAddress:
this.props.buildkitAddress ?? "unix:///run/buildkit/buildkitd.sock",
});

let secrets: { [key: string]: string } | undefined;
for (const [key, spec] of Object.entries(asset.dockerBuildSecrets ?? {})) {
if (!spec.startsWith("src=")) {
throw new Error(
`build secret ${key}: only DockerBuildSecret.fromSrc is supported by DockerAssetBuilder.BUILDKIT`,
);
}
secrets = { ...secrets, [key]: Fn.file(spec.slice("src=".length)) };
}
const cache = (entries: DockerCacheOption[]) =>
entries.map((e) => ({ type: e.type, attrs: e.params }));

const image = new BuildkitImage(this.scope, `${id}_Buildkit`, {
provider: this.buildkitProvider,
context: contextPath,
dockerfile: asset.dockerFile ?? "Dockerfile",
platforms: [asset.platform],
registry: Token.asString(
Fn.element(Fn.split("/", this.repository!.repositoryUrl), 0),
),
repository: this.repository!.name,
tags: [imageTag],
args: asset.dockerBuildArgs,
secrets,
target: asset.dockerBuildTarget,
cacheFrom: asset.dockerCacheFrom && cache(asset.dockerCacheFrom),
cacheTo: asset.dockerCacheTo && cache([asset.dockerCacheTo]),
triggers: { source_hash: asset.sourceHash },
});
return image.tagUrl;
}

private ensureBucket(): void {
if (this.bucket) return;

Expand Down
109 changes: 109 additions & 0 deletions src/aws/private/buildkit-provider.ts

@vincenthsh vincenthsh Sep 11, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't do this

Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Minimal hand-written bindings for the cruxstack/buildkit provider (no published cdktn package).
import { TerraformProvider, TerraformResource } from "cdktn";
import { Construct } from "constructs";

const PROVIDER_SOURCE = "cruxstack/buildkit";
const PROVIDER_VERSION = "0.0.1";

export interface BuildkitProviderConfig {
readonly buildkitAddress: string;
}

/**
* `provider "buildkit"` pinned to one explicit daemon: no auto-discovery, no embedded buildkitd.
*/
export class BuildkitProvider extends TerraformProvider {
constructor(
scope: Construct,
id: string,
private readonly config: BuildkitProviderConfig,
) {
super(scope, id, {
terraformResourceType: "buildkit",
terraformGeneratorMetadata: {
providerName: "buildkit",
providerVersionConstraint: PROVIDER_VERSION,
},
terraformProviderSource: PROVIDER_SOURCE,
});
}

protected synthesizeAttributes(): { [name: string]: any } {
return {
buildkit_address: this.config.buildkitAddress,
buildkit_autodiscover: false,
embedded_buildkitd: false,
};
}
}

export interface BuildkitCacheEntry {
readonly type: string;
readonly attrs?: { [key: string]: string };
}

export interface BuildkitImageConfig {
readonly provider: BuildkitProvider;
readonly context: string;
readonly dockerfile: string;
readonly platforms: string[];
readonly registry: string;
readonly repository: string;
readonly tags: string[];
readonly args?: { [key: string]: string };
readonly secrets?: { [key: string]: string };
readonly target?: string;
readonly cacheFrom?: BuildkitCacheEntry[];
readonly cacheTo?: BuildkitCacheEntry[];
readonly triggers?: { [key: string]: string };
}

/**
* `resource "buildkit_image"`: solve on buildkitd and push via the image exporter.
*/
export class BuildkitImage extends TerraformResource {
constructor(
scope: Construct,
id: string,
private readonly config: BuildkitImageConfig,
) {
super(scope, id, {
terraformResourceType: "buildkit_image",
terraformGeneratorMetadata: {
providerName: "buildkit",
providerVersionConstraint: PROVIDER_VERSION,
},
provider: config.provider,
});
}

/** Tag reference of the pushed image; depends on the build. */
public get tagUrl(): string {
return this.getStringAttribute("published.0.tag_url");
}

/** Manifest digest of the pushed image. */
public get imageDigest(): string {
return this.getStringAttribute("image_digest");
}

protected synthesizeAttributes(): { [name: string]: any } {
const c = this.config;
const cache = (entries?: BuildkitCacheEntry[]) =>
entries?.map((e) => ({ type: e.type, attrs: e.attrs }));
return {
context: c.context,
dockerfile: c.dockerfile,
platforms: c.platforms,
args: c.args,
secrets: c.secrets,
target: c.target,
triggers: c.triggers,
publish: [
{ registry: c.registry, repository: c.repository, tags: c.tags },
],
cache_from: cache(c.cacheFrom),
cache_to: cache(c.cacheTo),
};
}
}
116 changes: 116 additions & 0 deletions test/aws/storage/assets/image-asset-buildkit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as path from "path";
import {
image as dockerImage,
registryImage as dockerRegistryImage,
} from "@cdktn/provider-docker";
import { App, TerraformOutput, Testing } from "cdktn";
import "cdktn/lib/testing/adapters/jest";
import { DockerBuildSecret } from "../../../../src/";
import { DockerAssetBuilder } from "../../../../src/aws/aws-asset-manager";
import { AwsStack } from "../../../../src/aws/aws-stack";
import {
DockerImageAsset,
NetworkMode,
Platform,
} from "../../../../src/aws/storage/assets/image-asset";
import { Template } from "../../../assertions";

const TEST_OUTDIR = path.join(__dirname, "cdk.out");
const CDKTFJSON_PATH = path.join(__dirname, "fixtures", "app", "cdktf.json");
const REPO = "data.aws_ecr_repository.ExistingAssetRepository";

describe("image asset with DockerAssetBuilder.BUILDKIT", () => {
let stack: AwsStack;
beforeEach(() => {
const app = Testing.stubVersion(
new App({
outdir: TEST_OUTDIR,
stackTraces: false,
context: { cdktfJsonPath: CDKTFJSON_PATH },
}),
);
stack = new AwsStack(app, "MyStack", {
providerConfig: { region: "us-east-1" },
assetOptions: {
repositoryName: "existing-repo",
dockerBuilder: DockerAssetBuilder.BUILDKIT,
},
});
});

test("synthesizes buildkit_image + pinned provider, no docker resources", () => {
const image = new DockerImageAsset(stack, "Image", {
directory: path.join(__dirname, "demo-image-custom-docker-file"),
file: "Dockerfile.Custom",
platform: Platform.LINUX_ARM64,
buildArgs: { A: "b" },
buildSecrets: { TOKEN: DockerBuildSecret.fromSrc("/run/token") },
target: "final",
cacheFrom: [{ type: "registry", params: { ref: "repo/cache" } }],
});
new TerraformOutput(stack, "ImageUri", { value: image.imageUri });

const template = new Template(stack);
template.resourceCountIs(dockerImage.Image, 0);
template.resourceCountIs(dockerRegistryImage.RegistryImage, 0);
template.toMatchObject({
terraform: {
required_providers: {
buildkit: { source: "cruxstack/buildkit", version: "0.0.1" },
},
},
provider: {
buildkit: [
{
buildkit_address: "unix:///run/buildkit/buildkitd.sock",
buildkit_autodiscover: false,
embedded_buildkitd: false,
},
],
},
resource: {
buildkit_image: {
DockerAsset_Buildkit: {
provider: "buildkit",
context: `assets/DockerAsset/${image.assetHash}`,
dockerfile: "Dockerfile.Custom",
platforms: ["linux/arm64"],
args: { A: "b" },
secrets: { TOKEN: '${file("/run/token")}' },
target: "final",
publish: [
{
registry: `\${element(split("/", ${REPO}.repository_url), 0)}`,
repository: `\${${REPO}.name}`,
tags: [image.assetHash],
},
],
cache_from: [{ type: "registry", attrs: { ref: "repo/cache" } }],
triggers: { source_hash: image.assetHash },
},
},
},
});
expect(template.outputByName("ImageUri")).toEqual({
value: "${buildkit_image.DockerAsset_Buildkit.published.0.tag_url}",
});
});

test("requires an explicit platform", () => {
expect(() => {
new DockerImageAsset(stack, "Image", {
directory: path.join(__dirname, "demo-image"),
});
}).toThrow(/requires an explicit platform/);
});

test("rejects options buildkit_image cannot express", () => {
expect(() => {
new DockerImageAsset(stack, "Image", {
directory: path.join(__dirname, "demo-image"),
platform: Platform.LINUX_ARM64,
networkMode: NetworkMode.HOST,
});
}).toThrow(/does not support buildSsh, outputs or networkMode/);
});
});
Loading