1# encoding: utf-8
2from __future__ import absolute_import
3from __future__ import print_function
4from __future__ import unicode_literals
5
6import codecs
7import os
8import shutil
9import tempfile
10from operator import itemgetter
11from random import shuffle
12
13import py
14import pytest
15import yaml
16
17from ...helpers import build_config_details
18from compose.config import config
19from compose.config import types
20from compose.config.config import resolve_build_args
21from compose.config.config import resolve_environment
22from compose.config.environment import Environment
23from compose.config.errors import ConfigurationError
24from compose.config.errors import VERSION_EXPLANATION
25from compose.config.serialize import denormalize_service_dict
26from compose.config.serialize import serialize_config
27from compose.config.serialize import serialize_ns_time_value
28from compose.config.types import VolumeSpec
29from compose.const import COMPOSEFILE_V1 as V1
30from compose.const import COMPOSEFILE_V2_0 as V2_0
31from compose.const import COMPOSEFILE_V2_1 as V2_1
32from compose.const import COMPOSEFILE_V2_2 as V2_2
33from compose.const import COMPOSEFILE_V2_3 as V2_3
34from compose.const import COMPOSEFILE_V3_0 as V3_0
35from compose.const import COMPOSEFILE_V3_1 as V3_1
36from compose.const import COMPOSEFILE_V3_2 as V3_2
37from compose.const import COMPOSEFILE_V3_3 as V3_3
38from compose.const import COMPOSEFILE_V3_5 as V3_5
39from compose.const import IS_WINDOWS_PLATFORM
40from tests import mock
41from tests import unittest
42
43DEFAULT_VERSION = V2_0
44
45
46def make_service_dict(name, service_dict, working_dir='.', filename=None):
47    """Test helper function to construct a ServiceExtendsResolver
48    """
49    resolver = config.ServiceExtendsResolver(
50        config.ServiceConfig(
51            working_dir=working_dir,
52            filename=filename,
53            name=name,
54            config=service_dict),
55        config.ConfigFile(filename=filename, config={}),
56        environment=Environment.from_env_file(working_dir)
57    )
58    return config.process_service(resolver.run())
59
60
61def service_sort(services):
62    return sorted(services, key=itemgetter('name'))
63
64
65def secret_sort(secrets):
66    return sorted(secrets, key=itemgetter('source'))
67
68
69class ConfigTest(unittest.TestCase):
70
71    def test_load(self):
72        service_dicts = config.load(
73            build_config_details(
74                {
75                    'foo': {'image': 'busybox'},
76                    'bar': {'image': 'busybox', 'environment': ['FOO=1']},
77                },
78                'tests/fixtures/extends',
79                'common.yml'
80            )
81        ).services
82
83        assert service_sort(service_dicts) == service_sort([
84            {
85                'name': 'bar',
86                'image': 'busybox',
87                'environment': {'FOO': '1'},
88            },
89            {
90                'name': 'foo',
91                'image': 'busybox',
92            }
93        ])
94
95    def test_load_v2(self):
96        config_data = config.load(
97            build_config_details({
98                'version': '2',
99                'services': {
100                    'foo': {'image': 'busybox'},
101                    'bar': {'image': 'busybox', 'environment': ['FOO=1']},
102                },
103                'volumes': {
104                    'hello': {
105                        'driver': 'default',
106                        'driver_opts': {'beep': 'boop'}
107                    }
108                },
109                'networks': {
110                    'default': {
111                        'driver': 'bridge',
112                        'driver_opts': {'beep': 'boop'}
113                    },
114                    'with_ipam': {
115                        'ipam': {
116                            'driver': 'default',
117                            'config': [
118                                {'subnet': '172.28.0.0/16'}
119                            ]
120                        }
121                    },
122                    'internal': {
123                        'driver': 'bridge',
124                        'internal': True
125                    }
126                }
127            }, 'working_dir', 'filename.yml')
128        )
129        service_dicts = config_data.services
130        volume_dict = config_data.volumes
131        networks_dict = config_data.networks
132        assert service_sort(service_dicts) == service_sort([
133            {
134                'name': 'bar',
135                'image': 'busybox',
136                'environment': {'FOO': '1'},
137            },
138            {
139                'name': 'foo',
140                'image': 'busybox',
141            }
142        ])
143        assert volume_dict == {
144            'hello': {
145                'driver': 'default',
146                'driver_opts': {'beep': 'boop'}
147            }
148        }
149        assert networks_dict == {
150            'default': {
151                'driver': 'bridge',
152                'driver_opts': {'beep': 'boop'}
153            },
154            'with_ipam': {
155                'ipam': {
156                    'driver': 'default',
157                    'config': [
158                        {'subnet': '172.28.0.0/16'}
159                    ]
160                }
161            },
162            'internal': {
163                'driver': 'bridge',
164                'internal': True
165            }
166        }
167
168    def test_valid_versions(self):
169        for version in ['2', '2.0']:
170            cfg = config.load(build_config_details({'version': version}))
171            assert cfg.version == V2_0
172
173        cfg = config.load(build_config_details({'version': '2.1'}))
174        assert cfg.version == V2_1
175
176        cfg = config.load(build_config_details({'version': '2.2'}))
177        assert cfg.version == V2_2
178
179        cfg = config.load(build_config_details({'version': '2.3'}))
180        assert cfg.version == V2_3
181
182        for version in ['3', '3.0']:
183            cfg = config.load(build_config_details({'version': version}))
184            assert cfg.version == V3_0
185
186        cfg = config.load(build_config_details({'version': '3.1'}))
187        assert cfg.version == V3_1
188
189    def test_v1_file_version(self):
190        cfg = config.load(build_config_details({'web': {'image': 'busybox'}}))
191        assert cfg.version == V1
192        assert list(s['name'] for s in cfg.services) == ['web']
193
194        cfg = config.load(build_config_details({'version': {'image': 'busybox'}}))
195        assert cfg.version == V1
196        assert list(s['name'] for s in cfg.services) == ['version']
197
198    def test_wrong_version_type(self):
199        for version in [None, 1, 2, 2.0]:
200            with pytest.raises(ConfigurationError) as excinfo:
201                config.load(
202                    build_config_details(
203                        {'version': version},
204                        filename='filename.yml',
205                    )
206                )
207
208            assert 'Version in "filename.yml" is invalid - it should be a string.' \
209                in excinfo.exconly()
210
211    def test_unsupported_version(self):
212        with pytest.raises(ConfigurationError) as excinfo:
213            config.load(
214                build_config_details(
215                    {'version': '2.18'},
216                    filename='filename.yml',
217                )
218            )
219
220        assert 'Version in "filename.yml" is unsupported' in excinfo.exconly()
221        assert VERSION_EXPLANATION in excinfo.exconly()
222
223    def test_version_1_is_invalid(self):
224        with pytest.raises(ConfigurationError) as excinfo:
225            config.load(
226                build_config_details(
227                    {
228                        'version': '1',
229                        'web': {'image': 'busybox'},
230                    },
231                    filename='filename.yml',
232                )
233            )
234
235        assert 'Version in "filename.yml" is invalid' in excinfo.exconly()
236        assert VERSION_EXPLANATION in excinfo.exconly()
237
238    def test_v1_file_with_version_is_invalid(self):
239        with pytest.raises(ConfigurationError) as excinfo:
240            config.load(
241                build_config_details(
242                    {
243                        'version': '2',
244                        'web': {'image': 'busybox'},
245                    },
246                    filename='filename.yml',
247                )
248            )
249
250        assert 'Invalid top-level property "web"' in excinfo.exconly()
251        assert VERSION_EXPLANATION in excinfo.exconly()
252
253    def test_named_volume_config_empty(self):
254        config_details = build_config_details({
255            'version': '2',
256            'services': {
257                'simple': {'image': 'busybox'}
258            },
259            'volumes': {
260                'simple': None,
261                'other': {},
262            }
263        })
264        config_result = config.load(config_details)
265        volumes = config_result.volumes
266        assert 'simple' in volumes
267        assert volumes['simple'] == {}
268        assert volumes['other'] == {}
269
270    def test_named_volume_numeric_driver_opt(self):
271        config_details = build_config_details({
272            'version': '2',
273            'services': {
274                'simple': {'image': 'busybox'}
275            },
276            'volumes': {
277                'simple': {'driver_opts': {'size': 42}},
278            }
279        })
280        cfg = config.load(config_details)
281        assert cfg.volumes['simple']['driver_opts']['size'] == '42'
282
283    def test_volume_invalid_driver_opt(self):
284        config_details = build_config_details({
285            'version': '2',
286            'services': {
287                'simple': {'image': 'busybox'}
288            },
289            'volumes': {
290                'simple': {'driver_opts': {'size': True}},
291            }
292        })
293        with pytest.raises(ConfigurationError) as exc:
294            config.load(config_details)
295        assert 'driver_opts.size contains an invalid type' in exc.exconly()
296
297    def test_named_volume_invalid_type_list(self):
298        config_details = build_config_details({
299            'version': '2',
300            'services': {
301                'simple': {'image': 'busybox'}
302            },
303            'volumes': []
304        })
305        with pytest.raises(ConfigurationError) as exc:
306            config.load(config_details)
307        assert "volume must be a mapping, not an array" in exc.exconly()
308
309    def test_networks_invalid_type_list(self):
310        config_details = build_config_details({
311            'version': '2',
312            'services': {
313                'simple': {'image': 'busybox'}
314            },
315            'networks': []
316        })
317        with pytest.raises(ConfigurationError) as exc:
318            config.load(config_details)
319        assert "network must be a mapping, not an array" in exc.exconly()
320
321    def test_load_service_with_name_version(self):
322        with mock.patch('compose.config.config.log') as mock_logging:
323            config_data = config.load(
324                build_config_details({
325                    'version': {
326                        'image': 'busybox'
327                    }
328                }, 'working_dir', 'filename.yml')
329            )
330
331        assert 'Unexpected type for "version" key in "filename.yml"' \
332            in mock_logging.warn.call_args[0][0]
333
334        service_dicts = config_data.services
335        assert service_sort(service_dicts) == service_sort([
336            {
337                'name': 'version',
338                'image': 'busybox',
339            }
340        ])
341
342    def test_load_throws_error_when_not_dict(self):
343        with pytest.raises(ConfigurationError):
344            config.load(
345                build_config_details(
346                    {'web': 'busybox:latest'},
347                    'working_dir',
348                    'filename.yml'
349                )
350            )
351
352    def test_load_throws_error_when_not_dict_v2(self):
353        with pytest.raises(ConfigurationError):
354            config.load(
355                build_config_details(
356                    {'version': '2', 'services': {'web': 'busybox:latest'}},
357                    'working_dir',
358                    'filename.yml'
359                )
360            )
361
362    def test_load_throws_error_with_invalid_network_fields(self):
363        with pytest.raises(ConfigurationError):
364            config.load(
365                build_config_details({
366                    'version': '2',
367                    'services': {'web': 'busybox:latest'},
368                    'networks': {
369                        'invalid': {'foo', 'bar'}
370                    }
371                }, 'working_dir', 'filename.yml')
372            )
373
374    def test_load_config_link_local_ips_network(self):
375        base_file = config.ConfigFile(
376            'base.yaml',
377            {
378                'version': str(V2_1),
379                'services': {
380                    'web': {
381                        'image': 'example/web',
382                        'networks': {
383                            'foobar': {
384                                'aliases': ['foo', 'bar'],
385                                'link_local_ips': ['169.254.8.8']
386                            }
387                        }
388                    }
389                },
390                'networks': {'foobar': {}}
391            }
392        )
393
394        details = config.ConfigDetails('.', [base_file])
395        web_service = config.load(details).services[0]
396        assert web_service['networks'] == {
397            'foobar': {
398                'aliases': ['foo', 'bar'],
399                'link_local_ips': ['169.254.8.8']
400            }
401        }
402
403    def test_load_config_service_labels(self):
404        base_file = config.ConfigFile(
405            'base.yaml',
406            {
407                'version': '2.1',
408                'services': {
409                    'web': {
410                        'image': 'example/web',
411                        'labels': ['label_key=label_val']
412                    },
413                    'db': {
414                        'image': 'example/db',
415                        'labels': {
416                            'label_key': 'label_val'
417                        }
418                    }
419                },
420            }
421        )
422        details = config.ConfigDetails('.', [base_file])
423        service_dicts = config.load(details).services
424        for service in service_dicts:
425            assert service['labels'] == {
426                'label_key': 'label_val'
427            }
428
429    def test_load_config_custom_resource_names(self):
430        base_file = config.ConfigFile(
431            'base.yaml', {
432                'version': '3.5',
433                'volumes': {
434                    'abc': {
435                        'name': 'xyz'
436                    }
437                },
438                'networks': {
439                    'abc': {
440                        'name': 'xyz'
441                    }
442                },
443                'secrets': {
444                    'abc': {
445                        'name': 'xyz'
446                    }
447                },
448                'configs': {
449                    'abc': {
450                        'name': 'xyz'
451                    }
452                }
453            }
454        )
455        details = config.ConfigDetails('.', [base_file])
456        loaded_config = config.load(details)
457
458        assert loaded_config.networks['abc'] == {'name': 'xyz'}
459        assert loaded_config.volumes['abc'] == {'name': 'xyz'}
460        assert loaded_config.secrets['abc']['name'] == 'xyz'
461        assert loaded_config.configs['abc']['name'] == 'xyz'
462
463    def test_load_config_volume_and_network_labels(self):
464        base_file = config.ConfigFile(
465            'base.yaml',
466            {
467                'version': '2.1',
468                'services': {
469                    'web': {
470                        'image': 'example/web',
471                    },
472                },
473                'networks': {
474                    'with_label': {
475                        'labels': {
476                            'label_key': 'label_val'
477                        }
478                    }
479                },
480                'volumes': {
481                    'with_label': {
482                        'labels': {
483                            'label_key': 'label_val'
484                        }
485                    }
486                }
487            }
488        )
489
490        details = config.ConfigDetails('.', [base_file])
491        loaded_config = config.load(details)
492
493        assert loaded_config.networks == {
494            'with_label': {
495                'labels': {
496                    'label_key': 'label_val'
497                }
498            }
499        }
500
501        assert loaded_config.volumes == {
502            'with_label': {
503                'labels': {
504                    'label_key': 'label_val'
505                }
506            }
507        }
508
509    def test_load_config_invalid_service_names(self):
510        for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']:
511            with pytest.raises(ConfigurationError) as exc:
512                config.load(build_config_details(
513                    {invalid_name: {'image': 'busybox'}}))
514            assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly()
515
516    def test_load_config_invalid_service_names_v2(self):
517        for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']:
518            with pytest.raises(ConfigurationError) as exc:
519                config.load(build_config_details(
520                    {
521                        'version': '2',
522                        'services': {invalid_name: {'image': 'busybox'}},
523                    }))
524            assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly()
525
526    def test_load_with_invalid_field_name(self):
527        with pytest.raises(ConfigurationError) as exc:
528            config.load(build_config_details(
529                {
530                    'version': '2',
531                    'services': {
532                        'web': {'image': 'busybox', 'name': 'bogus'},
533                    }
534                },
535                'working_dir',
536                'filename.yml',
537            ))
538
539        assert "Unsupported config option for services.web: 'name'" in exc.exconly()
540
541    def test_load_with_invalid_field_name_v1(self):
542        with pytest.raises(ConfigurationError) as exc:
543            config.load(build_config_details(
544                {
545                    'web': {'image': 'busybox', 'name': 'bogus'},
546                },
547                'working_dir',
548                'filename.yml',
549            ))
550
551        assert "Unsupported config option for web: 'name'" in exc.exconly()
552
553    def test_load_invalid_service_definition(self):
554        config_details = build_config_details(
555            {'web': 'wrong'},
556            'working_dir',
557            'filename.yml')
558        with pytest.raises(ConfigurationError) as exc:
559            config.load(config_details)
560        assert "service 'web' must be a mapping not a string." in exc.exconly()
561
562    def test_load_with_empty_build_args(self):
563        config_details = build_config_details(
564            {
565                'version': '2',
566                'services': {
567                    'web': {
568                        'build': {
569                            'context': os.getcwd(),
570                            'args': None,
571                        },
572                    },
573                },
574            }
575        )
576        with pytest.raises(ConfigurationError) as exc:
577            config.load(config_details)
578        assert (
579            "services.web.build.args contains an invalid type, it should be an "
580            "object, or an array" in exc.exconly()
581        )
582
583    def test_config_integer_service_name_raise_validation_error(self):
584        with pytest.raises(ConfigurationError) as excinfo:
585            config.load(
586                build_config_details(
587                    {1: {'image': 'busybox'}},
588                    'working_dir',
589                    'filename.yml'
590                )
591            )
592
593        assert (
594            "In file 'filename.yml', the service name 1 must be a quoted string, i.e. '1'" in
595            excinfo.exconly()
596        )
597
598    def test_config_integer_service_name_raise_validation_error_v2(self):
599        with pytest.raises(ConfigurationError) as excinfo:
600            config.load(
601                build_config_details(
602                    {
603                        'version': '2',
604                        'services': {1: {'image': 'busybox'}}
605                    },
606                    'working_dir',
607                    'filename.yml'
608                )
609            )
610
611        assert (
612            "In file 'filename.yml', the service name 1 must be a quoted string, i.e. '1'." in
613            excinfo.exconly()
614        )
615
616    def test_config_integer_service_property_raise_validation_error(self):
617        with pytest.raises(ConfigurationError) as excinfo:
618            config.load(
619                build_config_details({
620                    'version': '2.1',
621                    'services': {'foobar': {'image': 'busybox', 1234: 'hah'}}
622                }, 'working_dir', 'filename.yml')
623            )
624
625        assert (
626            "Unsupported config option for services.foobar: '1234'" in excinfo.exconly()
627        )
628
629    def test_config_invalid_service_name_raise_validation_error(self):
630        with pytest.raises(ConfigurationError) as excinfo:
631            config.load(
632                build_config_details({
633                    'version': '2',
634                    'services': {
635                        'test_app': {'build': '.'},
636                        'mong\\o': {'image': 'mongo'},
637                    }
638                })
639            )
640
641            assert 'Invalid service name \'mong\\o\'' in excinfo.exconly()
642
643    def test_config_duplicate_cache_from_values_validation_error(self):
644        with pytest.raises(ConfigurationError) as exc:
645            config.load(
646                build_config_details({
647                    'version': '2.3',
648                    'services': {
649                        'test': {'build': {'context': '.', 'cache_from': ['a', 'b', 'a']}}
650                    }
651
652                })
653            )
654
655        assert 'build.cache_from contains non-unique items' in exc.exconly()
656
657    def test_load_with_multiple_files_v1(self):
658        base_file = config.ConfigFile(
659            'base.yaml',
660            {
661                'web': {
662                    'image': 'example/web',
663                    'links': ['db'],
664                },
665                'db': {
666                    'image': 'example/db',
667                },
668            })
669        override_file = config.ConfigFile(
670            'override.yaml',
671            {
672                'web': {
673                    'build': '/',
674                    'volumes': ['/home/user/project:/code'],
675                },
676            })
677        details = config.ConfigDetails('.', [base_file, override_file])
678
679        service_dicts = config.load(details).services
680        expected = [
681            {
682                'name': 'web',
683                'build': {'context': os.path.abspath('/')},
684                'volumes': [VolumeSpec.parse('/home/user/project:/code')],
685                'links': ['db'],
686            },
687            {
688                'name': 'db',
689                'image': 'example/db',
690            },
691        ]
692        assert service_sort(service_dicts) == service_sort(expected)
693
694    def test_load_with_multiple_files_and_empty_override(self):
695        base_file = config.ConfigFile(
696            'base.yml',
697            {'web': {'image': 'example/web'}})
698        override_file = config.ConfigFile('override.yml', None)
699        details = config.ConfigDetails('.', [base_file, override_file])
700
701        with pytest.raises(ConfigurationError) as exc:
702            config.load(details)
703        error_msg = "Top level object in 'override.yml' needs to be an object"
704        assert error_msg in exc.exconly()
705
706    def test_load_with_multiple_files_and_empty_override_v2(self):
707        base_file = config.ConfigFile(
708            'base.yml',
709            {'version': '2', 'services': {'web': {'image': 'example/web'}}})
710        override_file = config.ConfigFile('override.yml', None)
711        details = config.ConfigDetails('.', [base_file, override_file])
712
713        with pytest.raises(ConfigurationError) as exc:
714            config.load(details)
715        error_msg = "Top level object in 'override.yml' needs to be an object"
716        assert error_msg in exc.exconly()
717
718    def test_load_with_multiple_files_and_empty_base(self):
719        base_file = config.ConfigFile('base.yml', None)
720        override_file = config.ConfigFile(
721            'override.yml',
722            {'web': {'image': 'example/web'}})
723        details = config.ConfigDetails('.', [base_file, override_file])
724
725        with pytest.raises(ConfigurationError) as exc:
726            config.load(details)
727        assert "Top level object in 'base.yml' needs to be an object" in exc.exconly()
728
729    def test_load_with_multiple_files_and_empty_base_v2(self):
730        base_file = config.ConfigFile('base.yml', None)
731        override_file = config.ConfigFile(
732            'override.tml',
733            {'version': '2', 'services': {'web': {'image': 'example/web'}}}
734        )
735        details = config.ConfigDetails('.', [base_file, override_file])
736        with pytest.raises(ConfigurationError) as exc:
737            config.load(details)
738        assert "Top level object in 'base.yml' needs to be an object" in exc.exconly()
739
740    def test_load_with_multiple_files_and_extends_in_override_file(self):
741        base_file = config.ConfigFile(
742            'base.yaml',
743            {
744                'web': {'image': 'example/web'},
745            })
746        override_file = config.ConfigFile(
747            'override.yaml',
748            {
749                'web': {
750                    'extends': {
751                        'file': 'common.yml',
752                        'service': 'base',
753                    },
754                    'volumes': ['/home/user/project:/code'],
755                },
756            })
757        details = config.ConfigDetails('.', [base_file, override_file])
758
759        tmpdir = py.test.ensuretemp('config_test')
760        self.addCleanup(tmpdir.remove)
761        tmpdir.join('common.yml').write("""
762            base:
763              labels: ['label=one']
764        """)
765        with tmpdir.as_cwd():
766            service_dicts = config.load(details).services
767
768        expected = [
769            {
770                'name': 'web',
771                'image': 'example/web',
772                'volumes': [VolumeSpec.parse('/home/user/project:/code')],
773                'labels': {'label': 'one'},
774            },
775        ]
776        assert service_sort(service_dicts) == service_sort(expected)
777
778    def test_load_mixed_extends_resolution(self):
779        main_file = config.ConfigFile(
780            'main.yml', {
781                'version': '2.2',
782                'services': {
783                    'prodweb': {
784                        'extends': {
785                            'service': 'web',
786                            'file': 'base.yml'
787                        },
788                        'environment': {'PROD': 'true'},
789                    },
790                },
791            }
792        )
793
794        tmpdir = pytest.ensuretemp('config_test')
795        self.addCleanup(tmpdir.remove)
796        tmpdir.join('base.yml').write("""
797            version: '2.2'
798            services:
799              base:
800                image: base
801              web:
802                extends: base
803        """)
804
805        details = config.ConfigDetails('.', [main_file])
806        with tmpdir.as_cwd():
807            service_dicts = config.load(details).services
808            assert service_dicts[0] == {
809                'name': 'prodweb',
810                'image': 'base',
811                'environment': {'PROD': 'true'},
812            }
813
814    def test_load_with_multiple_files_and_invalid_override(self):
815        base_file = config.ConfigFile(
816            'base.yaml',
817            {'web': {'image': 'example/web'}})
818        override_file = config.ConfigFile(
819            'override.yaml',
820            {'bogus': 'thing'})
821        details = config.ConfigDetails('.', [base_file, override_file])
822
823        with pytest.raises(ConfigurationError) as exc:
824            config.load(details)
825        assert "service 'bogus' must be a mapping not a string." in exc.exconly()
826        assert "In file 'override.yaml'" in exc.exconly()
827
828    def test_load_sorts_in_dependency_order(self):
829        config_details = build_config_details({
830            'web': {
831                'image': 'busybox:latest',
832                'links': ['db'],
833            },
834            'db': {
835                'image': 'busybox:latest',
836                'volumes_from': ['volume:ro']
837            },
838            'volume': {
839                'image': 'busybox:latest',
840                'volumes': ['/tmp'],
841            }
842        })
843        services = config.load(config_details).services
844
845        assert services[0]['name'] == 'volume'
846        assert services[1]['name'] == 'db'
847        assert services[2]['name'] == 'web'
848
849    def test_load_with_extensions(self):
850        config_details = build_config_details({
851            'version': '2.3',
852            'x-data': {
853                'lambda': 3,
854                'excess': [True, {}]
855            }
856        })
857
858        config_data = config.load(config_details)
859        assert config_data.services == []
860
861    def test_config_build_configuration(self):
862        service = config.load(
863            build_config_details(
864                {'web': {
865                    'build': '.',
866                    'dockerfile': 'Dockerfile-alt'
867                }},
868                'tests/fixtures/extends',
869                'filename.yml'
870            )
871        ).services
872        assert 'context' in service[0]['build']
873        assert service[0]['build']['dockerfile'] == 'Dockerfile-alt'
874
875    def test_config_build_configuration_v2(self):
876        # service.dockerfile is invalid in v2
877        with pytest.raises(ConfigurationError):
878            config.load(
879                build_config_details(
880                    {
881                        'version': '2',
882                        'services': {
883                            'web': {
884                                'build': '.',
885                                'dockerfile': 'Dockerfile-alt'
886                            }
887                        }
888                    },
889                    'tests/fixtures/extends',
890                    'filename.yml'
891                )
892            )
893
894        service = config.load(
895            build_config_details({
896                'version': '2',
897                'services': {
898                    'web': {
899                        'build': '.'
900                    }
901                }
902            }, 'tests/fixtures/extends', 'filename.yml')
903        ).services[0]
904        assert 'context' in service['build']
905
906        service = config.load(
907            build_config_details(
908                {
909                    'version': '2',
910                    'services': {
911                        'web': {
912                            'build': {
913                                'context': '.',
914                                'dockerfile': 'Dockerfile-alt'
915                            }
916                        }
917                    }
918                },
919                'tests/fixtures/extends',
920                'filename.yml'
921            )
922        ).services
923        assert 'context' in service[0]['build']
924        assert service[0]['build']['dockerfile'] == 'Dockerfile-alt'
925
926    def test_load_with_buildargs(self):
927        service = config.load(
928            build_config_details(
929                {
930                    'version': '2',
931                    'services': {
932                        'web': {
933                            'build': {
934                                'context': '.',
935                                'dockerfile': 'Dockerfile-alt',
936                                'args': {
937                                    'opt1': 42,
938                                    'opt2': 'foobar'
939                                }
940                            }
941                        }
942                    }
943                },
944                'tests/fixtures/extends',
945                'filename.yml'
946            )
947        ).services[0]
948        assert 'args' in service['build']
949        assert 'opt1' in service['build']['args']
950        assert isinstance(service['build']['args']['opt1'], str)
951        assert service['build']['args']['opt1'] == '42'
952        assert service['build']['args']['opt2'] == 'foobar'
953
954    def test_load_build_labels_dict(self):
955        service = config.load(
956            build_config_details(
957                {
958                    'version': str(V3_3),
959                    'services': {
960                        'web': {
961                            'build': {
962                                'context': '.',
963                                'dockerfile': 'Dockerfile-alt',
964                                'labels': {
965                                    'label1': 42,
966                                    'label2': 'foobar'
967                                }
968                            }
969                        }
970                    }
971                },
972                'tests/fixtures/extends',
973                'filename.yml'
974            )
975        ).services[0]
976        assert 'labels' in service['build']
977        assert 'label1' in service['build']['labels']
978        assert service['build']['labels']['label1'] == '42'
979        assert service['build']['labels']['label2'] == 'foobar'
980
981    def test_load_build_labels_list(self):
982        base_file = config.ConfigFile(
983            'base.yml',
984            {
985                'version': '2.3',
986                'services': {
987                    'web': {
988                        'build': {
989                            'context': '.',
990                            'labels': ['foo=bar', 'baz=true', 'foobar=1']
991                        },
992                    },
993                },
994            }
995        )
996
997        details = config.ConfigDetails('.', [base_file])
998        service = config.load(details).services[0]
999        assert service['build']['labels'] == {
1000            'foo': 'bar', 'baz': 'true', 'foobar': '1'
1001        }
1002
1003    def test_build_args_allow_empty_properties(self):
1004        service = config.load(
1005            build_config_details(
1006                {
1007                    'version': '2',
1008                    'services': {
1009                        'web': {
1010                            'build': {
1011                                'context': '.',
1012                                'dockerfile': 'Dockerfile-alt',
1013                                'args': {
1014                                    'foo': None
1015                                }
1016                            }
1017                        }
1018                    }
1019                },
1020                'tests/fixtures/extends',
1021                'filename.yml'
1022            )
1023        ).services[0]
1024        assert 'args' in service['build']
1025        assert 'foo' in service['build']['args']
1026        assert service['build']['args']['foo'] == ''
1027
1028    # If build argument is None then it will be converted to the empty
1029    # string. Make sure that int zero kept as it is, i.e. not converted to
1030    # the empty string
1031    def test_build_args_check_zero_preserved(self):
1032        service = config.load(
1033            build_config_details(
1034                {
1035                    'version': '2',
1036                    'services': {
1037                        'web': {
1038                            'build': {
1039                                'context': '.',
1040                                'dockerfile': 'Dockerfile-alt',
1041                                'args': {
1042                                    'foo': 0
1043                                }
1044                            }
1045                        }
1046                    }
1047                },
1048                'tests/fixtures/extends',
1049                'filename.yml'
1050            )
1051        ).services[0]
1052        assert 'args' in service['build']
1053        assert 'foo' in service['build']['args']
1054        assert service['build']['args']['foo'] == '0'
1055
1056    def test_load_with_multiple_files_mismatched_networks_format(self):
1057        base_file = config.ConfigFile(
1058            'base.yaml',
1059            {
1060                'version': '2',
1061                'services': {
1062                    'web': {
1063                        'image': 'example/web',
1064                        'networks': {
1065                            'foobar': {'aliases': ['foo', 'bar']}
1066                        }
1067                    }
1068                },
1069                'networks': {'foobar': {}, 'baz': {}}
1070            }
1071        )
1072
1073        override_file = config.ConfigFile(
1074            'override.yaml',
1075            {
1076                'version': '2',
1077                'services': {
1078                    'web': {
1079                        'networks': ['baz']
1080                    }
1081                }
1082            }
1083        )
1084
1085        details = config.ConfigDetails('.', [base_file, override_file])
1086        web_service = config.load(details).services[0]
1087        assert web_service['networks'] == {
1088            'foobar': {'aliases': ['bar', 'foo']},
1089            'baz': {}
1090        }
1091
1092    def test_load_with_multiple_files_mismatched_networks_format_inverse_order(self):
1093        base_file = config.ConfigFile(
1094            'override.yaml',
1095            {
1096                'version': '2',
1097                'services': {
1098                    'web': {
1099                        'networks': ['baz']
1100                    }
1101                }
1102            }
1103        )
1104        override_file = config.ConfigFile(
1105            'base.yaml',
1106            {
1107                'version': '2',
1108                'services': {
1109                    'web': {
1110                        'image': 'example/web',
1111                        'networks': {
1112                            'foobar': {'aliases': ['foo', 'bar']}
1113                        }
1114                    }
1115                },
1116                'networks': {'foobar': {}, 'baz': {}}
1117            }
1118        )
1119
1120        details = config.ConfigDetails('.', [base_file, override_file])
1121        web_service = config.load(details).services[0]
1122        assert web_service['networks'] == {
1123            'foobar': {'aliases': ['bar', 'foo']},
1124            'baz': {}
1125        }
1126
1127    def test_load_with_multiple_files_v2(self):
1128        base_file = config.ConfigFile(
1129            'base.yaml',
1130            {
1131                'version': '2',
1132                'services': {
1133                    'web': {
1134                        'image': 'example/web',
1135                        'depends_on': ['db'],
1136                    },
1137                    'db': {
1138                        'image': 'example/db',
1139                    }
1140                },
1141            })
1142        override_file = config.ConfigFile(
1143            'override.yaml',
1144            {
1145                'version': '2',
1146                'services': {
1147                    'web': {
1148                        'build': '/',
1149                        'volumes': ['/home/user/project:/code'],
1150                        'depends_on': ['other'],
1151                    },
1152                    'other': {
1153                        'image': 'example/other',
1154                    }
1155                }
1156            })
1157        details = config.ConfigDetails('.', [base_file, override_file])
1158
1159        service_dicts = config.load(details).services
1160        expected = [
1161            {
1162                'name': 'web',
1163                'build': {'context': os.path.abspath('/')},
1164                'image': 'example/web',
1165                'volumes': [VolumeSpec.parse('/home/user/project:/code')],
1166                'depends_on': {
1167                    'db': {'condition': 'service_started'},
1168                    'other': {'condition': 'service_started'},
1169                },
1170            },
1171            {
1172                'name': 'db',
1173                'image': 'example/db',
1174            },
1175            {
1176                'name': 'other',
1177                'image': 'example/other',
1178            },
1179        ]
1180        assert service_sort(service_dicts) == service_sort(expected)
1181
1182    @mock.patch.dict(os.environ)
1183    def test_load_with_multiple_files_v3_2(self):
1184        os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true'
1185        base_file = config.ConfigFile(
1186            'base.yaml',
1187            {
1188                'version': '3.2',
1189                'services': {
1190                    'web': {
1191                        'image': 'example/web',
1192                        'volumes': [
1193                            {'source': '/a', 'target': '/b', 'type': 'bind'},
1194                            {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True}
1195                        ],
1196                        'stop_grace_period': '30s',
1197                    }
1198                },
1199                'volumes': {'vol': {}}
1200            }
1201        )
1202
1203        override_file = config.ConfigFile(
1204            'override.yaml',
1205            {
1206                'version': '3.2',
1207                'services': {
1208                    'web': {
1209                        'volumes': ['/c:/b', '/anonymous']
1210                    }
1211                }
1212            }
1213        )
1214        details = config.ConfigDetails('.', [base_file, override_file])
1215        service_dicts = config.load(details).services
1216        svc_volumes = map(lambda v: v.repr(), service_dicts[0]['volumes'])
1217        for vol in svc_volumes:
1218            assert vol in [
1219                '/anonymous',
1220                '/c:/b:rw',
1221                {'source': 'vol', 'target': '/x', 'type': 'volume', 'read_only': True}
1222            ]
1223        assert service_dicts[0]['stop_grace_period'] == '30s'
1224
1225    @mock.patch.dict(os.environ)
1226    def test_volume_mode_override(self):
1227        os.environ['COMPOSE_CONVERT_WINDOWS_PATHS'] = 'true'
1228        base_file = config.ConfigFile(
1229            'base.yaml',
1230            {
1231                'version': '2.3',
1232                'services': {
1233                    'web': {
1234                        'image': 'example/web',
1235                        'volumes': ['/c:/b:rw']
1236                    }
1237                },
1238            }
1239        )
1240
1241        override_file = config.ConfigFile(
1242            'override.yaml',
1243            {
1244                'version': '2.3',
1245                'services': {
1246                    'web': {
1247                        'volumes': ['/c:/b:ro']
1248                    }
1249                }
1250            }
1251        )
1252        details = config.ConfigDetails('.', [base_file, override_file])
1253        service_dicts = config.load(details).services
1254        svc_volumes = list(map(lambda v: v.repr(), service_dicts[0]['volumes']))
1255        assert svc_volumes == ['/c:/b:ro']
1256
1257    def test_undeclared_volume_v2(self):
1258        base_file = config.ConfigFile(
1259            'base.yaml',
1260            {
1261                'version': '2',
1262                'services': {
1263                    'web': {
1264                        'image': 'busybox:latest',
1265                        'volumes': ['data0028:/data:ro'],
1266                    },
1267                },
1268            }
1269        )
1270        details = config.ConfigDetails('.', [base_file])
1271        with pytest.raises(ConfigurationError):
1272            config.load(details)
1273
1274        base_file = config.ConfigFile(
1275            'base.yaml',
1276            {
1277                'version': '2',
1278                'services': {
1279                    'web': {
1280                        'image': 'busybox:latest',
1281                        'volumes': ['./data0028:/data:ro'],
1282                    },
1283                },
1284            }
1285        )
1286        details = config.ConfigDetails('.', [base_file])
1287        config_data = config.load(details)
1288        volume = config_data.services[0].get('volumes')[0]
1289        assert not volume.is_named_volume
1290
1291    def test_undeclared_volume_v1(self):
1292        base_file = config.ConfigFile(
1293            'base.yaml',
1294            {
1295                'web': {
1296                    'image': 'busybox:latest',
1297                    'volumes': ['data0028:/data:ro'],
1298                },
1299            }
1300        )
1301        details = config.ConfigDetails('.', [base_file])
1302        config_data = config.load(details)
1303        volume = config_data.services[0].get('volumes')[0]
1304        assert volume.external == 'data0028'
1305        assert volume.is_named_volume
1306
1307    def test_volumes_long_syntax(self):
1308        base_file = config.ConfigFile(
1309            'base.yaml', {
1310                'version': '2.3',
1311                'services': {
1312                    'web': {
1313                        'image': 'busybox:latest',
1314                        'volumes': [
1315                            {
1316                                'target': '/anonymous', 'type': 'volume'
1317                            }, {
1318                                'source': '/abc', 'target': '/xyz', 'type': 'bind'
1319                            }, {
1320                                'source': '\\\\.\\pipe\\abcd', 'target': '/named_pipe', 'type': 'npipe'
1321                            }, {
1322                                'type': 'tmpfs', 'target': '/tmpfs'
1323                            }
1324                        ]
1325                    },
1326                },
1327            },
1328        )
1329        details = config.ConfigDetails('.', [base_file])
1330        config_data = config.load(details)
1331        volumes = config_data.services[0].get('volumes')
1332        anon_volume = [v for v in volumes if v.target == '/anonymous'][0]
1333        tmpfs_mount = [v for v in volumes if v.type == 'tmpfs'][0]
1334        host_mount = [v for v in volumes if v.type == 'bind'][0]
1335        npipe_mount = [v for v in volumes if v.type == 'npipe'][0]
1336
1337        assert anon_volume.type == 'volume'
1338        assert not anon_volume.is_named_volume
1339
1340        assert tmpfs_mount.target == '/tmpfs'
1341        assert not tmpfs_mount.is_named_volume
1342
1343        assert host_mount.source == '/abc'
1344        assert host_mount.target == '/xyz'
1345        assert not host_mount.is_named_volume
1346
1347        assert npipe_mount.source == '\\\\.\\pipe\\abcd'
1348        assert npipe_mount.target == '/named_pipe'
1349        assert not npipe_mount.is_named_volume
1350
1351    def test_load_bind_mount_relative_path(self):
1352        expected_source = 'C:\\tmp\\web' if IS_WINDOWS_PLATFORM else '/tmp/web'
1353        base_file = config.ConfigFile(
1354            'base.yaml', {
1355                'version': '3.4',
1356                'services': {
1357                    'web': {
1358                        'image': 'busybox:latest',
1359                        'volumes': [
1360                            {'type': 'bind', 'source': './web', 'target': '/web'},
1361                        ],
1362                    },
1363                },
1364            },
1365        )
1366
1367        details = config.ConfigDetails('/tmp', [base_file])
1368        config_data = config.load(details)
1369        mount = config_data.services[0].get('volumes')[0]
1370        assert mount.target == '/web'
1371        assert mount.type == 'bind'
1372        assert mount.source == expected_source
1373
1374    def test_load_bind_mount_relative_path_with_tilde(self):
1375        base_file = config.ConfigFile(
1376            'base.yaml', {
1377                'version': '3.4',
1378                'services': {
1379                    'web': {
1380                        'image': 'busybox:latest',
1381                        'volumes': [
1382                            {'type': 'bind', 'source': '~/web', 'target': '/web'},
1383                        ],
1384                    },
1385                },
1386            },
1387        )
1388
1389        details = config.ConfigDetails('.', [base_file])
1390        config_data = config.load(details)
1391        mount = config_data.services[0].get('volumes')[0]
1392        assert mount.target == '/web'
1393        assert mount.type == 'bind'
1394        assert (
1395            not mount.source.startswith('~') and mount.source.endswith(
1396                '{}web'.format(os.path.sep)
1397            )
1398        )
1399
1400    def test_config_invalid_ipam_config(self):
1401        with pytest.raises(ConfigurationError) as excinfo:
1402            config.load(
1403                build_config_details(
1404                    {
1405                        'version': str(V2_1),
1406                        'networks': {
1407                            'foo': {
1408                                'driver': 'default',
1409                                'ipam': {
1410                                    'driver': 'default',
1411                                    'config': ['172.18.0.0/16'],
1412                                }
1413                            }
1414                        }
1415                    },
1416                    filename='filename.yml',
1417                )
1418            )
1419        assert ('networks.foo.ipam.config contains an invalid type,'
1420                ' it should be an object') in excinfo.exconly()
1421
1422    def test_config_valid_ipam_config(self):
1423        ipam_config = {
1424            'subnet': '172.28.0.0/16',
1425            'ip_range': '172.28.5.0/24',
1426            'gateway': '172.28.5.254',
1427            'aux_addresses': {
1428                'host1': '172.28.1.5',
1429                'host2': '172.28.1.6',
1430                'host3': '172.28.1.7',
1431            },
1432        }
1433        networks = config.load(
1434            build_config_details(
1435                {
1436                    'version': str(V2_1),
1437                    'networks': {
1438                        'foo': {
1439                            'driver': 'default',
1440                            'ipam': {
1441                                'driver': 'default',
1442                                'config': [ipam_config],
1443                            }
1444                        }
1445                    }
1446                },
1447                filename='filename.yml',
1448            )
1449        ).networks
1450
1451        assert 'foo' in networks
1452        assert networks['foo']['ipam']['config'] == [ipam_config]
1453
1454    def test_config_valid_service_names(self):
1455        for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']:
1456            services = config.load(
1457                build_config_details(
1458                    {valid_name: {'image': 'busybox'}},
1459                    'tests/fixtures/extends',
1460                    'common.yml')).services
1461            assert services[0]['name'] == valid_name
1462
1463    def test_config_hint(self):
1464        with pytest.raises(ConfigurationError) as excinfo:
1465            config.load(
1466                build_config_details(
1467                    {
1468                        'foo': {'image': 'busybox', 'privilige': 'something'},
1469                    },
1470                    'tests/fixtures/extends',
1471                    'filename.yml'
1472                )
1473            )
1474
1475        assert "(did you mean 'privileged'?)" in excinfo.exconly()
1476
1477    def test_load_errors_on_uppercase_with_no_image(self):
1478        with pytest.raises(ConfigurationError) as exc:
1479            config.load(build_config_details({
1480                'Foo': {'build': '.'},
1481            }, 'tests/fixtures/build-ctx'))
1482            assert "Service 'Foo' contains uppercase characters" in exc.exconly()
1483
1484    def test_invalid_config_v1(self):
1485        with pytest.raises(ConfigurationError) as excinfo:
1486            config.load(
1487                build_config_details(
1488                    {
1489                        'foo': {'image': 1},
1490                    },
1491                    'tests/fixtures/extends',
1492                    'filename.yml'
1493                )
1494            )
1495
1496        assert "foo.image contains an invalid type, it should be a string" \
1497            in excinfo.exconly()
1498
1499    def test_invalid_config_v2(self):
1500        with pytest.raises(ConfigurationError) as excinfo:
1501            config.load(
1502                build_config_details(
1503                    {
1504                        'version': '2',
1505                        'services': {
1506                            'foo': {'image': 1},
1507                        },
1508                    },
1509                    'tests/fixtures/extends',
1510                    'filename.yml'
1511                )
1512            )
1513
1514        assert "services.foo.image contains an invalid type, it should be a string" \
1515            in excinfo.exconly()
1516
1517    def test_invalid_config_build_and_image_specified_v1(self):
1518        with pytest.raises(ConfigurationError) as excinfo:
1519            config.load(
1520                build_config_details(
1521                    {
1522                        'foo': {'image': 'busybox', 'build': '.'},
1523                    },
1524                    'tests/fixtures/extends',
1525                    'filename.yml'
1526                )
1527            )
1528
1529        assert "foo has both an image and build path specified." in excinfo.exconly()
1530
1531    def test_invalid_config_type_should_be_an_array(self):
1532        with pytest.raises(ConfigurationError) as excinfo:
1533            config.load(
1534                build_config_details(
1535                    {
1536                        'foo': {'image': 'busybox', 'links': 'an_link'},
1537                    },
1538                    'tests/fixtures/extends',
1539                    'filename.yml'
1540                )
1541            )
1542
1543        assert "foo.links contains an invalid type, it should be an array" \
1544            in excinfo.exconly()
1545
1546    def test_invalid_config_not_a_dictionary(self):
1547        with pytest.raises(ConfigurationError) as excinfo:
1548            config.load(
1549                build_config_details(
1550                    ['foo', 'lol'],
1551                    'tests/fixtures/extends',
1552                    'filename.yml'
1553                )
1554            )
1555
1556        assert "Top level object in 'filename.yml' needs to be an object" \
1557            in excinfo.exconly()
1558
1559    def test_invalid_config_not_unique_items(self):
1560        with pytest.raises(ConfigurationError) as excinfo:
1561            config.load(
1562                build_config_details(
1563                    {
1564                        'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']}
1565                    },
1566                    'tests/fixtures/extends',
1567                    'filename.yml'
1568                )
1569            )
1570
1571        assert "has non-unique elements" in excinfo.exconly()
1572
1573    def test_invalid_list_of_strings_format(self):
1574        with pytest.raises(ConfigurationError) as excinfo:
1575            config.load(
1576                build_config_details(
1577                    {
1578                        'web': {'build': '.', 'command': [1]}
1579                    },
1580                    'tests/fixtures/extends',
1581                    'filename.yml'
1582                )
1583            )
1584
1585        assert "web.command contains 1, which is an invalid type, it should be a string" \
1586            in excinfo.exconly()
1587
1588    def test_load_config_dockerfile_without_build_raises_error_v1(self):
1589        with pytest.raises(ConfigurationError) as exc:
1590            config.load(build_config_details({
1591                'web': {
1592                    'image': 'busybox',
1593                    'dockerfile': 'Dockerfile.alt'
1594                }
1595            }))
1596
1597        assert "web has both an image and alternate Dockerfile." in exc.exconly()
1598
1599    def test_config_extra_hosts_string_raises_validation_error(self):
1600        with pytest.raises(ConfigurationError) as excinfo:
1601            config.load(
1602                build_config_details(
1603                    {'web': {
1604                        'image': 'busybox',
1605                        'extra_hosts': 'somehost:162.242.195.82'
1606                    }},
1607                    'working_dir',
1608                    'filename.yml'
1609                )
1610            )
1611
1612        assert "web.extra_hosts contains an invalid type" \
1613            in excinfo.exconly()
1614
1615    def test_config_extra_hosts_list_of_dicts_validation_error(self):
1616        with pytest.raises(ConfigurationError) as excinfo:
1617            config.load(
1618                build_config_details(
1619                    {'web': {
1620                        'image': 'busybox',
1621                        'extra_hosts': [
1622                            {'somehost': '162.242.195.82'},
1623                            {'otherhost': '50.31.209.229'}
1624                        ]
1625                    }},
1626                    'working_dir',
1627                    'filename.yml'
1628                )
1629            )
1630
1631        assert "web.extra_hosts contains {\"somehost\": \"162.242.195.82\"}, " \
1632               "which is an invalid type, it should be a string" \
1633            in excinfo.exconly()
1634
1635    def test_config_ulimits_invalid_keys_validation_error(self):
1636        with pytest.raises(ConfigurationError) as exc:
1637            config.load(build_config_details(
1638                {
1639                    'web': {
1640                        'image': 'busybox',
1641                        'ulimits': {
1642                            'nofile': {
1643                                "not_soft_or_hard": 100,
1644                                "soft": 10000,
1645                                "hard": 20000,
1646                            }
1647                        }
1648                    }
1649                },
1650                'working_dir',
1651                'filename.yml'))
1652
1653        assert "web.ulimits.nofile contains unsupported option: 'not_soft_or_hard'" \
1654            in exc.exconly()
1655
1656    def test_config_ulimits_required_keys_validation_error(self):
1657        with pytest.raises(ConfigurationError) as exc:
1658            config.load(build_config_details(
1659                {
1660                    'web': {
1661                        'image': 'busybox',
1662                        'ulimits': {'nofile': {"soft": 10000}}
1663                    }
1664                },
1665                'working_dir',
1666                'filename.yml'))
1667        assert "web.ulimits.nofile" in exc.exconly()
1668        assert "'hard' is a required property" in exc.exconly()
1669
1670    def test_config_ulimits_soft_greater_than_hard_error(self):
1671        expected = "'soft' value can not be greater than 'hard' value"
1672
1673        with pytest.raises(ConfigurationError) as exc:
1674            config.load(build_config_details(
1675                {
1676                    'web': {
1677                        'image': 'busybox',
1678                        'ulimits': {
1679                            'nofile': {"soft": 10000, "hard": 1000}
1680                        }
1681                    }
1682                },
1683                'working_dir',
1684                'filename.yml'))
1685        assert expected in exc.exconly()
1686
1687    def test_valid_config_which_allows_two_type_definitions(self):
1688        expose_values = [["8000"], [8000]]
1689        for expose in expose_values:
1690            service = config.load(
1691                build_config_details(
1692                    {'web': {
1693                        'image': 'busybox',
1694                        'expose': expose
1695                    }},
1696                    'working_dir',
1697                    'filename.yml'
1698                )
1699            ).services
1700            assert service[0]['expose'] == expose
1701
1702    def test_valid_config_oneof_string_or_list(self):
1703        entrypoint_values = [["sh"], "sh"]
1704        for entrypoint in entrypoint_values:
1705            service = config.load(
1706                build_config_details(
1707                    {'web': {
1708                        'image': 'busybox',
1709                        'entrypoint': entrypoint
1710                    }},
1711                    'working_dir',
1712                    'filename.yml'
1713                )
1714            ).services
1715            assert service[0]['entrypoint'] == entrypoint
1716
1717    def test_logs_warning_for_boolean_in_environment(self):
1718        config_details = build_config_details({
1719            'web': {
1720                'image': 'busybox',
1721                'environment': {'SHOW_STUFF': True}
1722            }
1723        })
1724
1725        with pytest.raises(ConfigurationError) as exc:
1726            config.load(config_details)
1727
1728        assert "contains true, which is an invalid type" in exc.exconly()
1729
1730    def test_config_valid_environment_dict_key_contains_dashes(self):
1731        services = config.load(
1732            build_config_details(
1733                {'web': {
1734                    'image': 'busybox',
1735                    'environment': {'SPRING_JPA_HIBERNATE_DDL-AUTO': 'none'}
1736                }},
1737                'working_dir',
1738                'filename.yml'
1739            )
1740        ).services
1741        assert services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'] == 'none'
1742
1743    def test_load_yaml_with_yaml_error(self):
1744        tmpdir = py.test.ensuretemp('invalid_yaml_test')
1745        self.addCleanup(tmpdir.remove)
1746        invalid_yaml_file = tmpdir.join('docker-compose.yml')
1747        invalid_yaml_file.write("""
1748            web:
1749              this is bogus: ok: what
1750        """)
1751        with pytest.raises(ConfigurationError) as exc:
1752            config.load_yaml(str(invalid_yaml_file))
1753
1754        assert 'line 3, column 32' in exc.exconly()
1755
1756    def test_load_yaml_with_bom(self):
1757        tmpdir = py.test.ensuretemp('bom_yaml')
1758        self.addCleanup(tmpdir.remove)
1759        bom_yaml = tmpdir.join('docker-compose.yml')
1760        with codecs.open(str(bom_yaml), 'w', encoding='utf-8') as f:
1761            f.write('''\ufeff
1762                version: '2.3'
1763                volumes:
1764                    park_bom:
1765            ''')
1766        assert config.load_yaml(str(bom_yaml)) == {
1767            'version': '2.3',
1768            'volumes': {'park_bom': None}
1769        }
1770
1771    def test_validate_extra_hosts_invalid(self):
1772        with pytest.raises(ConfigurationError) as exc:
1773            config.load(build_config_details({
1774                'web': {
1775                    'image': 'alpine',
1776                    'extra_hosts': "www.example.com: 192.168.0.17",
1777                }
1778            }))
1779        assert "web.extra_hosts contains an invalid type" in exc.exconly()
1780
1781    def test_validate_extra_hosts_invalid_list(self):
1782        with pytest.raises(ConfigurationError) as exc:
1783            config.load(build_config_details({
1784                'web': {
1785                    'image': 'alpine',
1786                    'extra_hosts': [
1787                        {'www.example.com': '192.168.0.17'},
1788                        {'api.example.com': '192.168.0.18'}
1789                    ],
1790                }
1791            }))
1792        assert "which is an invalid type" in exc.exconly()
1793
1794    def test_normalize_dns_options(self):
1795        actual = config.load(build_config_details({
1796            'web': {
1797                'image': 'alpine',
1798                'dns': '8.8.8.8',
1799                'dns_search': 'domain.local',
1800            }
1801        }))
1802        assert actual.services == [
1803            {
1804                'name': 'web',
1805                'image': 'alpine',
1806                'dns': ['8.8.8.8'],
1807                'dns_search': ['domain.local'],
1808            }
1809        ]
1810
1811    def test_tmpfs_option(self):
1812        actual = config.load(build_config_details({
1813            'version': '2',
1814            'services': {
1815                'web': {
1816                    'image': 'alpine',
1817                    'tmpfs': '/run',
1818                }
1819            }
1820        }))
1821        assert actual.services == [
1822            {
1823                'name': 'web',
1824                'image': 'alpine',
1825                'tmpfs': ['/run'],
1826            }
1827        ]
1828
1829    def test_oom_score_adj_option(self):
1830
1831        actual = config.load(build_config_details({
1832            'version': '2',
1833            'services': {
1834                'web': {
1835                    'image': 'alpine',
1836                    'oom_score_adj': 500
1837                }
1838            }
1839        }))
1840
1841        assert actual.services == [
1842            {
1843                'name': 'web',
1844                'image': 'alpine',
1845                'oom_score_adj': 500
1846            }
1847        ]
1848
1849    def test_swappiness_option(self):
1850        actual = config.load(build_config_details({
1851            'version': '2',
1852            'services': {
1853                'web': {
1854                    'image': 'alpine',
1855                    'mem_swappiness': 10,
1856                }
1857            }
1858        }))
1859        assert actual.services == [
1860            {
1861                'name': 'web',
1862                'image': 'alpine',
1863                'mem_swappiness': 10,
1864            }
1865        ]
1866
1867    def test_group_add_option(self):
1868        actual = config.load(build_config_details({
1869            'version': '2',
1870            'services': {
1871                'web': {
1872                    'image': 'alpine',
1873                    'group_add': ["docker", 777]
1874                }
1875            }
1876        }))
1877
1878        assert actual.services == [
1879            {
1880                'name': 'web',
1881                'image': 'alpine',
1882                'group_add': ["docker", 777]
1883            }
1884        ]
1885
1886    def test_dns_opt_option(self):
1887        actual = config.load(build_config_details({
1888            'version': '2',
1889            'services': {
1890                'web': {
1891                    'image': 'alpine',
1892                    'dns_opt': ["use-vc", "no-tld-query"]
1893                }
1894            }
1895        }))
1896
1897        assert actual.services == [
1898            {
1899                'name': 'web',
1900                'image': 'alpine',
1901                'dns_opt': ["use-vc", "no-tld-query"]
1902            }
1903        ]
1904
1905    def test_isolation_option(self):
1906        actual = config.load(build_config_details({
1907            'version': str(V2_1),
1908            'services': {
1909                'web': {
1910                    'image': 'win10',
1911                    'isolation': 'hyperv'
1912                }
1913            }
1914        }))
1915
1916        assert actual.services == [
1917            {
1918                'name': 'web',
1919                'image': 'win10',
1920                'isolation': 'hyperv',
1921            }
1922        ]
1923
1924    def test_runtime_option(self):
1925        actual = config.load(build_config_details({
1926            'version': str(V2_3),
1927            'services': {
1928                'web': {
1929                    'image': 'nvidia/cuda',
1930                    'runtime': 'nvidia'
1931                }
1932            }
1933        }))
1934
1935        assert actual.services == [
1936            {
1937                'name': 'web',
1938                'image': 'nvidia/cuda',
1939                'runtime': 'nvidia',
1940            }
1941        ]
1942
1943    def test_merge_service_dicts_from_files_with_extends_in_base(self):
1944        base = {
1945            'volumes': ['.:/app'],
1946            'extends': {'service': 'app'}
1947        }
1948        override = {
1949            'image': 'alpine:edge',
1950        }
1951        actual = config.merge_service_dicts_from_files(
1952            base,
1953            override,
1954            DEFAULT_VERSION)
1955        assert actual == {
1956            'image': 'alpine:edge',
1957            'volumes': ['.:/app'],
1958            'extends': {'service': 'app'}
1959        }
1960
1961    def test_merge_service_dicts_from_files_with_extends_in_override(self):
1962        base = {
1963            'volumes': ['.:/app'],
1964            'extends': {'service': 'app'}
1965        }
1966        override = {
1967            'image': 'alpine:edge',
1968            'extends': {'service': 'foo'}
1969        }
1970        actual = config.merge_service_dicts_from_files(
1971            base,
1972            override,
1973            DEFAULT_VERSION)
1974        assert actual == {
1975            'image': 'alpine:edge',
1976            'volumes': ['.:/app'],
1977            'extends': {'service': 'foo'}
1978        }
1979
1980    def test_merge_service_dicts_heterogeneous(self):
1981        base = {
1982            'volumes': ['.:/app'],
1983            'ports': ['5432']
1984        }
1985        override = {
1986            'image': 'alpine:edge',
1987            'ports': [5432]
1988        }
1989        actual = config.merge_service_dicts_from_files(
1990            base,
1991            override,
1992            DEFAULT_VERSION)
1993        assert actual == {
1994            'image': 'alpine:edge',
1995            'volumes': ['.:/app'],
1996            'ports': types.ServicePort.parse('5432')
1997        }
1998
1999    def test_merge_service_dicts_heterogeneous_2(self):
2000        base = {
2001            'volumes': ['.:/app'],
2002            'ports': [5432]
2003        }
2004        override = {
2005            'image': 'alpine:edge',
2006            'ports': ['5432']
2007        }
2008        actual = config.merge_service_dicts_from_files(
2009            base,
2010            override,
2011            DEFAULT_VERSION)
2012        assert actual == {
2013            'image': 'alpine:edge',
2014            'volumes': ['.:/app'],
2015            'ports': types.ServicePort.parse('5432')
2016        }
2017
2018    def test_merge_service_dicts_ports_sorting(self):
2019        base = {
2020            'ports': [5432]
2021        }
2022        override = {
2023            'image': 'alpine:edge',
2024            'ports': ['5432/udp']
2025        }
2026        actual = config.merge_service_dicts_from_files(
2027            base,
2028            override,
2029            DEFAULT_VERSION)
2030        assert len(actual['ports']) == 2
2031        assert types.ServicePort.parse('5432')[0] in actual['ports']
2032        assert types.ServicePort.parse('5432/udp')[0] in actual['ports']
2033
2034    def test_merge_service_dicts_heterogeneous_volumes(self):
2035        base = {
2036            'volumes': ['/a:/b', '/x:/z'],
2037        }
2038
2039        override = {
2040            'image': 'alpine:edge',
2041            'volumes': [
2042                {'source': '/e', 'target': '/b', 'type': 'bind'},
2043                {'source': '/c', 'target': '/d', 'type': 'bind'}
2044            ]
2045        }
2046
2047        actual = config.merge_service_dicts_from_files(
2048            base, override, V3_2
2049        )
2050
2051        assert actual['volumes'] == [
2052            {'source': '/e', 'target': '/b', 'type': 'bind'},
2053            {'source': '/c', 'target': '/d', 'type': 'bind'},
2054            '/x:/z'
2055        ]
2056
2057    def test_merge_logging_v1(self):
2058        base = {
2059            'image': 'alpine:edge',
2060            'log_driver': 'something',
2061            'log_opt': {'foo': 'three'},
2062        }
2063        override = {
2064            'image': 'alpine:edge',
2065            'command': 'true',
2066        }
2067        actual = config.merge_service_dicts(base, override, V1)
2068        assert actual == {
2069            'image': 'alpine:edge',
2070            'log_driver': 'something',
2071            'log_opt': {'foo': 'three'},
2072            'command': 'true',
2073        }
2074
2075    def test_merge_logging_v2(self):
2076        base = {
2077            'image': 'alpine:edge',
2078            'logging': {
2079                'driver': 'json-file',
2080                'options': {
2081                    'frequency': '2000',
2082                    'timeout': '23'
2083                }
2084            }
2085        }
2086        override = {
2087            'logging': {
2088                'options': {
2089                    'timeout': '360',
2090                    'pretty-print': 'on'
2091                }
2092            }
2093        }
2094
2095        actual = config.merge_service_dicts(base, override, V2_0)
2096        assert actual == {
2097            'image': 'alpine:edge',
2098            'logging': {
2099                'driver': 'json-file',
2100                'options': {
2101                    'frequency': '2000',
2102                    'timeout': '360',
2103                    'pretty-print': 'on'
2104                }
2105            }
2106        }
2107
2108    def test_merge_logging_v2_override_driver(self):
2109        base = {
2110            'image': 'alpine:edge',
2111            'logging': {
2112                'driver': 'json-file',
2113                'options': {
2114                    'frequency': '2000',
2115                    'timeout': '23'
2116                }
2117            }
2118        }
2119        override = {
2120            'logging': {
2121                'driver': 'syslog',
2122                'options': {
2123                    'timeout': '360',
2124                    'pretty-print': 'on'
2125                }
2126            }
2127        }
2128
2129        actual = config.merge_service_dicts(base, override, V2_0)
2130        assert actual == {
2131            'image': 'alpine:edge',
2132            'logging': {
2133                'driver': 'syslog',
2134                'options': {
2135                    'timeout': '360',
2136                    'pretty-print': 'on'
2137                }
2138            }
2139        }
2140
2141    def test_merge_logging_v2_no_base_driver(self):
2142        base = {
2143            'image': 'alpine:edge',
2144            'logging': {
2145                'options': {
2146                    'frequency': '2000',
2147                    'timeout': '23'
2148                }
2149            }
2150        }
2151        override = {
2152            'logging': {
2153                'driver': 'json-file',
2154                'options': {
2155                    'timeout': '360',
2156                    'pretty-print': 'on'
2157                }
2158            }
2159        }
2160
2161        actual = config.merge_service_dicts(base, override, V2_0)
2162        assert actual == {
2163            'image': 'alpine:edge',
2164            'logging': {
2165                'driver': 'json-file',
2166                'options': {
2167                    'frequency': '2000',
2168                    'timeout': '360',
2169                    'pretty-print': 'on'
2170                }
2171            }
2172        }
2173
2174    def test_merge_logging_v2_no_drivers(self):
2175        base = {
2176            'image': 'alpine:edge',
2177            'logging': {
2178                'options': {
2179                    'frequency': '2000',
2180                    'timeout': '23'
2181                }
2182            }
2183        }
2184        override = {
2185            'logging': {
2186                'options': {
2187                    'timeout': '360',
2188                    'pretty-print': 'on'
2189                }
2190            }
2191        }
2192
2193        actual = config.merge_service_dicts(base, override, V2_0)
2194        assert actual == {
2195            'image': 'alpine:edge',
2196            'logging': {
2197                'options': {
2198                    'frequency': '2000',
2199                    'timeout': '360',
2200                    'pretty-print': 'on'
2201                }
2202            }
2203        }
2204
2205    def test_merge_logging_v2_no_override_options(self):
2206        base = {
2207            'image': 'alpine:edge',
2208            'logging': {
2209                'driver': 'json-file',
2210                'options': {
2211                    'frequency': '2000',
2212                    'timeout': '23'
2213                }
2214            }
2215        }
2216        override = {
2217            'logging': {
2218                'driver': 'syslog'
2219            }
2220        }
2221
2222        actual = config.merge_service_dicts(base, override, V2_0)
2223        assert actual == {
2224            'image': 'alpine:edge',
2225            'logging': {
2226                'driver': 'syslog',
2227            }
2228        }
2229
2230    def test_merge_logging_v2_no_base(self):
2231        base = {
2232            'image': 'alpine:edge'
2233        }
2234        override = {
2235            'logging': {
2236                'driver': 'json-file',
2237                'options': {
2238                    'frequency': '2000'
2239                }
2240            }
2241        }
2242        actual = config.merge_service_dicts(base, override, V2_0)
2243        assert actual == {
2244            'image': 'alpine:edge',
2245            'logging': {
2246                'driver': 'json-file',
2247                'options': {
2248                    'frequency': '2000'
2249                }
2250            }
2251        }
2252
2253    def test_merge_logging_v2_no_override(self):
2254        base = {
2255            'image': 'alpine:edge',
2256            'logging': {
2257                'driver': 'syslog',
2258                'options': {
2259                    'frequency': '2000'
2260                }
2261            }
2262        }
2263        override = {}
2264        actual = config.merge_service_dicts(base, override, V2_0)
2265        assert actual == {
2266            'image': 'alpine:edge',
2267            'logging': {
2268                'driver': 'syslog',
2269                'options': {
2270                    'frequency': '2000'
2271                }
2272            }
2273        }
2274
2275    def test_merge_mixed_ports(self):
2276        base = {
2277            'image': 'busybox:latest',
2278            'command': 'top',
2279            'ports': [
2280                {
2281                    'target': '1245',
2282                    'published': '1245',
2283                    'protocol': 'udp',
2284                }
2285            ]
2286        }
2287
2288        override = {
2289            'ports': ['1245:1245/udp']
2290        }
2291
2292        actual = config.merge_service_dicts(base, override, V3_1)
2293        assert actual == {
2294            'image': 'busybox:latest',
2295            'command': 'top',
2296            'ports': [types.ServicePort('1245', '1245', 'udp', None, None)]
2297        }
2298
2299    def test_merge_depends_on_no_override(self):
2300        base = {
2301            'image': 'busybox',
2302            'depends_on': {
2303                'app1': {'condition': 'service_started'},
2304                'app2': {'condition': 'service_healthy'}
2305            }
2306        }
2307        override = {}
2308        actual = config.merge_service_dicts(base, override, V2_1)
2309        assert actual == base
2310
2311    def test_merge_depends_on_mixed_syntax(self):
2312        base = {
2313            'image': 'busybox',
2314            'depends_on': {
2315                'app1': {'condition': 'service_started'},
2316                'app2': {'condition': 'service_healthy'}
2317            }
2318        }
2319        override = {
2320            'depends_on': ['app3']
2321        }
2322
2323        actual = config.merge_service_dicts(base, override, V2_1)
2324        assert actual == {
2325            'image': 'busybox',
2326            'depends_on': {
2327                'app1': {'condition': 'service_started'},
2328                'app2': {'condition': 'service_healthy'},
2329                'app3': {'condition': 'service_started'}
2330            }
2331        }
2332
2333    def test_empty_environment_key_allowed(self):
2334        service_dict = config.load(
2335            build_config_details(
2336                {
2337                    'web': {
2338                        'build': '.',
2339                        'environment': {
2340                            'POSTGRES_PASSWORD': ''
2341                        },
2342                    },
2343                },
2344                '.',
2345                None,
2346            )
2347        ).services[0]
2348        assert service_dict['environment']['POSTGRES_PASSWORD'] == ''
2349
2350    def test_merge_pid(self):
2351        # Regression: https://github.com/docker/compose/issues/4184
2352        base = {
2353            'image': 'busybox',
2354            'pid': 'host'
2355        }
2356
2357        override = {
2358            'labels': {'com.docker.compose.test': 'yes'}
2359        }
2360
2361        actual = config.merge_service_dicts(base, override, V2_0)
2362        assert actual == {
2363            'image': 'busybox',
2364            'pid': 'host',
2365            'labels': {'com.docker.compose.test': 'yes'}
2366        }
2367
2368    def test_merge_different_secrets(self):
2369        base = {
2370            'image': 'busybox',
2371            'secrets': [
2372                {'source': 'src.txt'}
2373            ]
2374        }
2375        override = {'secrets': ['other-src.txt']}
2376
2377        actual = config.merge_service_dicts(base, override, V3_1)
2378        assert secret_sort(actual['secrets']) == secret_sort([
2379            {'source': 'src.txt'},
2380            {'source': 'other-src.txt'}
2381        ])
2382
2383    def test_merge_secrets_override(self):
2384        base = {
2385            'image': 'busybox',
2386            'secrets': ['src.txt'],
2387        }
2388        override = {
2389            'secrets': [
2390                {
2391                    'source': 'src.txt',
2392                    'target': 'data.txt',
2393                    'mode': 0o400
2394                }
2395            ]
2396        }
2397        actual = config.merge_service_dicts(base, override, V3_1)
2398        assert actual['secrets'] == override['secrets']
2399
2400    def test_merge_different_configs(self):
2401        base = {
2402            'image': 'busybox',
2403            'configs': [
2404                {'source': 'src.txt'}
2405            ]
2406        }
2407        override = {'configs': ['other-src.txt']}
2408
2409        actual = config.merge_service_dicts(base, override, V3_3)
2410        assert secret_sort(actual['configs']) == secret_sort([
2411            {'source': 'src.txt'},
2412            {'source': 'other-src.txt'}
2413        ])
2414
2415    def test_merge_configs_override(self):
2416        base = {
2417            'image': 'busybox',
2418            'configs': ['src.txt'],
2419        }
2420        override = {
2421            'configs': [
2422                {
2423                    'source': 'src.txt',
2424                    'target': 'data.txt',
2425                    'mode': 0o400
2426                }
2427            ]
2428        }
2429        actual = config.merge_service_dicts(base, override, V3_3)
2430        assert actual['configs'] == override['configs']
2431
2432    def test_merge_deploy(self):
2433        base = {
2434            'image': 'busybox',
2435        }
2436        override = {
2437            'deploy': {
2438                'mode': 'global',
2439                'restart_policy': {
2440                    'condition': 'on-failure'
2441                }
2442            }
2443        }
2444        actual = config.merge_service_dicts(base, override, V3_0)
2445        assert actual['deploy'] == override['deploy']
2446
2447    def test_merge_deploy_override(self):
2448        base = {
2449            'deploy': {
2450                'endpoint_mode': 'vip',
2451                'labels': ['com.docker.compose.a=1', 'com.docker.compose.b=2'],
2452                'mode': 'replicated',
2453                'placement': {
2454                    'constraints': [
2455                        'node.role == manager', 'engine.labels.aws == true'
2456                    ],
2457                    'preferences': [
2458                        {'spread': 'node.labels.zone'}, {'spread': 'x.d.z'}
2459                    ]
2460                },
2461                'replicas': 3,
2462                'resources': {
2463                    'limits': {'cpus': '0.50', 'memory': '50m'},
2464                    'reservations': {
2465                        'cpus': '0.1',
2466                        'generic_resources': [
2467                            {'discrete_resource_spec': {'kind': 'abc', 'value': 123}}
2468                        ],
2469                        'memory': '15m'
2470                    }
2471                },
2472                'restart_policy': {'condition': 'any', 'delay': '10s'},
2473                'update_config': {'delay': '10s', 'max_failure_ratio': 0.3}
2474            },
2475            'image': 'hello-world'
2476        }
2477        override = {
2478            'deploy': {
2479                'labels': {
2480                    'com.docker.compose.b': '21', 'com.docker.compose.c': '3'
2481                },
2482                'placement': {
2483                    'constraints': ['node.role == worker', 'engine.labels.dev == true'],
2484                    'preferences': [{'spread': 'node.labels.zone'}, {'spread': 'x.d.s'}]
2485                },
2486                'resources': {
2487                    'limits': {'memory': '200m'},
2488                    'reservations': {
2489                        'cpus': '0.78',
2490                        'generic_resources': [
2491                            {'discrete_resource_spec': {'kind': 'abc', 'value': 134}},
2492                            {'discrete_resource_spec': {'kind': 'xyz', 'value': 0.1}}
2493                        ]
2494                    }
2495                },
2496                'restart_policy': {'condition': 'on-failure', 'max_attempts': 42},
2497                'update_config': {'max_failure_ratio': 0.712, 'parallelism': 4}
2498            }
2499        }
2500        actual = config.merge_service_dicts(base, override, V3_5)
2501        assert actual['deploy'] == {
2502            'mode': 'replicated',
2503            'endpoint_mode': 'vip',
2504            'labels': {
2505                'com.docker.compose.a': '1',
2506                'com.docker.compose.b': '21',
2507                'com.docker.compose.c': '3'
2508            },
2509            'placement': {
2510                'constraints': [
2511                    'engine.labels.aws == true', 'engine.labels.dev == true',
2512                    'node.role == manager', 'node.role == worker'
2513                ],
2514                'preferences': [
2515                    {'spread': 'node.labels.zone'}, {'spread': 'x.d.s'}, {'spread': 'x.d.z'}
2516                ]
2517            },
2518            'replicas': 3,
2519            'resources': {
2520                'limits': {'cpus': '0.50', 'memory': '200m'},
2521                'reservations': {
2522                    'cpus': '0.78',
2523                    'memory': '15m',
2524                    'generic_resources': [
2525                        {'discrete_resource_spec': {'kind': 'abc', 'value': 134}},
2526                        {'discrete_resource_spec': {'kind': 'xyz', 'value': 0.1}},
2527                    ]
2528                }
2529            },
2530            'restart_policy': {
2531                'condition': 'on-failure',
2532                'delay': '10s',
2533                'max_attempts': 42,
2534            },
2535            'update_config': {
2536                'max_failure_ratio': 0.712,
2537                'delay': '10s',
2538                'parallelism': 4
2539            }
2540        }
2541
2542    def test_merge_credential_spec(self):
2543        base = {
2544            'image': 'bb',
2545            'credential_spec': {
2546                'file': '/hello-world',
2547            }
2548        }
2549
2550        override = {
2551            'credential_spec': {
2552                'registry': 'revolution.com',
2553            }
2554        }
2555
2556        actual = config.merge_service_dicts(base, override, V3_3)
2557        assert actual['credential_spec'] == override['credential_spec']
2558
2559    def test_merge_scale(self):
2560        base = {
2561            'image': 'bar',
2562            'scale': 2,
2563        }
2564
2565        override = {
2566            'scale': 4,
2567        }
2568
2569        actual = config.merge_service_dicts(base, override, V2_2)
2570        assert actual == {'image': 'bar', 'scale': 4}
2571
2572    def test_merge_blkio_config(self):
2573        base = {
2574            'image': 'bar',
2575            'blkio_config': {
2576                'weight': 300,
2577                'weight_device': [
2578                    {'path': '/dev/sda1', 'weight': 200}
2579                ],
2580                'device_read_iops': [
2581                    {'path': '/dev/sda1', 'rate': 300}
2582                ],
2583                'device_write_iops': [
2584                    {'path': '/dev/sda1', 'rate': 1000}
2585                ]
2586            }
2587        }
2588
2589        override = {
2590            'blkio_config': {
2591                'weight': 450,
2592                'weight_device': [
2593                    {'path': '/dev/sda2', 'weight': 400}
2594                ],
2595                'device_read_iops': [
2596                    {'path': '/dev/sda1', 'rate': 2000}
2597                ],
2598                'device_read_bps': [
2599                    {'path': '/dev/sda1', 'rate': 1024}
2600                ]
2601            }
2602        }
2603
2604        actual = config.merge_service_dicts(base, override, V2_2)
2605        assert actual == {
2606            'image': 'bar',
2607            'blkio_config': {
2608                'weight': override['blkio_config']['weight'],
2609                'weight_device': (
2610                    base['blkio_config']['weight_device'] +
2611                    override['blkio_config']['weight_device']
2612                ),
2613                'device_read_iops': override['blkio_config']['device_read_iops'],
2614                'device_read_bps': override['blkio_config']['device_read_bps'],
2615                'device_write_iops': base['blkio_config']['device_write_iops']
2616            }
2617        }
2618
2619    def test_merge_extra_hosts(self):
2620        base = {
2621            'image': 'bar',
2622            'extra_hosts': {
2623                'foo': '1.2.3.4',
2624            }
2625        }
2626
2627        override = {
2628            'extra_hosts': ['bar:5.6.7.8', 'foo:127.0.0.1']
2629        }
2630
2631        actual = config.merge_service_dicts(base, override, V2_0)
2632        assert actual['extra_hosts'] == {
2633            'foo': '127.0.0.1',
2634            'bar': '5.6.7.8',
2635        }
2636
2637    def test_merge_healthcheck_config(self):
2638        base = {
2639            'image': 'bar',
2640            'healthcheck': {
2641                'start_period': 1000,
2642                'interval': 3000,
2643                'test': ['true']
2644            }
2645        }
2646
2647        override = {
2648            'healthcheck': {
2649                'interval': 5000,
2650                'timeout': 10000,
2651                'test': ['echo', 'OK'],
2652            }
2653        }
2654
2655        actual = config.merge_service_dicts(base, override, V2_3)
2656        assert actual['healthcheck'] == {
2657            'start_period': base['healthcheck']['start_period'],
2658            'test': override['healthcheck']['test'],
2659            'interval': override['healthcheck']['interval'],
2660            'timeout': override['healthcheck']['timeout'],
2661        }
2662
2663    def test_merge_healthcheck_override_disables(self):
2664        base = {
2665            'image': 'bar',
2666            'healthcheck': {
2667                'start_period': 1000,
2668                'interval': 3000,
2669                'timeout': 2000,
2670                'retries': 3,
2671                'test': ['true']
2672            }
2673        }
2674
2675        override = {
2676            'healthcheck': {
2677                'disabled': True
2678            }
2679        }
2680
2681        actual = config.merge_service_dicts(base, override, V2_3)
2682        assert actual['healthcheck'] == {'disabled': True}
2683
2684    def test_merge_healthcheck_override_enables(self):
2685        base = {
2686            'image': 'bar',
2687            'healthcheck': {
2688                'disabled': True
2689            }
2690        }
2691
2692        override = {
2693            'healthcheck': {
2694                'disabled': False,
2695                'start_period': 1000,
2696                'interval': 3000,
2697                'timeout': 2000,
2698                'retries': 3,
2699                'test': ['true']
2700            }
2701        }
2702
2703        actual = config.merge_service_dicts(base, override, V2_3)
2704        assert actual['healthcheck'] == override['healthcheck']
2705
2706    def test_merge_device_cgroup_rules(self):
2707        base = {
2708            'image': 'bar',
2709            'device_cgroup_rules': ['c 7:128 rwm', 'x 3:244 rw']
2710        }
2711
2712        override = {
2713            'device_cgroup_rules': ['c 7:128 rwm', 'f 0:128 n']
2714        }
2715
2716        actual = config.merge_service_dicts(base, override, V2_3)
2717        assert sorted(actual['device_cgroup_rules']) == sorted(
2718            ['c 7:128 rwm', 'x 3:244 rw', 'f 0:128 n']
2719        )
2720
2721    def test_merge_isolation(self):
2722        base = {
2723            'image': 'bar',
2724            'isolation': 'default',
2725        }
2726
2727        override = {
2728            'isolation': 'hyperv',
2729        }
2730
2731        actual = config.merge_service_dicts(base, override, V2_3)
2732        assert actual == {
2733            'image': 'bar',
2734            'isolation': 'hyperv',
2735        }
2736
2737    def test_merge_storage_opt(self):
2738        base = {
2739            'image': 'bar',
2740            'storage_opt': {
2741                'size': '1G',
2742                'readonly': 'false',
2743            }
2744        }
2745
2746        override = {
2747            'storage_opt': {
2748                'size': '2G',
2749                'encryption': 'aes',
2750            }
2751        }
2752
2753        actual = config.merge_service_dicts(base, override, V2_3)
2754        assert actual['storage_opt'] == {
2755            'size': '2G',
2756            'readonly': 'false',
2757            'encryption': 'aes',
2758        }
2759
2760    def test_external_volume_config(self):
2761        config_details = build_config_details({
2762            'version': '2',
2763            'services': {
2764                'bogus': {'image': 'busybox'}
2765            },
2766            'volumes': {
2767                'ext': {'external': True},
2768                'ext2': {'external': {'name': 'aliased'}}
2769            }
2770        })
2771        config_result = config.load(config_details)
2772        volumes = config_result.volumes
2773        assert 'ext' in volumes
2774        assert volumes['ext']['external'] is True
2775        assert 'ext2' in volumes
2776        assert volumes['ext2']['external']['name'] == 'aliased'
2777
2778    def test_external_volume_invalid_config(self):
2779        config_details = build_config_details({
2780            'version': '2',
2781            'services': {
2782                'bogus': {'image': 'busybox'}
2783            },
2784            'volumes': {
2785                'ext': {'external': True, 'driver': 'foo'}
2786            }
2787        })
2788        with pytest.raises(ConfigurationError):
2789            config.load(config_details)
2790
2791    def test_depends_on_orders_services(self):
2792        config_details = build_config_details({
2793            'version': '2',
2794            'services': {
2795                'one': {'image': 'busybox', 'depends_on': ['three', 'two']},
2796                'two': {'image': 'busybox', 'depends_on': ['three']},
2797                'three': {'image': 'busybox'},
2798            },
2799        })
2800        actual = config.load(config_details)
2801        assert (
2802            [service['name'] for service in actual.services] ==
2803            ['three', 'two', 'one']
2804        )
2805
2806    def test_depends_on_unknown_service_errors(self):
2807        config_details = build_config_details({
2808            'version': '2',
2809            'services': {
2810                'one': {'image': 'busybox', 'depends_on': ['three']},
2811            },
2812        })
2813        with pytest.raises(ConfigurationError) as exc:
2814            config.load(config_details)
2815        assert "Service 'one' depends on service 'three'" in exc.exconly()
2816
2817    def test_linked_service_is_undefined(self):
2818        with pytest.raises(ConfigurationError):
2819            config.load(
2820                build_config_details({
2821                    'version': '2',
2822                    'services': {
2823                        'web': {'image': 'busybox', 'links': ['db:db']},
2824                    },
2825                })
2826            )
2827
2828    def test_load_dockerfile_without_context(self):
2829        config_details = build_config_details({
2830            'version': '2',
2831            'services': {
2832                'one': {'build': {'dockerfile': 'Dockerfile.foo'}},
2833            },
2834        })
2835        with pytest.raises(ConfigurationError) as exc:
2836            config.load(config_details)
2837        assert 'has neither an image nor a build context' in exc.exconly()
2838
2839    def test_load_secrets(self):
2840        base_file = config.ConfigFile(
2841            'base.yaml',
2842            {
2843                'version': '3.1',
2844                'services': {
2845                    'web': {
2846                        'image': 'example/web',
2847                        'secrets': [
2848                            'one',
2849                            {
2850                                'source': 'source',
2851                                'target': 'target',
2852                                'uid': '100',
2853                                'gid': '200',
2854                                'mode': 0o777,
2855                            },
2856                        ],
2857                    },
2858                },
2859                'secrets': {
2860                    'one': {'file': 'secret.txt'},
2861                },
2862            })
2863        details = config.ConfigDetails('.', [base_file])
2864        service_dicts = config.load(details).services
2865        expected = [
2866            {
2867                'name': 'web',
2868                'image': 'example/web',
2869                'secrets': [
2870                    types.ServiceSecret('one', None, None, None, None, None),
2871                    types.ServiceSecret('source', 'target', '100', '200', 0o777, None),
2872                ],
2873            },
2874        ]
2875        assert service_sort(service_dicts) == service_sort(expected)
2876
2877    def test_load_secrets_multi_file(self):
2878        base_file = config.ConfigFile(
2879            'base.yaml',
2880            {
2881                'version': '3.1',
2882                'services': {
2883                    'web': {
2884                        'image': 'example/web',
2885                        'secrets': ['one'],
2886                    },
2887                },
2888                'secrets': {
2889                    'one': {'file': 'secret.txt'},
2890                },
2891            })
2892        override_file = config.ConfigFile(
2893            'base.yaml',
2894            {
2895                'version': '3.1',
2896                'services': {
2897                    'web': {
2898                        'secrets': [
2899                            {
2900                                'source': 'source',
2901                                'target': 'target',
2902                                'uid': '100',
2903                                'gid': '200',
2904                                'mode': 0o777,
2905                            },
2906                        ],
2907                    },
2908                },
2909            })
2910        details = config.ConfigDetails('.', [base_file, override_file])
2911        service_dicts = config.load(details).services
2912        expected = [
2913            {
2914                'name': 'web',
2915                'image': 'example/web',
2916                'secrets': [
2917                    types.ServiceSecret('one', None, None, None, None, None),
2918                    types.ServiceSecret('source', 'target', '100', '200', 0o777, None),
2919                ],
2920            },
2921        ]
2922        assert service_sort(service_dicts) == service_sort(expected)
2923
2924    def test_load_configs(self):
2925        base_file = config.ConfigFile(
2926            'base.yaml',
2927            {
2928                'version': '3.3',
2929                'services': {
2930                    'web': {
2931                        'image': 'example/web',
2932                        'configs': [
2933                            'one',
2934                            {
2935                                'source': 'source',
2936                                'target': 'target',
2937                                'uid': '100',
2938                                'gid': '200',
2939                                'mode': 0o777,
2940                            },
2941                        ],
2942                    },
2943                },
2944                'configs': {
2945                    'one': {'file': 'secret.txt'},
2946                },
2947            })
2948        details = config.ConfigDetails('.', [base_file])
2949        service_dicts = config.load(details).services
2950        expected = [
2951            {
2952                'name': 'web',
2953                'image': 'example/web',
2954                'configs': [
2955                    types.ServiceConfig('one', None, None, None, None, None),
2956                    types.ServiceConfig('source', 'target', '100', '200', 0o777, None),
2957                ],
2958            },
2959        ]
2960        assert service_sort(service_dicts) == service_sort(expected)
2961
2962    def test_load_configs_multi_file(self):
2963        base_file = config.ConfigFile(
2964            'base.yaml',
2965            {
2966                'version': '3.3',
2967                'services': {
2968                    'web': {
2969                        'image': 'example/web',
2970                        'configs': ['one'],
2971                    },
2972                },
2973                'configs': {
2974                    'one': {'file': 'secret.txt'},
2975                },
2976            })
2977        override_file = config.ConfigFile(
2978            'base.yaml',
2979            {
2980                'version': '3.3',
2981                'services': {
2982                    'web': {
2983                        'configs': [
2984                            {
2985                                'source': 'source',
2986                                'target': 'target',
2987                                'uid': '100',
2988                                'gid': '200',
2989                                'mode': 0o777,
2990                            },
2991                        ],
2992                    },
2993                },
2994            })
2995        details = config.ConfigDetails('.', [base_file, override_file])
2996        service_dicts = config.load(details).services
2997        expected = [
2998            {
2999                'name': 'web',
3000                'image': 'example/web',
3001                'configs': [
3002                    types.ServiceConfig('one', None, None, None, None, None),
3003                    types.ServiceConfig('source', 'target', '100', '200', 0o777, None),
3004                ],
3005            },
3006        ]
3007        assert service_sort(service_dicts) == service_sort(expected)
3008
3009    def test_config_convertible_label_types(self):
3010        config_details = build_config_details(
3011            {
3012                'version': '3.5',
3013                'services': {
3014                    'web': {
3015                        'build': {
3016                            'labels': {'testbuild': True},
3017                            'context': os.getcwd()
3018                        },
3019                        'labels': {
3020                            "key": 12345
3021                        }
3022                    },
3023                },
3024                'networks': {
3025                    'foo': {
3026                        'labels': {'network.ips.max': 1023}
3027                    }
3028                },
3029                'volumes': {
3030                    'foo': {
3031                        'labels': {'volume.is_readonly': False}
3032                    }
3033                },
3034                'secrets': {
3035                    'foo': {
3036                        'labels': {'secret.data.expires': 1546282120}
3037                    }
3038                },
3039                'configs': {
3040                    'foo': {
3041                        'labels': {'config.data.correction.value': -0.1412}
3042                    }
3043                }
3044            }
3045        )
3046        loaded_config = config.load(config_details)
3047
3048        assert loaded_config.services[0]['build']['labels'] == {'testbuild': 'True'}
3049        assert loaded_config.services[0]['labels'] == {'key': '12345'}
3050        assert loaded_config.networks['foo']['labels']['network.ips.max'] == '1023'
3051        assert loaded_config.volumes['foo']['labels']['volume.is_readonly'] == 'False'
3052        assert loaded_config.secrets['foo']['labels']['secret.data.expires'] == '1546282120'
3053        assert loaded_config.configs['foo']['labels']['config.data.correction.value'] == '-0.1412'
3054
3055    def test_config_invalid_label_types(self):
3056        config_details = build_config_details({
3057            'version': '2.3',
3058            'volumes': {
3059                'foo': {'labels': [1, 2, 3]}
3060            }
3061        })
3062        with pytest.raises(ConfigurationError):
3063            config.load(config_details)
3064
3065    def test_service_volume_invalid_config(self):
3066        config_details = build_config_details(
3067            {
3068                'version': '3.2',
3069                'services': {
3070                    'web': {
3071                        'build': {
3072                            'context': '.',
3073                            'args': None,
3074                        },
3075                        'volumes': [
3076                            {
3077                                "type": "volume",
3078                                "source": "/data",
3079                                "garbage": {
3080                                    "and": "error"
3081                                }
3082                            }
3083                        ]
3084                    }
3085                }
3086            }
3087        )
3088        with pytest.raises(ConfigurationError) as exc:
3089            config.load(config_details)
3090
3091        assert "services.web.volumes contains unsupported option: 'garbage'" in exc.exconly()
3092
3093    def test_config_valid_service_label_validation(self):
3094        config_details = build_config_details(
3095            {
3096                'version': '3.5',
3097                'services': {
3098                    'web': {
3099                        'image': 'busybox',
3100                        'labels': {
3101                            "key": "string"
3102                        }
3103                    },
3104                },
3105            }
3106        )
3107        config.load(config_details)
3108
3109    def test_config_duplicate_mount_points(self):
3110        config1 = build_config_details(
3111            {
3112                'version': '3.5',
3113                'services': {
3114                    'web': {
3115                        'image': 'busybox',
3116                        'volumes': ['/tmp/foo:/tmp/foo', '/tmp/foo:/tmp/foo:rw']
3117                    }
3118                }
3119            }
3120        )
3121
3122        config2 = build_config_details(
3123            {
3124                'version': '3.5',
3125                'services': {
3126                    'web': {
3127                        'image': 'busybox',
3128                        'volumes': ['/x:/y', '/z:/y']
3129                    }
3130                }
3131            }
3132        )
3133
3134        with self.assertRaises(ConfigurationError) as e:
3135            config.load(config1)
3136        self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % (
3137            ', '.join(['/tmp/foo:/tmp/foo:rw']*2)))
3138
3139        with self.assertRaises(ConfigurationError) as e:
3140            config.load(config2)
3141        self.assertEquals(str(e.exception), 'Duplicate mount points: [%s]' % (
3142            ', '.join(['/x:/y:rw', '/z:/y:rw'])))
3143
3144
3145class NetworkModeTest(unittest.TestCase):
3146
3147    def test_network_mode_standard(self):
3148        config_data = config.load(build_config_details({
3149            'version': '2',
3150            'services': {
3151                'web': {
3152                    'image': 'busybox',
3153                    'command': "top",
3154                    'network_mode': 'bridge',
3155                },
3156            },
3157        }))
3158
3159        assert config_data.services[0]['network_mode'] == 'bridge'
3160
3161    def test_network_mode_standard_v1(self):
3162        config_data = config.load(build_config_details({
3163            'web': {
3164                'image': 'busybox',
3165                'command': "top",
3166                'net': 'bridge',
3167            },
3168        }))
3169
3170        assert config_data.services[0]['network_mode'] == 'bridge'
3171        assert 'net' not in config_data.services[0]
3172
3173    def test_network_mode_container(self):
3174        config_data = config.load(build_config_details({
3175            'version': '2',
3176            'services': {
3177                'web': {
3178                    'image': 'busybox',
3179                    'command': "top",
3180                    'network_mode': 'container:foo',
3181                },
3182            },
3183        }))
3184
3185        assert config_data.services[0]['network_mode'] == 'container:foo'
3186
3187    def test_network_mode_container_v1(self):
3188        config_data = config.load(build_config_details({
3189            'web': {
3190                'image': 'busybox',
3191                'command': "top",
3192                'net': 'container:foo',
3193            },
3194        }))
3195
3196        assert config_data.services[0]['network_mode'] == 'container:foo'
3197
3198    def test_network_mode_service(self):
3199        config_data = config.load(build_config_details({
3200            'version': '2',
3201            'services': {
3202                'web': {
3203                    'image': 'busybox',
3204                    'command': "top",
3205                    'network_mode': 'service:foo',
3206                },
3207                'foo': {
3208                    'image': 'busybox',
3209                    'command': "top",
3210                },
3211            },
3212        }))
3213
3214        assert config_data.services[1]['network_mode'] == 'service:foo'
3215
3216    def test_network_mode_service_v1(self):
3217        config_data = config.load(build_config_details({
3218            'web': {
3219                'image': 'busybox',
3220                'command': "top",
3221                'net': 'container:foo',
3222            },
3223            'foo': {
3224                'image': 'busybox',
3225                'command': "top",
3226            },
3227        }))
3228
3229        assert config_data.services[1]['network_mode'] == 'service:foo'
3230
3231    def test_network_mode_service_nonexistent(self):
3232        with pytest.raises(ConfigurationError) as excinfo:
3233            config.load(build_config_details({
3234                'version': '2',
3235                'services': {
3236                    'web': {
3237                        'image': 'busybox',
3238                        'command': "top",
3239                        'network_mode': 'service:foo',
3240                    },
3241                },
3242            }))
3243
3244        assert "service 'foo' which is undefined" in excinfo.exconly()
3245
3246    def test_network_mode_plus_networks_is_invalid(self):
3247        with pytest.raises(ConfigurationError) as excinfo:
3248            config.load(build_config_details({
3249                'version': '2',
3250                'services': {
3251                    'web': {
3252                        'image': 'busybox',
3253                        'command': "top",
3254                        'network_mode': 'bridge',
3255                        'networks': ['front'],
3256                    },
3257                },
3258                'networks': {
3259                    'front': None,
3260                }
3261            }))
3262
3263        assert "'network_mode' and 'networks' cannot be combined" in excinfo.exconly()
3264
3265
3266class PortsTest(unittest.TestCase):
3267    INVALID_PORTS_TYPES = [
3268        {"1": "8000"},
3269        False,
3270        "8000",
3271        8000,
3272    ]
3273
3274    NON_UNIQUE_SINGLE_PORTS = [
3275        ["8000", "8000"],
3276    ]
3277
3278    INVALID_PORT_MAPPINGS = [
3279        ["8000-8004:8000-8002"],
3280        ["4242:4242-4244"],
3281    ]
3282
3283    VALID_SINGLE_PORTS = [
3284        ["8000"],
3285        ["8000/tcp"],
3286        ["8000", "9000"],
3287        [8000],
3288        [8000, 9000],
3289    ]
3290
3291    VALID_PORT_MAPPINGS = [
3292        ["8000:8050"],
3293        ["49153-49154:3002-3003"],
3294    ]
3295
3296    def test_config_invalid_ports_type_validation(self):
3297        for invalid_ports in self.INVALID_PORTS_TYPES:
3298            with pytest.raises(ConfigurationError) as exc:
3299                self.check_config({'ports': invalid_ports})
3300
3301            assert "contains an invalid type" in exc.value.msg
3302
3303    def test_config_non_unique_ports_validation(self):
3304        for invalid_ports in self.NON_UNIQUE_SINGLE_PORTS:
3305            with pytest.raises(ConfigurationError) as exc:
3306                self.check_config({'ports': invalid_ports})
3307
3308            assert "non-unique" in exc.value.msg
3309
3310    def test_config_invalid_ports_format_validation(self):
3311        for invalid_ports in self.INVALID_PORT_MAPPINGS:
3312            with pytest.raises(ConfigurationError) as exc:
3313                self.check_config({'ports': invalid_ports})
3314
3315            assert "Port ranges don't match in length" in exc.value.msg
3316
3317    def test_config_valid_ports_format_validation(self):
3318        for valid_ports in self.VALID_SINGLE_PORTS + self.VALID_PORT_MAPPINGS:
3319            self.check_config({'ports': valid_ports})
3320
3321    def test_config_invalid_expose_type_validation(self):
3322        for invalid_expose in self.INVALID_PORTS_TYPES:
3323            with pytest.raises(ConfigurationError) as exc:
3324                self.check_config({'expose': invalid_expose})
3325
3326            assert "contains an invalid type" in exc.value.msg
3327
3328    def test_config_non_unique_expose_validation(self):
3329        for invalid_expose in self.NON_UNIQUE_SINGLE_PORTS:
3330            with pytest.raises(ConfigurationError) as exc:
3331                self.check_config({'expose': invalid_expose})
3332
3333            assert "non-unique" in exc.value.msg
3334
3335    def test_config_invalid_expose_format_validation(self):
3336        # Valid port mappings ARE NOT valid 'expose' entries
3337        for invalid_expose in self.INVALID_PORT_MAPPINGS + self.VALID_PORT_MAPPINGS:
3338            with pytest.raises(ConfigurationError) as exc:
3339                self.check_config({'expose': invalid_expose})
3340
3341            assert "should be of the format" in exc.value.msg
3342
3343    def test_config_valid_expose_format_validation(self):
3344        # Valid single ports ARE valid 'expose' entries
3345        for valid_expose in self.VALID_SINGLE_PORTS:
3346            self.check_config({'expose': valid_expose})
3347
3348    def check_config(self, cfg):
3349        config.load(
3350            build_config_details({
3351                'version': '2.3',
3352                'services': {
3353                    'web': dict(image='busybox', **cfg)
3354                },
3355            }, 'working_dir', 'filename.yml')
3356        )
3357
3358
3359class SubnetTest(unittest.TestCase):
3360    INVALID_SUBNET_TYPES = [
3361        None,
3362        False,
3363        10,
3364    ]
3365
3366    INVALID_SUBNET_MAPPINGS = [
3367        "",
3368        "192.168.0.1/sdfsdfs",
3369        "192.168.0.1/",
3370        "192.168.0.1/33",
3371        "192.168.0.1/01",
3372        "192.168.0.1",
3373        "fe80:0000:0000:0000:0204:61ff:fe9d:f156/sdfsdfs",
3374        "fe80:0000:0000:0000:0204:61ff:fe9d:f156/",
3375        "fe80:0000:0000:0000:0204:61ff:fe9d:f156/129",
3376        "fe80:0000:0000:0000:0204:61ff:fe9d:f156/01",
3377        "fe80:0000:0000:0000:0204:61ff:fe9d:f156",
3378        "ge80:0000:0000:0000:0204:61ff:fe9d:f156/128",
3379        "192.168.0.1/31/31",
3380    ]
3381
3382    VALID_SUBNET_MAPPINGS = [
3383        "192.168.0.1/0",
3384        "192.168.0.1/32",
3385        "fe80:0000:0000:0000:0204:61ff:fe9d:f156/0",
3386        "fe80:0000:0000:0000:0204:61ff:fe9d:f156/128",
3387        "1:2:3:4:5:6:7:8/0",
3388        "1::/0",
3389        "1:2:3:4:5:6:7::/0",
3390        "1::8/0",
3391        "1:2:3:4:5:6::8/0",
3392        "::/0",
3393        "::8/0",
3394        "::2:3:4:5:6:7:8/0",
3395        "fe80::7:8%eth0/0",
3396        "fe80::7:8%1/0",
3397        "::255.255.255.255/0",
3398        "::ffff:255.255.255.255/0",
3399        "::ffff:0:255.255.255.255/0",
3400        "2001:db8:3:4::192.0.2.33/0",
3401        "64:ff9b::192.0.2.33/0",
3402    ]
3403
3404    def test_config_invalid_subnet_type_validation(self):
3405        for invalid_subnet in self.INVALID_SUBNET_TYPES:
3406            with pytest.raises(ConfigurationError) as exc:
3407                self.check_config(invalid_subnet)
3408
3409            assert "contains an invalid type" in exc.value.msg
3410
3411    def test_config_invalid_subnet_format_validation(self):
3412        for invalid_subnet in self.INVALID_SUBNET_MAPPINGS:
3413            with pytest.raises(ConfigurationError) as exc:
3414                self.check_config(invalid_subnet)
3415
3416            assert "should use the CIDR format" in exc.value.msg
3417
3418    def test_config_valid_subnet_format_validation(self):
3419        for valid_subnet in self.VALID_SUBNET_MAPPINGS:
3420            self.check_config(valid_subnet)
3421
3422    def check_config(self, subnet):
3423        config.load(
3424            build_config_details({
3425                'version': '3.5',
3426                'services': {
3427                    'web': {
3428                        'image': 'busybox'
3429                    }
3430                },
3431                'networks': {
3432                    'default': {
3433                        'ipam': {
3434                            'config': [
3435                                {
3436                                    'subnet': subnet
3437                                }
3438                            ],
3439                            'driver': 'default'
3440                        }
3441                    }
3442                }
3443            })
3444        )
3445
3446
3447class InterpolationTest(unittest.TestCase):
3448
3449    @mock.patch.dict(os.environ)
3450    def test_config_file_with_environment_file(self):
3451        project_dir = 'tests/fixtures/default-env-file'
3452        service_dicts = config.load(
3453            config.find(
3454                project_dir, None, Environment.from_env_file(project_dir)
3455            )
3456        ).services
3457
3458        assert service_dicts[0] == {
3459            'name': 'web',
3460            'image': 'alpine:latest',
3461            'ports': [
3462                types.ServicePort.parse('5643')[0],
3463                types.ServicePort.parse('9999')[0]
3464            ],
3465            'command': 'true'
3466        }
3467
3468    @mock.patch.dict(os.environ)
3469    def test_config_file_with_environment_variable(self):
3470        project_dir = 'tests/fixtures/environment-interpolation'
3471        os.environ.update(
3472            IMAGE="busybox",
3473            HOST_PORT="80",
3474            LABEL_VALUE="myvalue",
3475        )
3476
3477        service_dicts = config.load(
3478            config.find(
3479                project_dir, None, Environment.from_env_file(project_dir)
3480            )
3481        ).services
3482
3483        assert service_dicts == [
3484            {
3485                'name': 'web',
3486                'image': 'busybox',
3487                'ports': types.ServicePort.parse('80:8000'),
3488                'labels': {'mylabel': 'myvalue'},
3489                'hostname': 'host-',
3490                'command': '${ESCAPED}',
3491            }
3492        ]
3493
3494    @mock.patch.dict(os.environ)
3495    def test_config_file_with_environment_variable_with_defaults(self):
3496        project_dir = 'tests/fixtures/environment-interpolation-with-defaults'
3497        os.environ.update(
3498            IMAGE="busybox",
3499        )
3500
3501        service_dicts = config.load(
3502            config.find(
3503                project_dir, None, Environment.from_env_file(project_dir)
3504            )
3505        ).services
3506
3507        assert service_dicts == [
3508            {
3509                'name': 'web',
3510                'image': 'busybox',
3511                'ports': types.ServicePort.parse('80:8000'),
3512                'hostname': 'host-',
3513            }
3514        ]
3515
3516    @mock.patch.dict(os.environ)
3517    def test_unset_variable_produces_warning(self):
3518        os.environ.pop('FOO', None)
3519        os.environ.pop('BAR', None)
3520        config_details = build_config_details(
3521            {
3522                'web': {
3523                    'image': '${FOO}',
3524                    'command': '${BAR}',
3525                    'container_name': '${BAR}',
3526                },
3527            },
3528            '.',
3529            None,
3530        )
3531
3532        with mock.patch('compose.config.environment.log') as log:
3533            config.load(config_details)
3534
3535            assert 2 == log.warn.call_count
3536            warnings = sorted(args[0][0] for args in log.warn.call_args_list)
3537            assert 'BAR' in warnings[0]
3538            assert 'FOO' in warnings[1]
3539
3540    def test_compatibility_mode_warnings(self):
3541        config_details = build_config_details({
3542            'version': '3.5',
3543            'services': {
3544                'web': {
3545                    'deploy': {
3546                        'labels': ['abc=def'],
3547                        'endpoint_mode': 'dnsrr',
3548                        'update_config': {'max_failure_ratio': 0.4},
3549                        'placement': {'constraints': ['node.id==deadbeef']},
3550                        'resources': {
3551                            'reservations': {'cpus': '0.2'}
3552                        },
3553                        'restart_policy': {
3554                            'delay': '2s',
3555                            'window': '12s'
3556                        }
3557                    },
3558                    'image': 'busybox'
3559                }
3560            }
3561        })
3562
3563        with mock.patch('compose.config.config.log') as log:
3564            config.load(config_details, compatibility=True)
3565
3566        assert log.warn.call_count == 1
3567        warn_message = log.warn.call_args[0][0]
3568        assert warn_message.startswith(
3569            'The following deploy sub-keys are not supported in compatibility mode'
3570        )
3571        assert 'labels' in warn_message
3572        assert 'endpoint_mode' in warn_message
3573        assert 'update_config' in warn_message
3574        assert 'placement' in warn_message
3575        assert 'resources.reservations.cpus' in warn_message
3576        assert 'restart_policy.delay' in warn_message
3577        assert 'restart_policy.window' in warn_message
3578
3579    def test_compatibility_mode_load(self):
3580        config_details = build_config_details({
3581            'version': '3.5',
3582            'services': {
3583                'foo': {
3584                    'image': 'alpine:3.7',
3585                    'deploy': {
3586                        'replicas': 3,
3587                        'restart_policy': {
3588                            'condition': 'any',
3589                            'max_attempts': 7,
3590                        },
3591                        'resources': {
3592                            'limits': {'memory': '300M', 'cpus': '0.7'},
3593                            'reservations': {'memory': '100M'},
3594                        },
3595                    },
3596                    'credential_spec': {
3597                        'file': 'spec.json'
3598                    },
3599                },
3600            },
3601        })
3602
3603        with mock.patch('compose.config.config.log') as log:
3604            cfg = config.load(config_details, compatibility=True)
3605
3606        assert log.warn.call_count == 0
3607
3608        service_dict = cfg.services[0]
3609        assert service_dict == {
3610            'image': 'alpine:3.7',
3611            'scale': 3,
3612            'restart': {'MaximumRetryCount': 7, 'Name': 'always'},
3613            'mem_limit': '300M',
3614            'mem_reservation': '100M',
3615            'cpus': 0.7,
3616            'name': 'foo',
3617            'security_opt': ['credentialspec=file://spec.json'],
3618        }
3619
3620    @mock.patch.dict(os.environ)
3621    def test_invalid_interpolation(self):
3622        with pytest.raises(config.ConfigurationError) as cm:
3623            config.load(
3624                build_config_details(
3625                    {'web': {'image': '${'}},
3626                    'working_dir',
3627                    'filename.yml'
3628                )
3629            )
3630
3631        assert 'Invalid' in cm.value.msg
3632        assert 'for "image" option' in cm.value.msg
3633        assert 'in service "web"' in cm.value.msg
3634        assert '"${"' in cm.value.msg
3635
3636    @mock.patch.dict(os.environ)
3637    def test_interpolation_secrets_section(self):
3638        os.environ['FOO'] = 'baz.bar'
3639        config_dict = config.load(build_config_details({
3640            'version': '3.1',
3641            'secrets': {
3642                'secretdata': {
3643                    'external': {'name': '$FOO'}
3644                }
3645            }
3646        }))
3647        assert config_dict.secrets == {
3648            'secretdata': {
3649                'external': {'name': 'baz.bar'},
3650                'name': 'baz.bar'
3651            }
3652        }
3653
3654    @mock.patch.dict(os.environ)
3655    def test_interpolation_configs_section(self):
3656        os.environ['FOO'] = 'baz.bar'
3657        config_dict = config.load(build_config_details({
3658            'version': '3.3',
3659            'configs': {
3660                'configdata': {
3661                    'external': {'name': '$FOO'}
3662                }
3663            }
3664        }))
3665        assert config_dict.configs == {
3666            'configdata': {
3667                'external': {'name': 'baz.bar'},
3668                'name': 'baz.bar'
3669            }
3670        }
3671
3672
3673class VolumeConfigTest(unittest.TestCase):
3674
3675    def test_no_binding(self):
3676        d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.')
3677        assert d['volumes'] == ['/data']
3678
3679    @mock.patch.dict(os.environ)
3680    def test_volume_binding_with_environment_variable(self):
3681        os.environ['VOLUME_PATH'] = '/host/path'
3682
3683        d = config.load(
3684            build_config_details(
3685                {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}},
3686                '.',
3687                None,
3688            )
3689        ).services[0]
3690        assert d['volumes'] == [VolumeSpec.parse('/host/path:/container/path')]
3691
3692    @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths')
3693    def test_volumes_order_is_preserved(self):
3694        volumes = ['/{0}:/{0}'.format(i) for i in range(0, 6)]
3695        shuffle(volumes)
3696        cfg = make_service_dict('foo', {'build': '.', 'volumes': volumes})
3697        assert cfg['volumes'] == volumes
3698
3699    @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths')
3700    @mock.patch.dict(os.environ)
3701    def test_volume_binding_with_home(self):
3702        os.environ['HOME'] = '/home/user'
3703        d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.')
3704        assert d['volumes'] == ['/home/user:/container/path']
3705
3706    def test_name_does_not_expand(self):
3707        d = make_service_dict('foo', {'build': '.', 'volumes': ['mydatavolume:/data']}, working_dir='.')
3708        assert d['volumes'] == ['mydatavolume:/data']
3709
3710    def test_absolute_posix_path_does_not_expand(self):
3711        d = make_service_dict('foo', {'build': '.', 'volumes': ['/var/lib/data:/data']}, working_dir='.')
3712        assert d['volumes'] == ['/var/lib/data:/data']
3713
3714    def test_absolute_windows_path_does_not_expand(self):
3715        d = make_service_dict('foo', {'build': '.', 'volumes': ['c:\\data:/data']}, working_dir='.')
3716        assert d['volumes'] == ['c:\\data:/data']
3717
3718    @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths')
3719    def test_relative_path_does_expand_posix(self):
3720        d = make_service_dict(
3721            'foo',
3722            {'build': '.', 'volumes': ['./data:/data']},
3723            working_dir='/home/me/myproject')
3724        assert d['volumes'] == ['/home/me/myproject/data:/data']
3725
3726        d = make_service_dict(
3727            'foo',
3728            {'build': '.', 'volumes': ['.:/data']},
3729            working_dir='/home/me/myproject')
3730        assert d['volumes'] == ['/home/me/myproject:/data']
3731
3732        d = make_service_dict(
3733            'foo',
3734            {'build': '.', 'volumes': ['../otherproject:/data']},
3735            working_dir='/home/me/myproject')
3736        assert d['volumes'] == ['/home/me/otherproject:/data']
3737
3738    @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths')
3739    def test_relative_path_does_expand_windows(self):
3740        d = make_service_dict(
3741            'foo',
3742            {'build': '.', 'volumes': ['./data:/data']},
3743            working_dir='c:\\Users\\me\\myproject')
3744        assert d['volumes'] == ['c:\\Users\\me\\myproject\\data:/data']
3745
3746        d = make_service_dict(
3747            'foo',
3748            {'build': '.', 'volumes': ['.:/data']},
3749            working_dir='c:\\Users\\me\\myproject')
3750        assert d['volumes'] == ['c:\\Users\\me\\myproject:/data']
3751
3752        d = make_service_dict(
3753            'foo',
3754            {'build': '.', 'volumes': ['../otherproject:/data']},
3755            working_dir='c:\\Users\\me\\myproject')
3756        assert d['volumes'] == ['c:\\Users\\me\\otherproject:/data']
3757
3758    @mock.patch.dict(os.environ)
3759    def test_home_directory_with_driver_does_not_expand(self):
3760        os.environ['NAME'] = 'surprise!'
3761        d = make_service_dict('foo', {
3762            'build': '.',
3763            'volumes': ['~:/data'],
3764            'volume_driver': 'foodriver',
3765        }, working_dir='.')
3766        assert d['volumes'] == ['~:/data']
3767
3768    def test_volume_path_with_non_ascii_directory(self):
3769        volume = u'/Füü/data:/data'
3770        container_path = config.resolve_volume_path(".", volume)
3771        assert container_path == volume
3772
3773
3774class MergePathMappingTest(object):
3775    config_name = ""
3776
3777    def test_empty(self):
3778        service_dict = config.merge_service_dicts({}, {}, DEFAULT_VERSION)
3779        assert self.config_name not in service_dict
3780
3781    def test_no_override(self):
3782        service_dict = config.merge_service_dicts(
3783            {self.config_name: ['/foo:/code', '/data']},
3784            {},
3785            DEFAULT_VERSION)
3786        assert set(service_dict[self.config_name]) == set(['/foo:/code', '/data'])
3787
3788    def test_no_base(self):
3789        service_dict = config.merge_service_dicts(
3790            {},
3791            {self.config_name: ['/bar:/code']},
3792            DEFAULT_VERSION)
3793        assert set(service_dict[self.config_name]) == set(['/bar:/code'])
3794
3795    def test_override_explicit_path(self):
3796        service_dict = config.merge_service_dicts(
3797            {self.config_name: ['/foo:/code', '/data']},
3798            {self.config_name: ['/bar:/code']},
3799            DEFAULT_VERSION)
3800        assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data'])
3801
3802    def test_add_explicit_path(self):
3803        service_dict = config.merge_service_dicts(
3804            {self.config_name: ['/foo:/code', '/data']},
3805            {self.config_name: ['/bar:/code', '/quux:/data']},
3806            DEFAULT_VERSION)
3807        assert set(service_dict[self.config_name]) == set(['/bar:/code', '/quux:/data'])
3808
3809    def test_remove_explicit_path(self):
3810        service_dict = config.merge_service_dicts(
3811            {self.config_name: ['/foo:/code', '/quux:/data']},
3812            {self.config_name: ['/bar:/code', '/data']},
3813            DEFAULT_VERSION)
3814        assert set(service_dict[self.config_name]) == set(['/bar:/code', '/data'])
3815
3816
3817class MergeVolumesTest(unittest.TestCase, MergePathMappingTest):
3818    config_name = 'volumes'
3819
3820
3821class MergeDevicesTest(unittest.TestCase, MergePathMappingTest):
3822    config_name = 'devices'
3823
3824
3825class BuildOrImageMergeTest(unittest.TestCase):
3826
3827    def test_merge_build_or_image_no_override(self):
3828        assert config.merge_service_dicts({'build': '.'}, {}, V1) == {'build': '.'}
3829
3830        assert config.merge_service_dicts({'image': 'redis'}, {}, V1) == {'image': 'redis'}
3831
3832    def test_merge_build_or_image_override_with_same(self):
3833        assert config.merge_service_dicts({'build': '.'}, {'build': './web'}, V1) == {'build': './web'}
3834
3835        assert config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}, V1) == {
3836            'image': 'postgres'
3837        }
3838
3839    def test_merge_build_or_image_override_with_other(self):
3840        assert config.merge_service_dicts({'build': '.'}, {'image': 'redis'}, V1) == {
3841            'image': 'redis'
3842        }
3843
3844        assert config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1) == {'build': '.'}
3845
3846
3847class MergeListsTest(object):
3848    config_name = ""
3849    base_config = []
3850    override_config = []
3851
3852    def merged_config(self):
3853        return set(self.base_config) | set(self.override_config)
3854
3855    def test_empty(self):
3856        assert self.config_name not in config.merge_service_dicts({}, {}, DEFAULT_VERSION)
3857
3858    def test_no_override(self):
3859        service_dict = config.merge_service_dicts(
3860            {self.config_name: self.base_config},
3861            {},
3862            DEFAULT_VERSION)
3863        assert set(service_dict[self.config_name]) == set(self.base_config)
3864
3865    def test_no_base(self):
3866        service_dict = config.merge_service_dicts(
3867            {},
3868            {self.config_name: self.base_config},
3869            DEFAULT_VERSION)
3870        assert set(service_dict[self.config_name]) == set(self.base_config)
3871
3872    def test_add_item(self):
3873        service_dict = config.merge_service_dicts(
3874            {self.config_name: self.base_config},
3875            {self.config_name: self.override_config},
3876            DEFAULT_VERSION)
3877        assert set(service_dict[self.config_name]) == set(self.merged_config())
3878
3879
3880class MergePortsTest(unittest.TestCase, MergeListsTest):
3881    config_name = 'ports'
3882    base_config = ['10:8000', '9000']
3883    override_config = ['20:8000']
3884
3885    def merged_config(self):
3886        return self.convert(self.base_config) | self.convert(self.override_config)
3887
3888    def convert(self, port_config):
3889        return set(config.merge_service_dicts(
3890            {self.config_name: port_config},
3891            {self.config_name: []},
3892            DEFAULT_VERSION
3893        )[self.config_name])
3894
3895    def test_duplicate_port_mappings(self):
3896        service_dict = config.merge_service_dicts(
3897            {self.config_name: self.base_config},
3898            {self.config_name: self.base_config},
3899            DEFAULT_VERSION
3900        )
3901        assert set(service_dict[self.config_name]) == self.convert(self.base_config)
3902
3903    def test_no_override(self):
3904        service_dict = config.merge_service_dicts(
3905            {self.config_name: self.base_config},
3906            {},
3907            DEFAULT_VERSION)
3908        assert set(service_dict[self.config_name]) == self.convert(self.base_config)
3909
3910    def test_no_base(self):
3911        service_dict = config.merge_service_dicts(
3912            {},
3913            {self.config_name: self.base_config},
3914            DEFAULT_VERSION)
3915        assert set(service_dict[self.config_name]) == self.convert(self.base_config)
3916
3917
3918class MergeNetworksTest(unittest.TestCase, MergeListsTest):
3919    config_name = 'networks'
3920    base_config = {'default': {'aliases': ['foo.bar', 'foo.baz']}}
3921    override_config = {'default': {'ipv4_address': '123.234.123.234'}}
3922
3923    def test_no_network_overrides(self):
3924        service_dict = config.merge_service_dicts(
3925            {self.config_name: self.base_config},
3926            {self.config_name: self.override_config},
3927            DEFAULT_VERSION)
3928        assert service_dict[self.config_name] == {
3929            'default': {
3930                'aliases': ['foo.bar', 'foo.baz'],
3931                'ipv4_address': '123.234.123.234'
3932            }
3933        }
3934
3935    def test_network_has_none_value(self):
3936        service_dict = config.merge_service_dicts(
3937            {self.config_name: {
3938                'default': None
3939            }},
3940            {self.config_name: {
3941                'default': {
3942                    'aliases': []
3943                }
3944            }},
3945            DEFAULT_VERSION)
3946
3947        assert service_dict[self.config_name] == {
3948            'default': {
3949                'aliases': []
3950            }
3951        }
3952
3953    def test_all_properties(self):
3954        service_dict = config.merge_service_dicts(
3955            {self.config_name: {
3956                'default': {
3957                    'aliases': ['foo.bar', 'foo.baz'],
3958                    'link_local_ips': ['192.168.1.10', '192.168.1.11'],
3959                    'ipv4_address': '111.111.111.111',
3960                    'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-first'
3961                }
3962            }},
3963            {self.config_name: {
3964                'default': {
3965                    'aliases': ['foo.baz', 'foo.baz2'],
3966                    'link_local_ips': ['192.168.1.11', '192.168.1.12'],
3967                    'ipv4_address': '123.234.123.234',
3968                    'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-second'
3969                }
3970            }},
3971            DEFAULT_VERSION)
3972
3973        assert service_dict[self.config_name] == {
3974            'default': {
3975                'aliases': ['foo.bar', 'foo.baz', 'foo.baz2'],
3976                'link_local_ips': ['192.168.1.10', '192.168.1.11', '192.168.1.12'],
3977                'ipv4_address': '123.234.123.234',
3978                'ipv6_address': 'FE80:CD00:0000:0CDE:1257:0000:211E:729C-second'
3979            }
3980        }
3981
3982    def test_no_network_name_overrides(self):
3983        service_dict = config.merge_service_dicts(
3984            {
3985                self.config_name: {
3986                    'default': {
3987                        'aliases': ['foo.bar', 'foo.baz'],
3988                        'ipv4_address': '123.234.123.234'
3989                    }
3990                }
3991            },
3992            {
3993                self.config_name: {
3994                    'another_network': {
3995                        'ipv4_address': '123.234.123.234'
3996                    }
3997                }
3998            },
3999            DEFAULT_VERSION)
4000        assert service_dict[self.config_name] == {
4001            'default': {
4002                'aliases': ['foo.bar', 'foo.baz'],
4003                'ipv4_address': '123.234.123.234'
4004            },
4005            'another_network': {
4006                'ipv4_address': '123.234.123.234'
4007            }
4008        }
4009
4010
4011class MergeStringsOrListsTest(unittest.TestCase):
4012
4013    def test_no_override(self):
4014        service_dict = config.merge_service_dicts(
4015            {'dns': '8.8.8.8'},
4016            {},
4017            DEFAULT_VERSION)
4018        assert set(service_dict['dns']) == set(['8.8.8.8'])
4019
4020    def test_no_base(self):
4021        service_dict = config.merge_service_dicts(
4022            {},
4023            {'dns': '8.8.8.8'},
4024            DEFAULT_VERSION)
4025        assert set(service_dict['dns']) == set(['8.8.8.8'])
4026
4027    def test_add_string(self):
4028        service_dict = config.merge_service_dicts(
4029            {'dns': ['8.8.8.8']},
4030            {'dns': '9.9.9.9'},
4031            DEFAULT_VERSION)
4032        assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9'])
4033
4034    def test_add_list(self):
4035        service_dict = config.merge_service_dicts(
4036            {'dns': '8.8.8.8'},
4037            {'dns': ['9.9.9.9']},
4038            DEFAULT_VERSION)
4039        assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9'])
4040
4041
4042class MergeLabelsTest(unittest.TestCase):
4043
4044    def test_empty(self):
4045        assert 'labels' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION)
4046
4047    def test_no_override(self):
4048        service_dict = config.merge_service_dicts(
4049            make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'),
4050            make_service_dict('foo', {'build': '.'}, 'tests/'),
4051            DEFAULT_VERSION)
4052        assert service_dict['labels'] == {'foo': '1', 'bar': ''}
4053
4054    def test_no_base(self):
4055        service_dict = config.merge_service_dicts(
4056            make_service_dict('foo', {'build': '.'}, 'tests/'),
4057            make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'),
4058            DEFAULT_VERSION)
4059        assert service_dict['labels'] == {'foo': '2'}
4060
4061    def test_override_explicit_value(self):
4062        service_dict = config.merge_service_dicts(
4063            make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'),
4064            make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'),
4065            DEFAULT_VERSION)
4066        assert service_dict['labels'] == {'foo': '2', 'bar': ''}
4067
4068    def test_add_explicit_value(self):
4069        service_dict = config.merge_service_dicts(
4070            make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'),
4071            make_service_dict('foo', {'build': '.', 'labels': ['bar=2']}, 'tests/'),
4072            DEFAULT_VERSION)
4073        assert service_dict['labels'] == {'foo': '1', 'bar': '2'}
4074
4075    def test_remove_explicit_value(self):
4076        service_dict = config.merge_service_dicts(
4077            make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar=2']}, 'tests/'),
4078            make_service_dict('foo', {'build': '.', 'labels': ['bar']}, 'tests/'),
4079            DEFAULT_VERSION)
4080        assert service_dict['labels'] == {'foo': '1', 'bar': ''}
4081
4082
4083class MergeBuildTest(unittest.TestCase):
4084    def test_full(self):
4085        base = {
4086            'context': '.',
4087            'dockerfile': 'Dockerfile',
4088            'args': {
4089                'x': '1',
4090                'y': '2',
4091            },
4092            'cache_from': ['ubuntu'],
4093            'labels': ['com.docker.compose.test=true']
4094        }
4095
4096        override = {
4097            'context': './prod',
4098            'dockerfile': 'Dockerfile.prod',
4099            'args': ['x=12'],
4100            'cache_from': ['debian'],
4101            'labels': {
4102                'com.docker.compose.test': 'false',
4103                'com.docker.compose.prod': 'true',
4104            }
4105        }
4106
4107        result = config.merge_build(None, {'build': base}, {'build': override})
4108        assert result['context'] == override['context']
4109        assert result['dockerfile'] == override['dockerfile']
4110        assert result['args'] == {'x': '12', 'y': '2'}
4111        assert set(result['cache_from']) == set(['ubuntu', 'debian'])
4112        assert result['labels'] == override['labels']
4113
4114    def test_empty_override(self):
4115        base = {
4116            'context': '.',
4117            'dockerfile': 'Dockerfile',
4118            'args': {
4119                'x': '1',
4120                'y': '2',
4121            },
4122            'cache_from': ['ubuntu'],
4123            'labels': {
4124                'com.docker.compose.test': 'true'
4125            }
4126        }
4127
4128        override = {}
4129
4130        result = config.merge_build(None, {'build': base}, {'build': override})
4131        assert result == base
4132
4133    def test_empty_base(self):
4134        base = {}
4135
4136        override = {
4137            'context': './prod',
4138            'dockerfile': 'Dockerfile.prod',
4139            'args': {'x': '12'},
4140            'cache_from': ['debian'],
4141            'labels': {
4142                'com.docker.compose.test': 'false',
4143                'com.docker.compose.prod': 'true',
4144            }
4145        }
4146
4147        result = config.merge_build(None, {'build': base}, {'build': override})
4148        assert result == override
4149
4150
4151class MemoryOptionsTest(unittest.TestCase):
4152
4153    def test_validation_fails_with_just_memswap_limit(self):
4154        """
4155        When you set a 'memswap_limit' it is invalid config unless you also set
4156        a mem_limit
4157        """
4158        with pytest.raises(ConfigurationError) as excinfo:
4159            config.load(
4160                build_config_details(
4161                    {
4162                        'foo': {'image': 'busybox', 'memswap_limit': 2000000},
4163                    },
4164                    'tests/fixtures/extends',
4165                    'filename.yml'
4166                )
4167            )
4168
4169        assert "foo.memswap_limit is invalid: when defining " \
4170               "'memswap_limit' you must set 'mem_limit' as well" \
4171            in excinfo.exconly()
4172
4173    def test_validation_with_correct_memswap_values(self):
4174        service_dict = config.load(
4175            build_config_details(
4176                {'foo': {'image': 'busybox', 'mem_limit': 1000000, 'memswap_limit': 2000000}},
4177                'tests/fixtures/extends',
4178                'common.yml'
4179            )
4180        ).services
4181        assert service_dict[0]['memswap_limit'] == 2000000
4182
4183    def test_memswap_can_be_a_string(self):
4184        service_dict = config.load(
4185            build_config_details(
4186                {'foo': {'image': 'busybox', 'mem_limit': "1G", 'memswap_limit': "512M"}},
4187                'tests/fixtures/extends',
4188                'common.yml'
4189            )
4190        ).services
4191        assert service_dict[0]['memswap_limit'] == "512M"
4192
4193
4194class EnvTest(unittest.TestCase):
4195
4196    def test_parse_environment_as_list(self):
4197        environment = [
4198            'NORMAL=F1',
4199            'CONTAINS_EQUALS=F=2',
4200            'TRAILING_EQUALS=',
4201        ]
4202        assert config.parse_environment(environment) == {
4203            'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''
4204        }
4205
4206    def test_parse_environment_as_dict(self):
4207        environment = {
4208            'NORMAL': 'F1',
4209            'CONTAINS_EQUALS': 'F=2',
4210            'TRAILING_EQUALS': None,
4211        }
4212        assert config.parse_environment(environment) == environment
4213
4214    def test_parse_environment_invalid(self):
4215        with pytest.raises(ConfigurationError):
4216            config.parse_environment('a=b')
4217
4218    def test_parse_environment_empty(self):
4219        assert config.parse_environment(None) == {}
4220
4221    @mock.patch.dict(os.environ)
4222    def test_resolve_environment(self):
4223        os.environ['FILE_DEF'] = 'E1'
4224        os.environ['FILE_DEF_EMPTY'] = 'E2'
4225        os.environ['ENV_DEF'] = 'E3'
4226
4227        service_dict = {
4228            'build': '.',
4229            'environment': {
4230                'FILE_DEF': 'F1',
4231                'FILE_DEF_EMPTY': '',
4232                'ENV_DEF': None,
4233                'NO_DEF': None
4234            },
4235        }
4236        assert resolve_environment(
4237            service_dict, Environment.from_env_file(None)
4238        ) == {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': None}
4239
4240    def test_resolve_environment_from_env_file(self):
4241        assert resolve_environment({'env_file': ['tests/fixtures/env/one.env']}) == {
4242            'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'
4243        }
4244
4245    def test_environment_overrides_env_file(self):
4246        assert resolve_environment({
4247            'environment': {'FOO': 'baz'},
4248            'env_file': ['tests/fixtures/env/one.env'],
4249        }) == {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz'}
4250
4251    def test_resolve_environment_with_multiple_env_files(self):
4252        service_dict = {
4253            'env_file': [
4254                'tests/fixtures/env/one.env',
4255                'tests/fixtures/env/two.env'
4256            ]
4257        }
4258        assert resolve_environment(service_dict) == {
4259            'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'
4260        }
4261
4262    def test_resolve_environment_nonexistent_file(self):
4263        with pytest.raises(ConfigurationError) as exc:
4264            config.load(build_config_details(
4265                {'foo': {'image': 'example', 'env_file': 'nonexistent.env'}},
4266                working_dir='tests/fixtures/env'))
4267
4268        assert 'Couldn\'t find env file' in exc.exconly()
4269        assert 'nonexistent.env' in exc.exconly()
4270
4271    @mock.patch.dict(os.environ)
4272    def test_resolve_environment_from_env_file_with_empty_values(self):
4273        os.environ['FILE_DEF'] = 'E1'
4274        os.environ['FILE_DEF_EMPTY'] = 'E2'
4275        os.environ['ENV_DEF'] = 'E3'
4276        assert resolve_environment(
4277            {'env_file': ['tests/fixtures/env/resolve.env']},
4278            Environment.from_env_file(None)
4279        ) == {
4280            'FILE_DEF': u'bär',
4281            'FILE_DEF_EMPTY': '',
4282            'ENV_DEF': 'E3',
4283            'NO_DEF': None
4284        }
4285
4286    @mock.patch.dict(os.environ)
4287    def test_resolve_build_args(self):
4288        os.environ['env_arg'] = 'value2'
4289
4290        build = {
4291            'context': '.',
4292            'args': {
4293                'arg1': 'value1',
4294                'empty_arg': '',
4295                'env_arg': None,
4296                'no_env': None
4297            }
4298        }
4299        assert resolve_build_args(build['args'], Environment.from_env_file(build['context'])) == {
4300            'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': None
4301        }
4302
4303    @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
4304    @mock.patch.dict(os.environ)
4305    def test_resolve_path(self):
4306        os.environ['HOSTENV'] = '/tmp'
4307        os.environ['CONTAINERENV'] = '/host/tmp'
4308
4309        service_dict = config.load(
4310            build_config_details(
4311                {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}},
4312                "tests/fixtures/env",
4313            )
4314        ).services[0]
4315        assert set(service_dict['volumes']) == set([VolumeSpec.parse('/tmp:/host/tmp')])
4316
4317        service_dict = config.load(
4318            build_config_details(
4319                {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}},
4320                "tests/fixtures/env",
4321            )
4322        ).services[0]
4323        assert set(service_dict['volumes']) == set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')])
4324
4325
4326def load_from_filename(filename, override_dir=None):
4327    return config.load(
4328        config.find('.', [filename], Environment.from_env_file('.'), override_dir=override_dir)
4329    ).services
4330
4331
4332class ExtendsTest(unittest.TestCase):
4333
4334    def test_extends(self):
4335        service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml')
4336
4337        assert service_sort(service_dicts) == service_sort([
4338            {
4339                'name': 'mydb',
4340                'image': 'busybox',
4341                'command': 'top',
4342            },
4343            {
4344                'name': 'myweb',
4345                'image': 'busybox',
4346                'command': 'top',
4347                'network_mode': 'bridge',
4348                'links': ['mydb:db'],
4349                'environment': {
4350                    "FOO": "1",
4351                    "BAR": "2",
4352                    "BAZ": "2",
4353                },
4354            }
4355        ])
4356
4357    def test_merging_env_labels_ulimits(self):
4358        service_dicts = load_from_filename('tests/fixtures/extends/common-env-labels-ulimits.yml')
4359
4360        assert service_sort(service_dicts) == service_sort([
4361            {
4362                'name': 'web',
4363                'image': 'busybox',
4364                'command': '/bin/true',
4365                'network_mode': 'host',
4366                'environment': {
4367                    "FOO": "2",
4368                    "BAR": "1",
4369                    "BAZ": "3",
4370                },
4371                'labels': {'label': 'one'},
4372                'ulimits': {'nproc': 65535, 'memlock': {'soft': 1024, 'hard': 2048}}
4373            }
4374        ])
4375
4376    def test_nested(self):
4377        service_dicts = load_from_filename('tests/fixtures/extends/nested.yml')
4378
4379        assert service_dicts == [
4380            {
4381                'name': 'myweb',
4382                'image': 'busybox',
4383                'command': '/bin/true',
4384                'network_mode': 'host',
4385                'environment': {
4386                    "FOO": "2",
4387                    "BAR": "2",
4388                },
4389            },
4390        ]
4391
4392    def test_self_referencing_file(self):
4393        """
4394        We specify a 'file' key that is the filename we're already in.
4395        """
4396        service_dicts = load_from_filename('tests/fixtures/extends/specify-file-as-self.yml')
4397        assert service_sort(service_dicts) == service_sort([
4398            {
4399                'environment':
4400                {
4401                    'YEP': '1', 'BAR': '1', 'BAZ': '3'
4402                },
4403                'image': 'busybox',
4404                'name': 'myweb'
4405            },
4406            {
4407                'environment':
4408                {'YEP': '1'},
4409                'image': 'busybox',
4410                'name': 'otherweb'
4411            },
4412            {
4413                'environment':
4414                {'YEP': '1', 'BAZ': '3'},
4415                'image': 'busybox',
4416                'name': 'web'
4417            }
4418        ])
4419
4420    def test_circular(self):
4421        with pytest.raises(config.CircularReference) as exc:
4422            load_from_filename('tests/fixtures/extends/circle-1.yml')
4423
4424        path = [
4425            (os.path.basename(filename), service_name)
4426            for (filename, service_name) in exc.value.trail
4427        ]
4428        expected = [
4429            ('circle-1.yml', 'web'),
4430            ('circle-2.yml', 'other'),
4431            ('circle-1.yml', 'web'),
4432        ]
4433        assert path == expected
4434
4435    def test_extends_validation_empty_dictionary(self):
4436        with pytest.raises(ConfigurationError) as excinfo:
4437            config.load(
4438                build_config_details(
4439                    {
4440                        'web': {'image': 'busybox', 'extends': {}},
4441                    },
4442                    'tests/fixtures/extends',
4443                    'filename.yml'
4444                )
4445            )
4446
4447        assert 'service' in excinfo.exconly()
4448
4449    def test_extends_validation_missing_service_key(self):
4450        with pytest.raises(ConfigurationError) as excinfo:
4451            config.load(
4452                build_config_details(
4453                    {
4454                        'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}},
4455                    },
4456                    'tests/fixtures/extends',
4457                    'filename.yml'
4458                )
4459            )
4460
4461        assert "'service' is a required property" in excinfo.exconly()
4462
4463    def test_extends_validation_invalid_key(self):
4464        with pytest.raises(ConfigurationError) as excinfo:
4465            config.load(
4466                build_config_details(
4467                    {
4468                        'web': {
4469                            'image': 'busybox',
4470                            'extends': {
4471                                'file': 'common.yml',
4472                                'service': 'web',
4473                                'rogue_key': 'is not allowed'
4474                            }
4475                        },
4476                    },
4477                    'tests/fixtures/extends',
4478                    'filename.yml'
4479                )
4480            )
4481
4482        assert "web.extends contains unsupported option: 'rogue_key'" \
4483            in excinfo.exconly()
4484
4485    def test_extends_validation_sub_property_key(self):
4486        with pytest.raises(ConfigurationError) as excinfo:
4487            config.load(
4488                build_config_details(
4489                    {
4490                        'web': {
4491                            'image': 'busybox',
4492                            'extends': {
4493                                'file': 1,
4494                                'service': 'web',
4495                            }
4496                        },
4497                    },
4498                    'tests/fixtures/extends',
4499                    'filename.yml'
4500                )
4501            )
4502
4503        assert "web.extends.file contains 1, which is an invalid type, it should be a string" \
4504            in excinfo.exconly()
4505
4506    def test_extends_validation_no_file_key_no_filename_set(self):
4507        dictionary = {'extends': {'service': 'web'}}
4508
4509        with pytest.raises(ConfigurationError) as excinfo:
4510            make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends')
4511
4512        assert 'file' in excinfo.exconly()
4513
4514    def test_extends_validation_valid_config(self):
4515        service = config.load(
4516            build_config_details(
4517                {
4518                    'web': {'image': 'busybox', 'extends': {'service': 'web', 'file': 'common.yml'}},
4519                },
4520                'tests/fixtures/extends',
4521                'common.yml'
4522            )
4523        ).services
4524
4525        assert len(service) == 1
4526        assert isinstance(service[0], dict)
4527        assert service[0]['command'] == "/bin/true"
4528
4529    def test_extended_service_with_invalid_config(self):
4530        with pytest.raises(ConfigurationError) as exc:
4531            load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml')
4532        assert (
4533            "myweb has neither an image nor a build context specified" in
4534            exc.exconly()
4535        )
4536
4537    def test_extended_service_with_valid_config(self):
4538        service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml')
4539        assert service[0]['command'] == "top"
4540
4541    def test_extends_file_defaults_to_self(self):
4542        """
4543        Test not specifying a file in our extends options that the
4544        config is valid and correctly extends from itself.
4545        """
4546        service_dicts = load_from_filename('tests/fixtures/extends/no-file-specified.yml')
4547        assert service_sort(service_dicts) == service_sort([
4548            {
4549                'name': 'myweb',
4550                'image': 'busybox',
4551                'environment': {
4552                    "BAR": "1",
4553                    "BAZ": "3",
4554                }
4555            },
4556            {
4557                'name': 'web',
4558                'image': 'busybox',
4559                'environment': {
4560                    "BAZ": "3",
4561                }
4562            }
4563        ])
4564
4565    def test_invalid_links_in_extended_service(self):
4566        with pytest.raises(ConfigurationError) as excinfo:
4567            load_from_filename('tests/fixtures/extends/invalid-links.yml')
4568
4569        assert "services with 'links' cannot be extended" in excinfo.exconly()
4570
4571    def test_invalid_volumes_from_in_extended_service(self):
4572        with pytest.raises(ConfigurationError) as excinfo:
4573            load_from_filename('tests/fixtures/extends/invalid-volumes.yml')
4574
4575        assert "services with 'volumes_from' cannot be extended" in excinfo.exconly()
4576
4577    def test_invalid_net_in_extended_service(self):
4578        with pytest.raises(ConfigurationError) as excinfo:
4579            load_from_filename('tests/fixtures/extends/invalid-net-v2.yml')
4580
4581        assert 'network_mode: service' in excinfo.exconly()
4582        assert 'cannot be extended' in excinfo.exconly()
4583
4584        with pytest.raises(ConfigurationError) as excinfo:
4585            load_from_filename('tests/fixtures/extends/invalid-net.yml')
4586
4587        assert 'net: container' in excinfo.exconly()
4588        assert 'cannot be extended' in excinfo.exconly()
4589
4590    @mock.patch.dict(os.environ)
4591    def test_load_config_runs_interpolation_in_extended_service(self):
4592        os.environ.update(HOSTNAME_VALUE="penguin")
4593        expected_interpolated_value = "host-penguin"
4594        service_dicts = load_from_filename(
4595            'tests/fixtures/extends/valid-interpolation.yml')
4596        for service in service_dicts:
4597            assert service['hostname'] == expected_interpolated_value
4598
4599    @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
4600    def test_volume_path(self):
4601        dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml')
4602
4603        paths = [
4604            VolumeSpec(
4605                os.path.abspath('tests/fixtures/volume-path/common/foo'),
4606                '/foo',
4607                'rw'),
4608            VolumeSpec(
4609                os.path.abspath('tests/fixtures/volume-path/bar'),
4610                '/bar',
4611                'rw')
4612        ]
4613
4614        assert set(dicts[0]['volumes']) == set(paths)
4615
4616    def test_parent_build_path_dne(self):
4617        child = load_from_filename('tests/fixtures/extends/nonexistent-path-child.yml')
4618
4619        assert child == [
4620            {
4621                'name': 'dnechild',
4622                'image': 'busybox',
4623                'command': '/bin/true',
4624                'environment': {
4625                    "FOO": "1",
4626                    "BAR": "2",
4627                },
4628            },
4629        ]
4630
4631    def test_load_throws_error_when_base_service_does_not_exist(self):
4632        with pytest.raises(ConfigurationError) as excinfo:
4633            load_from_filename('tests/fixtures/extends/nonexistent-service.yml')
4634
4635        assert "Cannot extend service 'foo'" in excinfo.exconly()
4636        assert "Service not found" in excinfo.exconly()
4637
4638    def test_partial_service_config_in_extends_is_still_valid(self):
4639        dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml')
4640        assert dicts[0]['environment'] == {'FOO': '1'}
4641
4642    def test_extended_service_with_verbose_and_shorthand_way(self):
4643        services = load_from_filename('tests/fixtures/extends/verbose-and-shorthand.yml')
4644        assert service_sort(services) == service_sort([
4645            {
4646                'name': 'base',
4647                'image': 'busybox',
4648                'environment': {'BAR': '1'},
4649            },
4650            {
4651                'name': 'verbose',
4652                'image': 'busybox',
4653                'environment': {'BAR': '1', 'FOO': '1'},
4654            },
4655            {
4656                'name': 'shorthand',
4657                'image': 'busybox',
4658                'environment': {'BAR': '1', 'FOO': '2'},
4659            },
4660        ])
4661
4662    @mock.patch.dict(os.environ)
4663    def test_extends_with_environment_and_env_files(self):
4664        tmpdir = py.test.ensuretemp('test_extends_with_environment')
4665        self.addCleanup(tmpdir.remove)
4666        commondir = tmpdir.mkdir('common')
4667        commondir.join('base.yml').write("""
4668            app:
4669                image: 'example/app'
4670                env_file:
4671                    - 'envs'
4672                environment:
4673                    - SECRET
4674                    - TEST_ONE=common
4675                    - TEST_TWO=common
4676        """)
4677        tmpdir.join('docker-compose.yml').write("""
4678            ext:
4679                extends:
4680                    file: common/base.yml
4681                    service: app
4682                env_file:
4683                    - 'envs'
4684                environment:
4685                    - THING
4686                    - TEST_ONE=top
4687        """)
4688        commondir.join('envs').write("""
4689            COMMON_ENV_FILE
4690            TEST_ONE=common-env-file
4691            TEST_TWO=common-env-file
4692            TEST_THREE=common-env-file
4693            TEST_FOUR=common-env-file
4694        """)
4695        tmpdir.join('envs').write("""
4696            TOP_ENV_FILE
4697            TEST_ONE=top-env-file
4698            TEST_TWO=top-env-file
4699            TEST_THREE=top-env-file
4700        """)
4701
4702        expected = [
4703            {
4704                'name': 'ext',
4705                'image': 'example/app',
4706                'environment': {
4707                    'SECRET': 'secret',
4708                    'TOP_ENV_FILE': 'secret',
4709                    'COMMON_ENV_FILE': 'secret',
4710                    'THING': 'thing',
4711                    'TEST_ONE': 'top',
4712                    'TEST_TWO': 'common',
4713                    'TEST_THREE': 'top-env-file',
4714                    'TEST_FOUR': 'common-env-file',
4715                },
4716            },
4717        ]
4718
4719        os.environ['SECRET'] = 'secret'
4720        os.environ['THING'] = 'thing'
4721        os.environ['COMMON_ENV_FILE'] = 'secret'
4722        os.environ['TOP_ENV_FILE'] = 'secret'
4723        config = load_from_filename(str(tmpdir.join('docker-compose.yml')))
4724
4725        assert config == expected
4726
4727    def test_extends_with_mixed_versions_is_error(self):
4728        tmpdir = py.test.ensuretemp('test_extends_with_mixed_version')
4729        self.addCleanup(tmpdir.remove)
4730        tmpdir.join('docker-compose.yml').write("""
4731            version: "2"
4732            services:
4733              web:
4734                extends:
4735                  file: base.yml
4736                  service: base
4737                image: busybox
4738        """)
4739        tmpdir.join('base.yml').write("""
4740            base:
4741              volumes: ['/foo']
4742              ports: ['3000:3000']
4743        """)
4744
4745        with pytest.raises(ConfigurationError) as exc:
4746            load_from_filename(str(tmpdir.join('docker-compose.yml')))
4747        assert 'Version mismatch' in exc.exconly()
4748
4749    def test_extends_with_defined_version_passes(self):
4750        tmpdir = py.test.ensuretemp('test_extends_with_defined_version')
4751        self.addCleanup(tmpdir.remove)
4752        tmpdir.join('docker-compose.yml').write("""
4753            version: "2"
4754            services:
4755              web:
4756                extends:
4757                  file: base.yml
4758                  service: base
4759                image: busybox
4760        """)
4761        tmpdir.join('base.yml').write("""
4762            version: "2"
4763            services:
4764                base:
4765                  volumes: ['/foo']
4766                  ports: ['3000:3000']
4767                  command: top
4768        """)
4769
4770        service = load_from_filename(str(tmpdir.join('docker-compose.yml')))
4771        assert service[0]['command'] == "top"
4772
4773    def test_extends_with_depends_on(self):
4774        tmpdir = py.test.ensuretemp('test_extends_with_depends_on')
4775        self.addCleanup(tmpdir.remove)
4776        tmpdir.join('docker-compose.yml').write("""
4777            version: "2"
4778            services:
4779              base:
4780                image: example
4781              web:
4782                extends: base
4783                image: busybox
4784                depends_on: ['other']
4785              other:
4786                image: example
4787        """)
4788        services = load_from_filename(str(tmpdir.join('docker-compose.yml')))
4789        assert service_sort(services)[2]['depends_on'] == {
4790            'other': {'condition': 'service_started'}
4791        }
4792
4793    def test_extends_with_healthcheck(self):
4794        service_dicts = load_from_filename('tests/fixtures/extends/healthcheck-2.yml')
4795        assert service_sort(service_dicts) == [{
4796            'name': 'demo',
4797            'image': 'foobar:latest',
4798            'healthcheck': {
4799                'test': ['CMD', '/health.sh'],
4800                'interval': 10000000000,
4801                'timeout': 5000000000,
4802                'retries': 36,
4803            }
4804        }]
4805
4806    def test_extends_with_ports(self):
4807        tmpdir = py.test.ensuretemp('test_extends_with_ports')
4808        self.addCleanup(tmpdir.remove)
4809        tmpdir.join('docker-compose.yml').write("""
4810            version: '2'
4811
4812            services:
4813              a:
4814                image: nginx
4815                ports:
4816                  - 80
4817
4818              b:
4819                extends:
4820                  service: a
4821        """)
4822        services = load_from_filename(str(tmpdir.join('docker-compose.yml')))
4823
4824        assert len(services) == 2
4825        for svc in services:
4826            assert svc['ports'] == [types.ServicePort('80', None, None, None, None)]
4827
4828    def test_extends_with_security_opt(self):
4829        tmpdir = py.test.ensuretemp('test_extends_with_ports')
4830        self.addCleanup(tmpdir.remove)
4831        tmpdir.join('docker-compose.yml').write("""
4832            version: '2'
4833
4834            services:
4835              a:
4836                image: nginx
4837                security_opt:
4838                  - apparmor:unconfined
4839                  - seccomp:unconfined
4840
4841              b:
4842                extends:
4843                  service: a
4844        """)
4845        services = load_from_filename(str(tmpdir.join('docker-compose.yml')))
4846        assert len(services) == 2
4847        for svc in services:
4848            assert types.SecurityOpt.parse('apparmor:unconfined') in svc['security_opt']
4849            assert types.SecurityOpt.parse('seccomp:unconfined') in svc['security_opt']
4850
4851
4852@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
4853class ExpandPathTest(unittest.TestCase):
4854    working_dir = '/home/user/somedir'
4855
4856    def test_expand_path_normal(self):
4857        result = config.expand_path(self.working_dir, 'myfile')
4858        assert result == self.working_dir + '/' + 'myfile'
4859
4860    def test_expand_path_absolute(self):
4861        abs_path = '/home/user/otherdir/somefile'
4862        result = config.expand_path(self.working_dir, abs_path)
4863        assert result == abs_path
4864
4865    def test_expand_path_with_tilde(self):
4866        test_path = '~/otherdir/somefile'
4867        with mock.patch.dict(os.environ):
4868            os.environ['HOME'] = user_path = '/home/user/'
4869            result = config.expand_path(self.working_dir, test_path)
4870
4871        assert result == user_path + 'otherdir/somefile'
4872
4873
4874class VolumePathTest(unittest.TestCase):
4875
4876    def test_split_path_mapping_with_windows_path(self):
4877        host_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config"
4878        windows_volume_path = host_path + ":/opt/connect/config:ro"
4879        expected_mapping = ("/opt/connect/config", (host_path, 'ro'))
4880
4881        mapping = config.split_path_mapping(windows_volume_path)
4882        assert mapping == expected_mapping
4883
4884    def test_split_path_mapping_with_windows_path_in_container(self):
4885        host_path = 'c:\\Users\\remilia\\data'
4886        container_path = 'c:\\scarletdevil\\data'
4887        expected_mapping = (container_path, (host_path, None))
4888
4889        mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path))
4890        assert mapping == expected_mapping
4891
4892    def test_split_path_mapping_with_root_mount(self):
4893        host_path = '/'
4894        container_path = '/var/hostroot'
4895        expected_mapping = (container_path, (host_path, None))
4896        mapping = config.split_path_mapping('{0}:{1}'.format(host_path, container_path))
4897        assert mapping == expected_mapping
4898
4899
4900@pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
4901class BuildPathTest(unittest.TestCase):
4902
4903    def setUp(self):
4904        self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx')
4905
4906    def test_nonexistent_path(self):
4907        with pytest.raises(ConfigurationError):
4908            config.load(
4909                build_config_details(
4910                    {
4911                        'foo': {'build': 'nonexistent.path'},
4912                    },
4913                    'working_dir',
4914                    'filename.yml'
4915                )
4916            )
4917
4918    def test_relative_path(self):
4919        relative_build_path = '../build-ctx/'
4920        service_dict = make_service_dict(
4921            'relpath',
4922            {'build': relative_build_path},
4923            working_dir='tests/fixtures/build-path'
4924        )
4925        assert service_dict['build'] == self.abs_context_path
4926
4927    def test_absolute_path(self):
4928        service_dict = make_service_dict(
4929            'abspath',
4930            {'build': self.abs_context_path},
4931            working_dir='tests/fixtures/build-path'
4932        )
4933        assert service_dict['build'] == self.abs_context_path
4934
4935    def test_from_file(self):
4936        service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml')
4937        assert service_dict == [{'name': 'foo', 'build': {'context': self.abs_context_path}}]
4938
4939    def test_from_file_override_dir(self):
4940        override_dir = os.path.join(os.getcwd(), 'tests/fixtures/')
4941        service_dict = load_from_filename(
4942            'tests/fixtures/build-path-override-dir/docker-compose.yml', override_dir=override_dir)
4943        assert service_dict == [{'name': 'foo', 'build': {'context': self.abs_context_path}}]
4944
4945    def test_valid_url_in_build_path(self):
4946        valid_urls = [
4947            'git://github.com/docker/docker',
4948            'git@github.com:docker/docker.git',
4949            'git@bitbucket.org:atlassianlabs/atlassian-docker.git',
4950            'https://github.com/docker/docker.git',
4951            'http://github.com/docker/docker.git',
4952            'github.com/docker/docker.git',
4953        ]
4954        for valid_url in valid_urls:
4955            service_dict = config.load(build_config_details({
4956                'validurl': {'build': valid_url},
4957            }, '.', None)).services
4958            assert service_dict[0]['build'] == {'context': valid_url}
4959
4960    def test_invalid_url_in_build_path(self):
4961        invalid_urls = [
4962            'example.com/bogus',
4963            'ftp://example.com/',
4964            '/path/does/not/exist',
4965        ]
4966        for invalid_url in invalid_urls:
4967            with pytest.raises(ConfigurationError) as exc:
4968                config.load(build_config_details({
4969                    'invalidurl': {'build': invalid_url},
4970                }, '.', None))
4971            assert 'build path' in exc.exconly()
4972
4973
4974class HealthcheckTest(unittest.TestCase):
4975    def test_healthcheck(self):
4976        config_dict = config.load(
4977            build_config_details({
4978                'version': '2.3',
4979                'services': {
4980                    'test': {
4981                        'image': 'busybox',
4982                        'healthcheck': {
4983                            'test': ['CMD', 'true'],
4984                            'interval': '1s',
4985                            'timeout': '1m',
4986                            'retries': 3,
4987                            'start_period': '10s',
4988                        }
4989                    }
4990                }
4991
4992            })
4993        )
4994
4995        serialized_config = yaml.load(serialize_config(config_dict))
4996        serialized_service = serialized_config['services']['test']
4997
4998        assert serialized_service['healthcheck'] == {
4999            'test': ['CMD', 'true'],
5000            'interval': '1s',
5001            'timeout': '1m',
5002            'retries': 3,
5003            'start_period': '10s'
5004        }
5005
5006    def test_disable(self):
5007        config_dict = config.load(
5008            build_config_details({
5009                'version': '2.3',
5010                'services': {
5011                    'test': {
5012                        'image': 'busybox',
5013                        'healthcheck': {
5014                            'disable': True,
5015                        }
5016                    }
5017                }
5018
5019            })
5020        )
5021
5022        serialized_config = yaml.load(serialize_config(config_dict))
5023        serialized_service = serialized_config['services']['test']
5024
5025        assert serialized_service['healthcheck'] == {
5026            'test': ['NONE'],
5027        }
5028
5029    def test_disable_with_other_config_is_invalid(self):
5030        with pytest.raises(ConfigurationError) as excinfo:
5031            config.load(
5032                build_config_details({
5033                    'version': '2.3',
5034                    'services': {
5035                        'invalid-healthcheck': {
5036                            'image': 'busybox',
5037                            'healthcheck': {
5038                                'disable': True,
5039                                'interval': '1s',
5040                            }
5041                        }
5042                    }
5043
5044                })
5045            )
5046
5047        assert 'invalid-healthcheck' in excinfo.exconly()
5048        assert '"disable: true" cannot be combined with other options' in excinfo.exconly()
5049
5050    def test_healthcheck_with_invalid_test(self):
5051        with pytest.raises(ConfigurationError) as excinfo:
5052            config.load(
5053                build_config_details({
5054                    'version': '2.3',
5055                    'services': {
5056                        'invalid-healthcheck': {
5057                            'image': 'busybox',
5058                            'healthcheck': {
5059                                'test': ['true'],
5060                                'interval': '1s',
5061                                'timeout': '1m',
5062                                'retries': 3,
5063                                'start_period': '10s',
5064                            }
5065                        }
5066                    }
5067
5068                })
5069            )
5070
5071        assert 'invalid-healthcheck' in excinfo.exconly()
5072        assert 'the first item must be either NONE, CMD or CMD-SHELL' in excinfo.exconly()
5073
5074
5075class GetDefaultConfigFilesTestCase(unittest.TestCase):
5076
5077    files = [
5078        'docker-compose.yml',
5079        'docker-compose.yaml',
5080    ]
5081
5082    def test_get_config_path_default_file_in_basedir(self):
5083        for index, filename in enumerate(self.files):
5084            assert filename == get_config_filename_for_files(self.files[index:])
5085        with pytest.raises(config.ComposeFileNotFound):
5086            get_config_filename_for_files([])
5087
5088    def test_get_config_path_default_file_in_parent_dir(self):
5089        """Test with files placed in the subdir"""
5090
5091        def get_config_in_subdir(files):
5092            return get_config_filename_for_files(files, subdir=True)
5093
5094        for index, filename in enumerate(self.files):
5095            assert filename == get_config_in_subdir(self.files[index:])
5096        with pytest.raises(config.ComposeFileNotFound):
5097            get_config_in_subdir([])
5098
5099
5100def get_config_filename_for_files(filenames, subdir=None):
5101    def make_files(dirname, filenames):
5102        for fname in filenames:
5103            with open(os.path.join(dirname, fname), 'w') as f:
5104                f.write('')
5105
5106    project_dir = tempfile.mkdtemp()
5107    try:
5108        make_files(project_dir, filenames)
5109        if subdir:
5110            base_dir = tempfile.mkdtemp(dir=project_dir)
5111        else:
5112            base_dir = project_dir
5113        filename, = config.get_default_config_files(base_dir)
5114        return os.path.basename(filename)
5115    finally:
5116        shutil.rmtree(project_dir)
5117
5118
5119class SerializeTest(unittest.TestCase):
5120    def test_denormalize_depends_on_v3(self):
5121        service_dict = {
5122            'image': 'busybox',
5123            'command': 'true',
5124            'depends_on': {
5125                'service2': {'condition': 'service_started'},
5126                'service3': {'condition': 'service_started'},
5127            }
5128        }
5129
5130        assert denormalize_service_dict(service_dict, V3_0) == {
5131            'image': 'busybox',
5132            'command': 'true',
5133            'depends_on': ['service2', 'service3']
5134        }
5135
5136    def test_denormalize_depends_on_v2_1(self):
5137        service_dict = {
5138            'image': 'busybox',
5139            'command': 'true',
5140            'depends_on': {
5141                'service2': {'condition': 'service_started'},
5142                'service3': {'condition': 'service_started'},
5143            }
5144        }
5145
5146        assert denormalize_service_dict(service_dict, V2_1) == service_dict
5147
5148    def test_serialize_time(self):
5149        data = {
5150            9: '9ns',
5151            9000: '9us',
5152            9000000: '9ms',
5153            90000000: '90ms',
5154            900000000: '900ms',
5155            999999999: '999999999ns',
5156            1000000000: '1s',
5157            60000000000: '1m',
5158            60000000001: '60000000001ns',
5159            9000000000000: '150m',
5160            90000000000000: '25h',
5161        }
5162
5163        for k, v in data.items():
5164            assert serialize_ns_time_value(k) == v
5165
5166    def test_denormalize_healthcheck(self):
5167        service_dict = {
5168            'image': 'test',
5169            'healthcheck': {
5170                'test': 'exit 1',
5171                'interval': '1m40s',
5172                'timeout': '30s',
5173                'retries': 5,
5174                'start_period': '2s90ms'
5175            }
5176        }
5177        processed_service = config.process_service(config.ServiceConfig(
5178            '.', 'test', 'test', service_dict
5179        ))
5180        denormalized_service = denormalize_service_dict(processed_service, V2_3)
5181        assert denormalized_service['healthcheck']['interval'] == '100s'
5182        assert denormalized_service['healthcheck']['timeout'] == '30s'
5183        assert denormalized_service['healthcheck']['start_period'] == '2090ms'
5184
5185    def test_denormalize_image_has_digest(self):
5186        service_dict = {
5187            'image': 'busybox'
5188        }
5189        image_digest = 'busybox@sha256:abcde'
5190
5191        assert denormalize_service_dict(service_dict, V3_0, image_digest) == {
5192            'image': 'busybox@sha256:abcde'
5193        }
5194
5195    def test_denormalize_image_no_digest(self):
5196        service_dict = {
5197            'image': 'busybox'
5198        }
5199
5200        assert denormalize_service_dict(service_dict, V3_0) == {
5201            'image': 'busybox'
5202        }
5203
5204    def test_serialize_secrets(self):
5205        service_dict = {
5206            'image': 'example/web',
5207            'secrets': [
5208                {'source': 'one'},
5209                {
5210                    'source': 'source',
5211                    'target': 'target',
5212                    'uid': '100',
5213                    'gid': '200',
5214                    'mode': 0o777,
5215                }
5216            ]
5217        }
5218        secrets_dict = {
5219            'one': {'file': '/one.txt'},
5220            'source': {'file': '/source.pem'},
5221            'two': {'external': True},
5222        }
5223        config_dict = config.load(build_config_details({
5224            'version': '3.1',
5225            'services': {'web': service_dict},
5226            'secrets': secrets_dict
5227        }))
5228
5229        serialized_config = yaml.load(serialize_config(config_dict))
5230        serialized_service = serialized_config['services']['web']
5231        assert secret_sort(serialized_service['secrets']) == secret_sort(service_dict['secrets'])
5232        assert 'secrets' in serialized_config
5233        assert serialized_config['secrets']['two'] == secrets_dict['two']
5234
5235    def test_serialize_ports(self):
5236        config_dict = config.Config(version=V2_0, services=[
5237            {
5238                'ports': [types.ServicePort('80', '8080', None, None, None)],
5239                'image': 'alpine',
5240                'name': 'web'
5241            }
5242        ], volumes={}, networks={}, secrets={}, configs={})
5243
5244        serialized_config = yaml.load(serialize_config(config_dict))
5245        assert '8080:80/tcp' in serialized_config['services']['web']['ports']
5246
5247    def test_serialize_ports_with_ext_ip(self):
5248        config_dict = config.Config(version=V3_5, services=[
5249            {
5250                'ports': [types.ServicePort('80', '8080', None, None, '127.0.0.1')],
5251                'image': 'alpine',
5252                'name': 'web'
5253            }
5254        ], volumes={}, networks={}, secrets={}, configs={})
5255
5256        serialized_config = yaml.load(serialize_config(config_dict))
5257        assert '127.0.0.1:8080:80/tcp' in serialized_config['services']['web']['ports']
5258
5259    def test_serialize_configs(self):
5260        service_dict = {
5261            'image': 'example/web',
5262            'configs': [
5263                {'source': 'one'},
5264                {
5265                    'source': 'source',
5266                    'target': 'target',
5267                    'uid': '100',
5268                    'gid': '200',
5269                    'mode': 0o777,
5270                }
5271            ]
5272        }
5273        configs_dict = {
5274            'one': {'file': '/one.txt'},
5275            'source': {'file': '/source.pem'},
5276            'two': {'external': True},
5277        }
5278        config_dict = config.load(build_config_details({
5279            'version': '3.3',
5280            'services': {'web': service_dict},
5281            'configs': configs_dict
5282        }))
5283
5284        serialized_config = yaml.load(serialize_config(config_dict))
5285        serialized_service = serialized_config['services']['web']
5286        assert secret_sort(serialized_service['configs']) == secret_sort(service_dict['configs'])
5287        assert 'configs' in serialized_config
5288        assert serialized_config['configs']['two'] == configs_dict['two']
5289
5290    def test_serialize_bool_string(self):
5291        cfg = {
5292            'version': '2.2',
5293            'services': {
5294                'web': {
5295                    'image': 'example/web',
5296                    'command': 'true',
5297                    'environment': {'FOO': 'Y', 'BAR': 'on'}
5298                }
5299            }
5300        }
5301        config_dict = config.load(build_config_details(cfg))
5302
5303        serialized_config = serialize_config(config_dict)
5304        assert 'command: "true"\n' in serialized_config
5305        assert 'FOO: "Y"\n' in serialized_config
5306        assert 'BAR: "on"\n' in serialized_config
5307
5308    def test_serialize_escape_dollar_sign(self):
5309        cfg = {
5310            'version': '2.2',
5311            'services': {
5312                'web': {
5313                    'image': 'busybox',
5314                    'command': 'echo $$FOO',
5315                    'environment': {
5316                        'CURRENCY': '$$'
5317                    },
5318                    'entrypoint': ['$$SHELL', '-c'],
5319                }
5320            }
5321        }
5322        config_dict = config.load(build_config_details(cfg))
5323
5324        serialized_config = yaml.load(serialize_config(config_dict))
5325        serialized_service = serialized_config['services']['web']
5326        assert serialized_service['environment']['CURRENCY'] == '$$'
5327        assert serialized_service['command'] == 'echo $$FOO'
5328        assert serialized_service['entrypoint'][0] == '$$SHELL'
5329
5330    def test_serialize_unicode_values(self):
5331        cfg = {
5332            'version': '2.3',
5333            'services': {
5334                'web': {
5335                    'image': 'busybox',
5336                    'command': 'echo 十六夜 咲夜'
5337                }
5338            }
5339        }
5340
5341        config_dict = config.load(build_config_details(cfg))
5342
5343        serialized_config = yaml.load(serialize_config(config_dict))
5344        serialized_service = serialized_config['services']['web']
5345        assert serialized_service['command'] == 'echo 十六夜 咲夜'
5346
5347    def test_serialize_external_false(self):
5348        cfg = {
5349            'version': '3.4',
5350            'volumes': {
5351                'test': {
5352                    'name': 'test-false',
5353                    'external': False
5354                }
5355            }
5356        }
5357
5358        config_dict = config.load(build_config_details(cfg))
5359        serialized_config = yaml.load(serialize_config(config_dict))
5360        serialized_volume = serialized_config['volumes']['test']
5361        assert serialized_volume['external'] is False
5362