diff --git a/spring-boot-project/spring-boot-docker-compose/src/dockerTest/java/org/springframework/boot/docker/compose/core/DockerCliIntegrationTests.java b/spring-boot-project/spring-boot-docker-compose/src/dockerTest/java/org/springframework/boot/docker/compose/core/DockerCliIntegrationTests.java index c7bdc989636..2848836327e 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/dockerTest/java/org/springframework/boot/docker/compose/core/DockerCliIntegrationTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/dockerTest/java/org/springframework/boot/docker/compose/core/DockerCliIntegrationTests.java @@ -67,7 +67,7 @@ class DockerCliIntegrationTests { @Test void runLifecycle() throws IOException { - File composeFile = createComposeFile(); + File composeFile = createComposeFile("redis-compose.yaml"); DockerCli cli = new DockerCli(null, DockerComposeFile.of(composeFile), Collections.emptySet()); try { // Verify that no services are running (this is a fresh compose project) @@ -103,6 +103,26 @@ class DockerCliIntegrationTests { } } + @Test + void shouldWorkWithMultipleComposeFiles() throws IOException { + List composeFiles = createComposeFiles(); + DockerCli cli = new DockerCli(null, DockerComposeFile.of(composeFiles), Collections.emptySet()); + try { + // List the config and verify that both redis are there + DockerCliComposeConfigResponse config = cli.run(new ComposeConfig()); + assertThat(config.services()).containsOnlyKeys("redis1", "redis2"); + // Run up + cli.run(new ComposeUp(LogLevel.INFO, Collections.emptyList())); + // Run ps and use id to run inspect on the id + List ps = cli.run(new ComposePs()); + assertThat(ps).hasSize(2); + } + finally { + // Clean up in any case + quietComposeDown(cli); + } + } + private static void quietComposeDown(DockerCli cli) { try { cli.run(new ComposeDown(Duration.ZERO, Collections.emptyList())); @@ -112,13 +132,21 @@ class DockerCliIntegrationTests { } } - private static File createComposeFile() throws IOException { - File composeFile = new ClassPathResource("redis-compose.yaml", DockerCliIntegrationTests.class).getFile(); - File tempComposeFile = Path.of(tempDir.toString(), composeFile.getName()).toFile(); - String composeFileContent = FileCopyUtils.copyToString(new FileReader(composeFile)); - composeFileContent = composeFileContent.replace("{imageName}", TestImage.REDIS.toString()); - FileCopyUtils.copy(composeFileContent, new FileWriter(tempComposeFile)); - return tempComposeFile; + private static File createComposeFile(String resource) throws IOException { + File source = new ClassPathResource(resource, DockerCliIntegrationTests.class).getFile(); + File target = Path.of(tempDir.toString(), source.getName()).toFile(); + String content = FileCopyUtils.copyToString(new FileReader(source)); + content = content.replace("{imageName}", TestImage.REDIS.toString()); + try (FileWriter writer = new FileWriter(target)) { + FileCopyUtils.copy(content, writer); + } + return target; + } + + private static List createComposeFiles() throws IOException { + File file1 = createComposeFile("1.yaml"); + File file2 = createComposeFile("2.yaml"); + return List.of(file1, file2); } } diff --git a/spring-boot-project/spring-boot-docker-compose/src/dockerTest/resources/org/springframework/boot/docker/compose/core/1.yaml b/spring-boot-project/spring-boot-docker-compose/src/dockerTest/resources/org/springframework/boot/docker/compose/core/1.yaml new file mode 100644 index 00000000000..e460afcf939 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/dockerTest/resources/org/springframework/boot/docker/compose/core/1.yaml @@ -0,0 +1,5 @@ +services: + redis1: + image: '{imageName}' + ports: + - '6379' diff --git a/spring-boot-project/spring-boot-docker-compose/src/dockerTest/resources/org/springframework/boot/docker/compose/core/2.yaml b/spring-boot-project/spring-boot-docker-compose/src/dockerTest/resources/org/springframework/boot/docker/compose/core/2.yaml new file mode 100644 index 00000000000..37b82aab595 --- /dev/null +++ b/spring-boot-project/spring-boot-docker-compose/src/dockerTest/resources/org/springframework/boot/docker/compose/core/2.yaml @@ -0,0 +1,5 @@ +services: + redis2: + image: '{imageName}' + ports: + - '6379' diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCli.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCli.java index 160e156a241..8e4e05716b7 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCli.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerCli.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -94,8 +94,10 @@ class DockerCli { case DOCKER_COMPOSE -> { List result = new ArrayList<>(this.dockerCommands.get(type)); if (this.composeFile != null) { - result.add("--file"); - result.add(this.composeFile.toString()); + for (File file : this.composeFile.getFiles()) { + result.add("--file"); + result.add(file.getPath()); + } } result.add("--ansi"); result.add("never"); diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeFile.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeFile.java index 8c58e7fef49..35ec96afe0a 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeFile.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeFile.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,10 @@ import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import org.springframework.util.Assert; @@ -33,6 +36,7 @@ import org.springframework.util.Assert; * @author Phillip Webb * @since 3.1.0 * @see #of(File) + * @see #of(Collection) * @see #find(File) */ public final class DockerComposeFile { @@ -40,17 +44,31 @@ public final class DockerComposeFile { private static final List SEARCH_ORDER = List.of("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"); - private final File file; + private final List files; - private DockerComposeFile(File file) { + private DockerComposeFile(List files) { + Assert.state(!files.isEmpty(), "Files must not be empty"); + this.files = files.stream().map(DockerComposeFile::toCanonicalFile).toList(); + } + + private static File toCanonicalFile(File file) { try { - this.file = file.getCanonicalFile(); + return file.getCanonicalFile(); } catch (IOException ex) { throw new UncheckedIOException(ex); } } + /** + * Returns the source docker compose files. + * @return the source docker compose files + * @since 3.4.0 + */ + public List getFiles() { + return this.files; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -60,17 +78,20 @@ public final class DockerComposeFile { return false; } DockerComposeFile other = (DockerComposeFile) obj; - return this.file.equals(other.file); + return this.files.equals(other.files); } @Override public int hashCode() { - return this.file.hashCode(); + return this.files.hashCode(); } @Override public String toString() { - return this.file.toString(); + if (this.files.size() == 1) { + return this.files.get(0).getPath(); + } + return this.files.stream().map(File::toString).collect(Collectors.joining(", ")); } /** @@ -111,7 +132,23 @@ public final class DockerComposeFile { Assert.notNull(file, "File must not be null"); Assert.isTrue(file.exists(), () -> "Docker Compose file '%s' does not exist".formatted(file)); Assert.isTrue(file.isFile(), () -> "Docker compose file '%s' is not a file".formatted(file)); - return new DockerComposeFile(file); + return new DockerComposeFile(Collections.singletonList(file)); + } + + /** + * Creates a new {@link DockerComposeFile} for the given {@link File files}. + * @param files the source files + * @return the docker compose file + * @since 3.4.0 + */ + public static DockerComposeFile of(Collection files) { + Assert.notNull(files, "Files must not be null"); + for (File file : files) { + Assert.notNull(file, "File must not be null"); + Assert.isTrue(file.exists(), () -> "Docker Compose file '%s' does not exist".formatted(file)); + Assert.isTrue(file.isFile(), () -> "Docker compose file '%s' is not a file".formatted(file)); + } + return new DockerComposeFile(List.copyOf(files)); } } diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeOrigin.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeOrigin.java index ed2fb98de9a..3ff40b85e24 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeOrigin.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/core/DockerComposeOrigin.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,7 +31,7 @@ public record DockerComposeOrigin(DockerComposeFile composeFile, String serviceN @Override public String toString() { - return "Docker compose service '%s' defined in '%s'".formatted(this.serviceName, + return "Docker compose service '%s' defined in %s".formatted(this.serviceName, (this.composeFile != null) ? this.composeFile : "default compose file"); } diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java index a93d5d4b4e6..a6dcc25aecc 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeLifecycleManager.java @@ -39,6 +39,7 @@ import org.springframework.context.ApplicationListener; import org.springframework.context.event.SimpleApplicationEventMulticaster; import org.springframework.core.log.LogMessage; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; /** * Manages the lifecycle for docker compose services. @@ -110,7 +111,7 @@ class DockerComposeLifecycleManager { Set activeProfiles = this.properties.getProfiles().getActive(); DockerCompose dockerCompose = getDockerCompose(composeFile, activeProfiles); if (!dockerCompose.hasDefinedServices()) { - logger.warn(LogMessage.format("No services defined in Docker Compose file '%s' with active profiles %s", + logger.warn(LogMessage.format("No services defined in Docker Compose file %s with active profiles %s", composeFile, activeProfiles)); return; } @@ -145,11 +146,16 @@ class DockerComposeLifecycleManager { } protected DockerComposeFile getComposeFile() { - DockerComposeFile composeFile = (this.properties.getFile() != null) - ? DockerComposeFile.of(this.properties.getFile()) : DockerComposeFile.find(this.workingDirectory); + DockerComposeFile composeFile = (CollectionUtils.isEmpty(this.properties.getFile())) + ? DockerComposeFile.find(this.workingDirectory) : DockerComposeFile.of(this.properties.getFile()); Assert.state(composeFile != null, () -> "No Docker Compose file found in directory '%s'".formatted( ((this.workingDirectory != null) ? this.workingDirectory : new File(".")).toPath().toAbsolutePath())); - logger.info(LogMessage.format("Using Docker Compose file '%s'", composeFile)); + if (composeFile.getFiles().size() == 1) { + logger.info(LogMessage.format("Using Docker Compose file %s", composeFile.getFiles().get(0))); + } + else { + logger.info(LogMessage.format("Using Docker Compose files %s", composeFile.toString())); + } return composeFile; } diff --git a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeProperties.java b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeProperties.java index e7042b78028..b099b8592f3 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeProperties.java +++ b/spring-boot-project/spring-boot-docker-compose/src/main/java/org/springframework/boot/docker/compose/lifecycle/DockerComposeProperties.java @@ -49,7 +49,7 @@ public class DockerComposeProperties { /** * Path to a specific docker compose configuration file. */ - private File file; + private final List file = new ArrayList<>(); /** * Docker compose lifecycle management. @@ -88,14 +88,10 @@ public class DockerComposeProperties { this.enabled = enabled; } - public File getFile() { + public List getFile() { return this.file; } - public void setFile(File file) { - this.file = file; - } - public LifecycleManagement getLifecycleManagement() { return this.lifecycleManagement; } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java index 02bab15eb46..c5b2ccde295 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeFileTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package org.springframework.boot.docker.compose.core; import java.io.File; import java.io.IOException; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -59,12 +60,20 @@ class DockerComposeFileTests { assertThat(composeFile.toString()).endsWith(File.separator + "compose.yml"); } + @Test + void toStringReturnsFileNameList() throws Exception { + File file1 = createTempFile("1.yml"); + File file2 = createTempFile("2.yml"); + DockerComposeFile composeFile = DockerComposeFile.of(List.of(file1, file2)); + assertThat(composeFile).hasToString(file1 + ", " + file2); + } + @Test void findFindsSingleFile() throws Exception { File file = new File(this.temp, "docker-compose.yml"); FileCopyUtils.copy(new byte[0], file); DockerComposeFile composeFile = DockerComposeFile.find(file.getParentFile()); - assertThat(composeFile.toString()).endsWith(File.separator + "docker-compose.yml"); + assertThat(composeFile.getFiles()).containsExactly(file); } @Test @@ -74,7 +83,7 @@ class DockerComposeFileTests { File f2 = new File(this.temp, "compose.yml"); FileCopyUtils.copy(new byte[0], f2); DockerComposeFile composeFile = DockerComposeFile.find(f1.getParentFile()); - assertThat(composeFile.toString()).endsWith(File.separator + "compose.yml"); + assertThat(composeFile.getFiles()).containsExactly(f2); } @Test @@ -94,24 +103,31 @@ class DockerComposeFileTests { @Test void findWhenWorkingDirectoryIsNotDirectoryThrowsException() throws Exception { - File file = new File(this.temp, "iamafile"); - FileCopyUtils.copy(new byte[0], file); + File file = createTempFile("iamafile"); assertThatIllegalArgumentException().isThrownBy(() -> DockerComposeFile.find(file)) .withMessageEndingWith("is not a directory"); } @Test void ofReturnsDockerComposeFile() throws Exception { - File file = new File(this.temp, "anyfile.yml"); - FileCopyUtils.copy(new byte[0], file); + File file = createTempFile("compose.yml"); DockerComposeFile composeFile = DockerComposeFile.of(file); assertThat(composeFile).isNotNull(); - assertThat(composeFile).hasToString(file.getCanonicalPath()); + assertThat(composeFile.getFiles()).containsExactly(file); + } + + @Test + void ofWithMultipleFilesReturnsDockerComposeFile() throws Exception { + File file1 = createTempFile("1.yml"); + File file2 = createTempFile("2.yml"); + DockerComposeFile composeFile = DockerComposeFile.of(List.of(file1, file2)); + assertThat(composeFile).isNotNull(); + assertThat(composeFile.getFiles()).containsExactly(file1, file2); } @Test void ofWhenFileIsNullThrowsException() { - assertThatIllegalArgumentException().isThrownBy(() -> DockerComposeFile.of(null)) + assertThatIllegalArgumentException().isThrownBy(() -> DockerComposeFile.of((File) null)) .withMessage("File must not be null"); } @@ -129,9 +145,13 @@ class DockerComposeFileTests { } private DockerComposeFile createComposeFile(String name) throws IOException { + return DockerComposeFile.of(createTempFile(name)); + } + + private File createTempFile(String name) throws IOException { File file = new File(this.temp, name); FileCopyUtils.copy(new byte[0], file); - return DockerComposeFile.of(file); + return file.getCanonicalFile(); } } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeOriginTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeOriginTests.java index 7d59606e64c..592216fbcba 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeOriginTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/core/DockerComposeOriginTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package org.springframework.boot.docker.compose.core; import java.io.File; import java.io.IOException; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -42,8 +43,17 @@ class DockerComposeOriginTests { void hasToString() throws Exception { DockerComposeFile composeFile = createTempComposeFile(); DockerComposeOrigin origin = new DockerComposeOrigin(composeFile, "service-1"); - assertThat(origin.toString()).startsWith("Docker compose service 'service-1' defined in '") - .endsWith("compose.yaml'"); + assertThat(origin.toString()).startsWith("Docker compose service 'service-1' defined in ") + .endsWith("compose.yaml"); + } + + @Test + void hasToStringWithMultipleFiles() throws IOException { + File file1 = createTempFile("1.yaml"); + File file2 = createTempFile("2.yaml"); + DockerComposeOrigin origin = new DockerComposeOrigin(DockerComposeFile.of(List.of(file1, file2)), "service-1"); + assertThat(origin.toString()) + .startsWith("Docker compose service 'service-1' defined in %s, %s".formatted(file1, file2)); } @Test @@ -63,9 +73,13 @@ class DockerComposeOriginTests { } private DockerComposeFile createTempComposeFile() throws IOException { - File file = new File(this.temp, "compose.yaml"); + return DockerComposeFile.of(createTempFile("compose.yaml")); + } + + private File createTempFile(String filename) throws IOException { + File file = new File(this.temp, filename); FileCopyUtils.copy(new byte[0], file); - return DockerComposeFile.of(file); + return file.getCanonicalFile(); } } diff --git a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/lifecycle/DockerComposePropertiesTests.java b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/lifecycle/DockerComposePropertiesTests.java index b61f26a33dc..5694de1937c 100644 --- a/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/lifecycle/DockerComposePropertiesTests.java +++ b/spring-boot-project/spring-boot-docker-compose/src/test/java/org/springframework/boot/docker/compose/lifecycle/DockerComposePropertiesTests.java @@ -47,7 +47,7 @@ class DockerComposePropertiesTests { void getWhenNoPropertiesReturnsNew() { Binder binder = new Binder(new MapConfigurationPropertySource()); DockerComposeProperties properties = DockerComposeProperties.get(binder); - assertThat(properties.getFile()).isNull(); + assertThat(properties.getFile()).isEmpty(); assertThat(properties.getLifecycleManagement()).isEqualTo(LifecycleManagement.START_AND_STOP); assertThat(properties.getHost()).isNull(); assertThat(properties.getStart().getCommand()).isEqualTo(StartCommand.UP); @@ -76,7 +76,7 @@ class DockerComposePropertiesTests { source.put("spring.docker.compose.readiness.tcp.read-timeout", "500ms"); Binder binder = new Binder(new MapConfigurationPropertySource(source)); DockerComposeProperties properties = DockerComposeProperties.get(binder); - assertThat(properties.getFile()).isEqualTo(new File("my-compose.yml")); + assertThat(properties.getFile()).containsExactly(new File("my-compose.yml")); assertThat(properties.getLifecycleManagement()).isEqualTo(LifecycleManagement.START_ONLY); assertThat(properties.getHost()).isEqualTo("myhost"); assertThat(properties.getStart().getCommand()).isEqualTo(StartCommand.START);