diff --git a/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java index 2d3104af6b1..74ecab217eb 100644 --- a/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java +++ b/core/src/main/java/org/testcontainers/containers/ParsedDockerComposeFile.java @@ -12,6 +12,8 @@ import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.NodeId; +import org.yaml.snakeyaml.nodes.ScalarNode; import org.yaml.snakeyaml.nodes.Tag; import org.yaml.snakeyaml.representer.Representer; import org.yaml.snakeyaml.resolver.Resolver; @@ -46,17 +48,22 @@ class ParsedDockerComposeFile { LoaderOptions options = new LoaderOptions(); options.setMaxAliasesForCollections(1_000); DumperOptions dumperOptions = new DumperOptions(); + Resolver resolver = new Resolver(); SafeConstructor constructor = new SafeConstructor(options) { @Override protected Object constructObject(Node node) { - if (node.getTag().equals(new Tag("!reset")) || node.getTag().equals(new Tag("!override"))) { + if (node.getTag().equals(new Tag("!reset"))) { return null; } + if (node.getTag().equals(new Tag("!override"))) { + // !override replaces the value from previous compose files, so keep the value as if it was untagged + node.setTag(resolveUntaggedTag(node, resolver)); + } return super.constructObject(node); } }; - Yaml yaml = new Yaml(constructor, new Representer(dumperOptions), dumperOptions, options, new Resolver()); + Yaml yaml = new Yaml(constructor, new Representer(dumperOptions), dumperOptions, options, resolver); try (FileInputStream fileInputStream = FileUtils.openInputStream(composeFile)) { composeFileContent = yaml.load(fileInputStream); } catch (Exception e) { @@ -76,6 +83,18 @@ protected Object constructObject(Node node) { parseAndValidate(); } + private static Tag resolveUntaggedTag(Node node, Resolver resolver) { + switch (node.getNodeId()) { + case mapping: + return Tag.MAP; + case sequence: + return Tag.SEQ; + default: + ScalarNode scalarNode = (ScalarNode) node; + return resolver.resolve(NodeId.scalar, scalarNode.getValue(), scalarNode.isPlain()); + } + } + private void parseAndValidate() { final Map servicesMap; if (composeFileContent.containsKey("version") && "2.0".equals(composeFileContent.get("version"))) { @@ -117,7 +136,7 @@ private void parseAndValidate() { composeFileName, serviceName ); - break; + continue; } @SuppressWarnings("unchecked") diff --git a/core/src/test/java/org/testcontainers/containers/DockerComposeFilesTest.java b/core/src/test/java/org/testcontainers/containers/DockerComposeFilesTest.java index 4704817c0e0..6d081b16000 100644 --- a/core/src/test/java/org/testcontainers/containers/DockerComposeFilesTest.java +++ b/core/src/test/java/org/testcontainers/containers/DockerComposeFilesTest.java @@ -29,4 +29,16 @@ void shouldGetDependencyImagesWhenOverriding() { assertThat(dockerComposeFiles.getDependencyImages()) .containsExactlyInAnyOrder("alpine:3.17", "redis:b", "mysql:b", "aservice:latest"); } + + @Test + void shouldGetDependencyImagesWhenOverridingWithOverrideTag() { + DockerComposeFiles dockerComposeFiles = new DockerComposeFiles( + Lists.newArrayList( + new File("src/test/resources/docker-compose-imagename-overriding-tag-a.yml"), + new File("src/test/resources/docker-compose-imagename-overriding-tag-b.yml") + ) + ); + assertThat(dockerComposeFiles.getDependencyImages()) + .containsExactlyInAnyOrder("postgres:16", "redis:7", "mysql:8.4"); + } } diff --git a/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java index 36126df6a38..630bc463663 100644 --- a/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java +++ b/core/src/test/java/org/testcontainers/containers/ParsedDockerComposeFileValidationTest.java @@ -150,6 +150,63 @@ void shouldObtainImageFromDockerfileBuildWithContext() { ); // redis, mysql from compose file, alpine:3.17 from Dockerfile build } + @Test + void shouldObtainImageNamesFromOverrideTag() { + File file = new File("src/test/resources/docker-compose-imagename-overriding-tag-b.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("values tagged with !override are kept") + .containsOnly( + entry("db", Sets.newHashSet("postgres:16")), + entry("redis", Sets.newHashSet("redis:7")), + entry("mysql", Sets.newHashSet("mysql:8.4")) + ); + } + + @Test + void shouldKeepQuotedValuesTaggedWithOverrideAsStrings() { + File file = new File("src/test/resources/docker-compose-imagename-overriding-tag-quoted.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("quoted values tagged with !override are not implicitly typed") + .containsOnly(entry("db", Sets.newHashSet("1.0"))); + } + + @Test + void shouldIgnoreImageNamesRemovedWithResetTag() { + File file = new File("src/test/resources/docker-compose-imagename-reset-tag.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("values tagged with !reset are removed") + .containsOnly(entry("redis", Sets.newHashSet("redis:7"))); + } + + @Test + void shouldContinueAfterServiceWithUnknownStructure() { + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile( + ImmutableMap.of( + "services", + ImmutableMap.of("unknown", "not a map", "redis", ImmutableMap.of("image", "redis:7")) + ) + ); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("services after one with an unknown structure are still parsed") + .containsOnly(entry("redis", Sets.newHashSet("redis:7"))); + } + + @Test + void shouldObtainImageNamesV1WithServicesNamedLikeTopLevelElements() { + File file = new File("src/test/resources/docker-compose-imagename-parsing-v1-top-level-names.yml"); + ParsedDockerComposeFile parsedFile = new ParsedDockerComposeFile(file); + assertThat(parsedFile.getServiceNameToImageNames()) + .as("v1 services may be named like top-level elements or extension fields of newer formats") + .containsOnly( + entry("name", Sets.newHashSet("busybox:1.36")), + entry("networks", Sets.newHashSet("redis:7")), + entry("x-api", Sets.newHashSet("private.example/api:1.0")) + ); + } + @Test void shouldSupportALotOfAliases() throws Exception { File file = temporaryFolder.resolve("tmp-docker-compose.yml").toFile(); diff --git a/core/src/test/resources/docker-compose-imagename-overriding-tag-a.yml b/core/src/test/resources/docker-compose-imagename-overriding-tag-a.yml new file mode 100644 index 00000000000..9d6457e8a6c --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-overriding-tag-a.yml @@ -0,0 +1,7 @@ +services: + db: + image: postgres:15 + redis: + image: redis:6 + mysql: + image: mysql:8.0 diff --git a/core/src/test/resources/docker-compose-imagename-overriding-tag-b.yml b/core/src/test/resources/docker-compose-imagename-overriding-tag-b.yml new file mode 100644 index 00000000000..315fc0dd971 --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-overriding-tag-b.yml @@ -0,0 +1,7 @@ +services: + db: !override + image: postgres:16 + redis: + image: !override redis:7 + mysql: + image: mysql:8.4 diff --git a/core/src/test/resources/docker-compose-imagename-overriding-tag-quoted.yml b/core/src/test/resources/docker-compose-imagename-overriding-tag-quoted.yml new file mode 100644 index 00000000000..312a954e07a --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-overriding-tag-quoted.yml @@ -0,0 +1,3 @@ +services: + db: + image: !override "1.0" diff --git a/core/src/test/resources/docker-compose-imagename-parsing-v1-top-level-names.yml b/core/src/test/resources/docker-compose-imagename-parsing-v1-top-level-names.yml new file mode 100644 index 00000000000..0a09d6bc762 --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-parsing-v1-top-level-names.yml @@ -0,0 +1,6 @@ +name: + image: busybox:1.36 +networks: + image: redis:7 +x-api: + image: private.example/api:1.0 diff --git a/core/src/test/resources/docker-compose-imagename-reset-tag.yml b/core/src/test/resources/docker-compose-imagename-reset-tag.yml new file mode 100644 index 00000000000..ac4c495014a --- /dev/null +++ b/core/src/test/resources/docker-compose-imagename-reset-tag.yml @@ -0,0 +1,5 @@ +services: + db: + image: !reset null + redis: + image: redis:7