22
33import com .github .dockerjava .api .DockerClient ;
44import com .github .dockerjava .api .model .Container ;
5- import com .google .common .annotations .VisibleForTesting ;
65import com .google .common .base .Joiner ;
76import com .google .common .base .Splitter ;
7+ import com .google .common .base .Strings ;
88import com .google .common .collect .Maps ;
99import com .google .common .util .concurrent .Uninterruptibles ;
1010import lombok .NonNull ;
1111import lombok .extern .slf4j .Slf4j ;
12- import org .apache .commons .io .FileUtils ;
1312import org .apache .commons .lang .StringUtils ;
1413import org .apache .commons .lang .SystemUtils ;
1514import org .junit .runner .Description ;
1918import org .testcontainers .containers .output .OutputFrame ;
2019import org .testcontainers .containers .output .Slf4jLogConsumer ;
2120import org .testcontainers .containers .startupcheck .IndefiniteWaitOneShotStartupCheckStrategy ;
22- import org .testcontainers .containers .wait .strategy .*;
21+ import org .testcontainers .containers .wait .strategy .Wait ;
22+ import org .testcontainers .containers .wait .strategy .WaitAllStrategy ;
23+ import org .testcontainers .containers .wait .strategy .WaitStrategy ;
2324import org .testcontainers .lifecycle .Startable ;
2425import org .testcontainers .utility .*;
25- import org .yaml .snakeyaml .Yaml ;
2626import org .zeroturnaround .exec .InvalidExitValueException ;
2727import org .zeroturnaround .exec .ProcessExecutor ;
2828import org .zeroturnaround .exec .stream .slf4j .Slf4jStream ;
2929
3030import java .io .File ;
31- import java .io .FileInputStream ;
32- import java .io .IOException ;
33- import java .nio .file .*;
3431import java .time .Duration ;
3532import java .util .AbstractMap .SimpleEntry ;
3633import java .util .*;
3734import java .util .concurrent .ConcurrentHashMap ;
3835import java .util .concurrent .TimeUnit ;
3936import java .util .concurrent .atomic .AtomicInteger ;
4037import java .util .function .Consumer ;
38+ import java .util .stream .Collectors ;
4139import java .util .stream .Stream ;
4240
4341import static com .google .common .base .Preconditions .checkArgument ;
@@ -58,8 +56,7 @@ public class DockerComposeContainer<SELF extends DockerComposeContainer<SELF>> e
5856 */
5957 private final String identifier ;
6058 private final List <File > composeFiles ;
61- private final Set <String > spawnedContainerIds = new HashSet <>();
62- private final Set <String > spawnedNetworkIds = new HashSet <>();
59+ private Set <ParsedDockerComposeFile > parsedComposeFiles ;
6360 private final Map <String , Integer > scalingPreferences = new HashMap <>();
6461 private DockerClient dockerClient ;
6562 private boolean localCompose ;
@@ -107,10 +104,11 @@ public DockerComposeContainer(String identifier, File... composeFiles) {
107104 public DockerComposeContainer (String identifier , List <File > composeFiles ) {
108105
109106 this .composeFiles = composeFiles ;
107+ this .parsedComposeFiles = composeFiles .stream ().map (ParsedDockerComposeFile ::new ).collect (Collectors .toSet ());
110108
111109 // Use a unique identifier so that containers created for this compose environment can be identified
112110 this .identifier = identifier ;
113- project = randomProjectId ();
111+ this . project = randomProjectId ();
114112
115113 this .dockerClient = DockerClientFactory .instance ().client ();
116114 }
@@ -154,15 +152,25 @@ public void start() {
154152 log .warn ("Exception while pulling images, using local images if available" , e );
155153 }
156154 }
157- applyScaling (); // scale before up, so that all scaled instances are available first for linking
158155 createServices ();
159156 startAmbassadorContainers ();
160157 waitUntilServiceStarted ();
161158 }
162159 }
163160
164161 private void pullImages () {
165- runWithCompose ("pull" );
162+ // Pull images using our docker client rather than compose itself,
163+ // (a) as a workaround for https://github.com/docker/compose/issues/5854, which prevents authenticated image pulls being possible when credential helpers are in use
164+ // (b) so that credential helper-based auth still works when compose is running from within a container
165+ parsedComposeFiles .stream ()
166+ .flatMap (it -> it .getServiceImageNames ().stream ())
167+ .forEach (imageName -> {
168+ try {
169+ DockerClientFactory .instance ().checkAndPullImage (dockerClient , imageName );
170+ } catch (Exception e ) {
171+ log .warn ("Failed to pull image '{}'. Exception message was {}" , imageName , e .getMessage ());
172+ }
173+ });
166174 }
167175
168176 public SELF withServices (@ NonNull String ... services ) {
@@ -171,18 +179,23 @@ public SELF withServices(@NonNull String... services) {
171179 }
172180
173181 private void createServices () {
174- // Run the docker-compose container, which starts up the services
175- String command = "up -d" ;
182+ // Apply scaling
183+ final String servicesWithScalingSettings = Stream .concat (services .stream (), scalingPreferences .keySet ().stream ())
184+ .map (service -> "--scale " + service + "=" + scalingPreferences .getOrDefault (service , 1 ))
185+ .collect (joining (" " ));
186+
187+ String flags = "-d" ;
176188
177189 if (build ) {
178- command += " --build" ;
190+ flags += " --build" ;
179191 }
180192
181- if (!services .isEmpty ()) {
182- command += " " + String .join (" " , services );
193+ // Run the docker-compose container, which starts up the services
194+ if (Strings .isNullOrEmpty (servicesWithScalingSettings )) {
195+ runWithCompose ("up " + flags );
196+ } else {
197+ runWithCompose ("up " + flags + " " + servicesWithScalingSettings );
183198 }
184-
185- runWithCompose (command );
186199 }
187200
188201 private void waitUntilServiceStarted () {
@@ -221,10 +234,6 @@ private void runWithCompose(String cmd) {
221234 checkNotNull (composeFiles );
222235 checkArgument (!composeFiles .isEmpty (), "No docker compose file have been provided" );
223236
224- for (File composeFile : composeFiles ) {
225- validate (composeFile );
226- }
227-
228237 final DockerCompose dockerCompose ;
229238 if (localCompose ) {
230239 dockerCompose = new LocalDockerCompose (composeFiles , project );
@@ -238,72 +247,6 @@ private void runWithCompose(String cmd) {
238247 .invoke ();
239248 }
240249
241- @ SuppressWarnings ("unchecked" )
242- private static void validate (File composeFile ) {
243- Yaml yaml = new Yaml ();
244- try (FileInputStream fileInputStream = FileUtils .openInputStream (composeFile )) {
245- Object template = yaml .load (fileInputStream );
246- validate (template , composeFile .getAbsolutePath ());
247- } catch (IOException e ) {
248- log .warn ("Failed to read YAML from {}" , composeFile .getAbsolutePath (), e );
249- }
250- }
251-
252- @ VisibleForTesting
253- static void validate (Object template , String identifier ) {
254- if (!(template instanceof Map )) {
255- return ;
256- }
257-
258- Map <String , ?> map = (Map <String , ?>) template ;
259-
260- final Map <String , ?> servicesMap ;
261- if (map .containsKey ("version" )) {
262- if (!map .containsKey ("services" )) {
263- log .debug ("Compose file {} has an unknown format: 'version' is set but 'services' is not defined" , identifier );
264- return ;
265- }
266- Object services = map .get ("services" );
267- if (!(services instanceof Map )) {
268- log .debug ("Compose file {} has an unknown format: 'services' is not Map" , identifier );
269- return ;
270- }
271-
272- servicesMap = (Map <String , ?>) services ;
273- } else {
274- servicesMap = map ;
275- }
276-
277- for (Map .Entry <String , ?> entry : servicesMap .entrySet ()) {
278- String serviceName = entry .getKey ();
279- Object serviceDefinition = entry .getValue ();
280- if (!(serviceDefinition instanceof Map )) {
281- log .debug ("Compose file {} has an unknown format: service '{}' is not Map" , identifier , serviceName );
282- break ;
283- }
284-
285- if (((Map ) serviceDefinition ).containsKey ("container_name" )) {
286- throw new IllegalStateException (String .format (
287- "Compose file %s has 'container_name' property set for service '%s' but this property is not supported by Testcontainers, consider removing it" ,
288- identifier ,
289- serviceName
290- ));
291- }
292- }
293- }
294-
295- private void applyScaling () {
296- // Apply scaling
297- if (!scalingPreferences .isEmpty ()) {
298- StringBuilder sb = new StringBuilder ("scale" );
299- for (Map .Entry <String , Integer > scale : scalingPreferences .entrySet ()) {
300- sb .append (" " ).append (scale .getKey ()).append ("=" ).append (scale .getValue ());
301- }
302-
303- runWithCompose (sb .toString ());
304- }
305- }
306-
307250 private void registerContainersForShutdown () {
308251 ResourceReaper .instance ().registerFilterForCleanup (Arrays .asList (
309252 new SimpleEntry <>("label" , "com.docker.compose.project=" + project )
@@ -333,29 +276,12 @@ public void stop() {
333276 ambassadorContainer .stop ();
334277
335278 // Kill the services using docker-compose
336- try {
337- String cmd = "down -v" ;
338- if (removeImages != null ) {
339- cmd += " --rmi " + removeImages .dockerRemoveImagesType ();
340- }
341- runWithCompose (cmd );
342-
343- // If we reach here then docker-compose down has cleared networks and containers;
344- // we can unregister from ResourceReaper
345- spawnedContainerIds .forEach (ResourceReaper .instance ()::unregisterContainer );
346- spawnedNetworkIds .forEach (ResourceReaper .instance ()::unregisterNetwork );
347- } catch (Exception e ) {
348- // docker-compose down failed; use ResourceReaper to ensure cleanup
349-
350- // kill the spawned service containers
351- spawnedContainerIds .forEach (ResourceReaper .instance ()::stopAndRemoveContainer );
352-
353- // remove the networks after removing the containers
354- spawnedNetworkIds .forEach (ResourceReaper .instance ()::removeNetworkById );
279+ String cmd = "down -v" ;
280+ if (removeImages != null ) {
281+ cmd += " --rmi " + removeImages .dockerRemoveImagesType ();
355282 }
283+ runWithCompose (cmd );
356284
357- spawnedContainerIds .clear ();
358- spawnedNetworkIds .clear ();
359285 } finally {
360286 project = randomProjectId ();
361287 }
@@ -597,9 +523,6 @@ interface DockerCompose {
597523class ContainerisedDockerCompose extends GenericContainer <ContainerisedDockerCompose > implements DockerCompose {
598524
599525 private static final String DOCKER_SOCKET_PATH = "/var/run/docker.sock" ;
600- private static final String DOCKER_CONFIG_FILE = "/root/.docker/config.json" ;
601- private static final String DOCKER_CONFIG_ENV = "DOCKER_CONFIG_FILE" ;
602- private static final String DOCKER_CONFIG_PROPERTY = "dockerConfigFile" ;
603526 public static final char UNIX_PATH_SEPERATOR = ':' ;
604527
605528 public ContainerisedDockerCompose (List <File > composeFiles , String identifier ) {
@@ -630,27 +553,6 @@ public ContainerisedDockerCompose(List<File> composeFiles, String identifier) {
630553 addEnv ("DOCKER_HOST" , "unix:///docker.sock" );
631554 setStartupCheckStrategy (new IndefiniteWaitOneShotStartupCheckStrategy ());
632555 setWorkingDirectory (containerPwd );
633-
634- String dockerConfigPath = determineDockerConfigPath ();
635- if (dockerConfigPath != null && !dockerConfigPath .isEmpty ()) {
636- addFileSystemBind (dockerConfigPath , DOCKER_CONFIG_FILE , READ_ONLY );
637- }
638- }
639-
640- private String determineDockerConfigPath () {
641- String dockerConfigEnv = System .getenv (DOCKER_CONFIG_ENV );
642- String dockerConfigProperty = System .getProperty (DOCKER_CONFIG_PROPERTY );
643- Path dockerConfig = Paths .get (System .getProperty ("user.home" ), ".docker" , "config.json" );
644-
645- if (dockerConfigEnv != null && !dockerConfigEnv .trim ().isEmpty () && Files .exists (Paths .get (dockerConfigEnv ))) {
646- return dockerConfigEnv ;
647- } else if (dockerConfigProperty != null && !dockerConfigProperty .trim ().isEmpty () && Files .exists (Paths .get (dockerConfigProperty ))) {
648- return dockerConfigProperty ;
649- } else if (Files .exists (dockerConfig )) {
650- return dockerConfig .toString ();
651- } else {
652- return null ;
653- }
654556 }
655557
656558 private String getDockerSocketHostPath () {
0 commit comments