From c8896a9f62e1c67d953771328d2cbdfdba05ce9a Mon Sep 17 00:00:00 2001 From: so0k Date: Sat, 12 Sep 2026 02:01:48 +0800 Subject: [PATCH] feat(aws): BuildKit builder for Docker image assets Adds DockerAssetBuilder.BUILDKIT to AwsAssetManagerOptions. When selected, addDockerImageAsset emits one buildkit_image resource plus a provider "buildkit" pinned to cruxstack/buildkit 0.0.1, instead of kreuzwerker's docker_image + docker_registry_image. Motivation: kreuzwerker/docker needs a Docker Engine to build (exportLoad) and push (client.ImagePush), which a daemonless TACOS host (Atlantis, no root-equivalent socket) cannot provide. buildkit_image solves and pushes directly against a reachable rootless buildkitd. buildkit_autodiscover and embedded_buildkitd are hardcoded false (not configurable) as a security control: embedded_buildkitd would let config spawn a private daemon. The cdktn binding in src/aws/private/buildkit-provider.ts is hand-written since no @cdktn/provider-buildkit package is published. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Dgqc87KhZ9EijLMoJ61ZXe --- src/aws/aws-asset-manager.ts | 107 ++++++++++++++++ src/aws/private/buildkit-provider.ts | 109 ++++++++++++++++ .../assets/image-asset-buildkit.test.ts | 116 ++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 src/aws/private/buildkit-provider.ts create mode 100644 test/aws/storage/assets/image-asset-buildkit.test.ts diff --git a/src/aws/aws-asset-manager.ts b/src/aws/aws-asset-manager.ts index 2228f447..e9c03b33 100644 --- a/src/aws/aws-asset-manager.ts +++ b/src/aws/aws-asset-manager.ts @@ -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"; @@ -28,6 +31,7 @@ import { FileAssetPackaging, FileAssetSource, } from "../assets"; +import { BuildkitImage, BuildkitProvider } from "./private/buildkit-provider"; export interface AwsAssetManagerOptions { /** @@ -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", } /** @@ -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. */ @@ -217,6 +249,16 @@ export class AwsAssetManager implements IAssetManager { // type: , }); + 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, @@ -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; diff --git a/src/aws/private/buildkit-provider.ts b/src/aws/private/buildkit-provider.ts new file mode 100644 index 00000000..865f5e8a --- /dev/null +++ b/src/aws/private/buildkit-provider.ts @@ -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), + }; + } +} diff --git a/test/aws/storage/assets/image-asset-buildkit.test.ts b/test/aws/storage/assets/image-asset-buildkit.test.ts new file mode 100644 index 00000000..845dc01d --- /dev/null +++ b/test/aws/storage/assets/image-asset-buildkit.test.ts @@ -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/); + }); +});