From 4fbd7a03812d4d4ba99921b8512256baa8da16c9 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sat, 26 Sep 2026 10:09:42 +0100 Subject: [PATCH 1/2] Keep the tag of image references pinned by tag and digest ImageName.fromString split name:tag@digest at the @ first, leaving the tag inside the image name and exposing the digest as the tag. Modules that read the version from imageName.tag then misbehaved: MongoDB fell back to the legacy mongo shell and never became healthy, and Kafka threw in its constructor. Split off the digest first, read the tag from what precedes it, and expose the digest via a new optional digest field. --- .../kafka/src/kafka-container-7.test.ts | 8 ++++ .../mongodb/src/mongodb-container.test.ts | 7 +++- .../src/container-runtime/image-name.test.ts | 38 +++++++++++++++++++ .../src/container-runtime/image-name.ts | 34 +++++++++++------ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/modules/kafka/src/kafka-container-7.test.ts b/packages/modules/kafka/src/kafka-container-7.test.ts index cedffc668..de07b1775 100644 --- a/packages/modules/kafka/src/kafka-container-7.test.ts +++ b/packages/modules/kafka/src/kafka-container-7.test.ts @@ -228,6 +228,14 @@ describe("KafkaContainer", { timeout: 240_000 }, () => { ); }); + it("should read the version from an image pinned by tag and digest", async () => { + expect(() => + new KafkaContainer( + "confluentinc/cp-kafka:6.2.14@sha256:1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd" + ).withKraft() + ).toThrow("Provided Confluent Platform's version 6.2.14 is not supported in Kraft mode (must be 7.0.0 or above)"); + }); + it("should connect using kraft and custom network", async () => { await using network = await new Network().start(); await using container = await new KafkaContainer(IMAGE).withKraft().withNetwork(network).start(); diff --git a/packages/modules/mongodb/src/mongodb-container.test.ts b/packages/modules/mongodb/src/mongodb-container.test.ts index e6c014f40..df676e6a4 100644 --- a/packages/modules/mongodb/src/mongodb-container.test.ts +++ b/packages/modules/mongodb/src/mongodb-container.test.ts @@ -5,7 +5,12 @@ import { MongoDBContainer } from "./mongodb-container"; const IMAGE = getImage(__dirname); describe("MongoDBContainer", { timeout: 240_000 }, () => { - it.each([IMAGE, "mongo:6.0.25", "mongo:4.4.29"])("should work with %s", async (image) => { + it.each([ + IMAGE, + "mongo:6.0.25", + "mongo:4.4.29", + "mongo:8.2.12@sha256:e0ce8c35124d4a9f9785532d1f268f39e9728ffa1cb38f46fa482436424c4bd3", + ])("should work with %s", async (image) => { // connectMongo { await using container = await new MongoDBContainer(image).start(); diff --git a/packages/testcontainers/src/container-runtime/image-name.test.ts b/packages/testcontainers/src/container-runtime/image-name.test.ts index 249ad7d39..10697798c 100644 --- a/packages/testcontainers/src/container-runtime/image-name.test.ts +++ b/packages/testcontainers/src/container-runtime/image-name.test.ts @@ -8,6 +8,9 @@ describe("ContainerImage", { concurrent: false }, () => { expect(imageName.equals(new ImageName("registry", "image", "anotherTag"))).toBe(false); expect(imageName.equals(new ImageName("registry", "anotherImage", "tag"))).toBe(false); expect(imageName.equals(new ImageName("anotherRegistry", "image", "tag"))).toBe(false); + expect(imageName.equals(new ImageName("registry", "image", "tag", "sha256:1234abcd1234abcd1234abcd1234abcd"))).toBe( + false + ); }); describe("string", { concurrent: false }, () => { @@ -31,6 +34,16 @@ describe("ContainerImage", { concurrent: false }, () => { expect(imageName.string).toBe("registry/image@sha256:1234abcd1234abcd1234abcd1234abcd"); }); + it("should work with tag and digest", () => { + const imageName = new ImageName(undefined, "image", "tag", "sha256:1234abcd1234abcd1234abcd1234abcd"); + expect(imageName.string).toBe("image:tag@sha256:1234abcd1234abcd1234abcd1234abcd"); + }); + + it("should work with registry, tag and digest", () => { + const imageName = new ImageName("registry", "image", "tag", "sha256:1234abcd1234abcd1234abcd1234abcd"); + expect(imageName.string).toBe("registry/image:tag@sha256:1234abcd1234abcd1234abcd1234abcd"); + }); + it("should not append the `latest` tag to image IDs", () => { const imageName = new ImageName( undefined, @@ -74,6 +87,7 @@ describe("ContainerImage", { concurrent: false }, () => { expect(imageName.registry).toBeUndefined(); expect(imageName.image).toBe("image"); expect(imageName.tag).toBe("latest"); + expect(imageName.digest).toBeUndefined(); }); it("should work without tag", () => { @@ -130,6 +144,30 @@ describe("ContainerImage", { concurrent: false }, () => { expect(imageName.registry).toBe(undefined); expect(imageName.image).toBe("image"); expect(imageName.tag).toBe("sha256:1234abcd1234abcd1234abcd1234abcd"); + expect(imageName.digest).toBe("sha256:1234abcd1234abcd1234abcd1234abcd"); + expect(imageName.string).toBe("image@sha256:1234abcd1234abcd1234abcd1234abcd"); + }); + + it("should work with tag and digest", () => { + const imageName = ImageName.fromString("image:tag@sha256:1234abcd1234abcd1234abcd1234abcd"); + + expect(imageName.registry).toBe(undefined); + expect(imageName.image).toBe("image"); + expect(imageName.tag).toBe("tag"); + expect(imageName.digest).toBe("sha256:1234abcd1234abcd1234abcd1234abcd"); + expect(imageName.string).toBe("image:tag@sha256:1234abcd1234abcd1234abcd1234abcd"); + }); + + it("should work with registry with port, nested image, tag and digest", () => { + const imageName = ImageName.fromString( + "domain.com:5000/parent/child:tag@sha256:1234abcd1234abcd1234abcd1234abcd" + ); + + expect(imageName.registry).toBe("domain.com:5000"); + expect(imageName.image).toBe("parent/child"); + expect(imageName.tag).toBe("tag"); + expect(imageName.digest).toBe("sha256:1234abcd1234abcd1234abcd1234abcd"); + expect(imageName.string).toBe("domain.com:5000/parent/child:tag@sha256:1234abcd1234abcd1234abcd1234abcd"); }); it("should work with image being an image ID", () => { diff --git a/packages/testcontainers/src/container-runtime/image-name.ts b/packages/testcontainers/src/container-runtime/image-name.ts index 8e8e85427..863b1c33f 100644 --- a/packages/testcontainers/src/container-runtime/image-name.ts +++ b/packages/testcontainers/src/container-runtime/image-name.ts @@ -8,8 +8,13 @@ export class ImageName { constructor( public readonly registry: string | undefined, public readonly image: string, - public readonly tag: string + public readonly tag: string, + public readonly digest?: string ) { + if (!this.digest && this.tag.startsWith("sha256:")) { + // A digest-only reference (image@sha256:...) keeps the digest as its tag. + this.digest = this.tag; + } if (!this.registry && process.env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX) { const prefix = process.env.TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX; @@ -31,11 +36,12 @@ export class ImageName { } log.info(message); } + const tagAndDigest = this.digest ? `${this.tag}@${this.digest}` : this.tag; if (this.registry) { if (this.tag.startsWith("sha256:")) { this.string = `${this.registry}/${this.image}@${this.tag}`; } else { - this.string = `${this.registry}/${this.image}:${this.tag}`; + this.string = `${this.registry}/${this.image}:${tagAndDigest}`; } } else if (this.tag === "latest" && ImageName.hexRE.test(this.image)) { // 64 byte hex string. This refers to an image sha256 directly. @@ -48,26 +54,32 @@ export class ImageName { } else if (this.tag.startsWith("sha256:")) { this.string = `${this.image}@${this.tag}`; } else { - this.string = `${this.image}:${this.tag}`; + this.string = `${this.image}:${tagAndDigest}`; } } public equals(other: ImageName): boolean { - return this.registry === other.registry && this.image === other.image && this.tag === other.tag; + return ( + this.registry === other.registry && + this.image === other.image && + this.tag === other.tag && + this.digest === other.digest + ); } public static fromString(string: string): ImageName { const registry = this.getRegistry(string); const stringWithoutRegistry = registry ? string.split("/").slice(1).join("/") : string; - if (stringWithoutRegistry.includes("@")) { - const [image, tag] = stringWithoutRegistry.split("@"); - return new ImageName(registry, image, tag); - } else if (stringWithoutRegistry.includes(":")) { - const [image, tag] = stringWithoutRegistry.split(":"); - return new ImageName(registry, image, tag); + const [imageAndTag, digest] = stringWithoutRegistry.split("@"); + + if (imageAndTag.includes(":")) { + const [image, tag] = imageAndTag.split(":"); + return new ImageName(registry, image, tag, digest); + } else if (digest) { + return new ImageName(registry, imageAndTag, digest); } else { - return new ImageName(registry, stringWithoutRegistry, "latest"); + return new ImageName(registry, imageAndTag, "latest"); } } From dd0a53bf9fe53c323749e02420a104b314591e29 Mon Sep 17 00:00:00 2001 From: Cristian Greco Date: Sat, 26 Sep 2026 10:26:12 +0100 Subject: [PATCH 2/2] Remove module-specific tag and digest tests --- packages/modules/kafka/src/kafka-container-7.test.ts | 8 -------- packages/modules/mongodb/src/mongodb-container.test.ts | 7 +------ 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/modules/kafka/src/kafka-container-7.test.ts b/packages/modules/kafka/src/kafka-container-7.test.ts index de07b1775..cedffc668 100644 --- a/packages/modules/kafka/src/kafka-container-7.test.ts +++ b/packages/modules/kafka/src/kafka-container-7.test.ts @@ -228,14 +228,6 @@ describe("KafkaContainer", { timeout: 240_000 }, () => { ); }); - it("should read the version from an image pinned by tag and digest", async () => { - expect(() => - new KafkaContainer( - "confluentinc/cp-kafka:6.2.14@sha256:1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd" - ).withKraft() - ).toThrow("Provided Confluent Platform's version 6.2.14 is not supported in Kraft mode (must be 7.0.0 or above)"); - }); - it("should connect using kraft and custom network", async () => { await using network = await new Network().start(); await using container = await new KafkaContainer(IMAGE).withKraft().withNetwork(network).start(); diff --git a/packages/modules/mongodb/src/mongodb-container.test.ts b/packages/modules/mongodb/src/mongodb-container.test.ts index df676e6a4..e6c014f40 100644 --- a/packages/modules/mongodb/src/mongodb-container.test.ts +++ b/packages/modules/mongodb/src/mongodb-container.test.ts @@ -5,12 +5,7 @@ import { MongoDBContainer } from "./mongodb-container"; const IMAGE = getImage(__dirname); describe("MongoDBContainer", { timeout: 240_000 }, () => { - it.each([ - IMAGE, - "mongo:6.0.25", - "mongo:4.4.29", - "mongo:8.2.12@sha256:e0ce8c35124d4a9f9785532d1f268f39e9728ffa1cb38f46fa482436424c4bd3", - ])("should work with %s", async (image) => { + it.each([IMAGE, "mongo:6.0.25", "mongo:4.4.29"])("should work with %s", async (image) => { // connectMongo { await using container = await new MongoDBContainer(image).start();