config_test.py 85 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493
  1. # encoding: utf-8
  2. from __future__ import absolute_import
  3. from __future__ import print_function
  4. from __future__ import unicode_literals
  5. import os
  6. import shutil
  7. import tempfile
  8. from operator import itemgetter
  9. import py
  10. import pytest
  11. from compose.config import config
  12. from compose.config.config import resolve_build_args
  13. from compose.config.config import resolve_environment
  14. from compose.config.config import V1
  15. from compose.config.config import V2_0
  16. from compose.config.errors import ConfigurationError
  17. from compose.config.errors import VERSION_EXPLANATION
  18. from compose.config.types import VolumeSpec
  19. from compose.const import IS_WINDOWS_PLATFORM
  20. from tests import mock
  21. from tests import unittest
  22. DEFAULT_VERSION = V2_0
  23. def make_service_dict(name, service_dict, working_dir, filename=None):
  24. """Test helper function to construct a ServiceExtendsResolver
  25. """
  26. resolver = config.ServiceExtendsResolver(
  27. config.ServiceConfig(
  28. working_dir=working_dir,
  29. filename=filename,
  30. name=name,
  31. config=service_dict),
  32. config.ConfigFile(filename=filename, config={}))
  33. return config.process_service(resolver.run())
  34. def service_sort(services):
  35. return sorted(services, key=itemgetter('name'))
  36. def build_config_details(contents, working_dir='working_dir', filename='filename.yml'):
  37. return config.ConfigDetails(
  38. working_dir,
  39. [config.ConfigFile(filename, contents)])
  40. class ConfigTest(unittest.TestCase):
  41. def test_load(self):
  42. service_dicts = config.load(
  43. build_config_details(
  44. {
  45. 'foo': {'image': 'busybox'},
  46. 'bar': {'image': 'busybox', 'environment': ['FOO=1']},
  47. },
  48. 'tests/fixtures/extends',
  49. 'common.yml'
  50. )
  51. ).services
  52. self.assertEqual(
  53. service_sort(service_dicts),
  54. service_sort([
  55. {
  56. 'name': 'bar',
  57. 'image': 'busybox',
  58. 'environment': {'FOO': '1'},
  59. },
  60. {
  61. 'name': 'foo',
  62. 'image': 'busybox',
  63. }
  64. ])
  65. )
  66. def test_load_v2(self):
  67. config_data = config.load(
  68. build_config_details({
  69. 'version': '2',
  70. 'services': {
  71. 'foo': {'image': 'busybox'},
  72. 'bar': {'image': 'busybox', 'environment': ['FOO=1']},
  73. },
  74. 'volumes': {
  75. 'hello': {
  76. 'driver': 'default',
  77. 'driver_opts': {'beep': 'boop'}
  78. }
  79. },
  80. 'networks': {
  81. 'default': {
  82. 'driver': 'bridge',
  83. 'driver_opts': {'beep': 'boop'}
  84. },
  85. 'with_ipam': {
  86. 'ipam': {
  87. 'driver': 'default',
  88. 'config': [
  89. {'subnet': '172.28.0.0/16'}
  90. ]
  91. }
  92. }
  93. }
  94. }, 'working_dir', 'filename.yml')
  95. )
  96. service_dicts = config_data.services
  97. volume_dict = config_data.volumes
  98. networks_dict = config_data.networks
  99. self.assertEqual(
  100. service_sort(service_dicts),
  101. service_sort([
  102. {
  103. 'name': 'bar',
  104. 'image': 'busybox',
  105. 'environment': {'FOO': '1'},
  106. },
  107. {
  108. 'name': 'foo',
  109. 'image': 'busybox',
  110. }
  111. ])
  112. )
  113. self.assertEqual(volume_dict, {
  114. 'hello': {
  115. 'driver': 'default',
  116. 'driver_opts': {'beep': 'boop'}
  117. }
  118. })
  119. self.assertEqual(networks_dict, {
  120. 'default': {
  121. 'driver': 'bridge',
  122. 'driver_opts': {'beep': 'boop'}
  123. },
  124. 'with_ipam': {
  125. 'ipam': {
  126. 'driver': 'default',
  127. 'config': [
  128. {'subnet': '172.28.0.0/16'}
  129. ]
  130. }
  131. }
  132. })
  133. def test_valid_versions(self):
  134. for version in ['2', '2.0']:
  135. cfg = config.load(build_config_details({'version': version}))
  136. assert cfg.version == V2_0
  137. def test_v1_file_version(self):
  138. cfg = config.load(build_config_details({'web': {'image': 'busybox'}}))
  139. assert cfg.version == V1
  140. assert list(s['name'] for s in cfg.services) == ['web']
  141. cfg = config.load(build_config_details({'version': {'image': 'busybox'}}))
  142. assert cfg.version == V1
  143. assert list(s['name'] for s in cfg.services) == ['version']
  144. def test_wrong_version_type(self):
  145. for version in [None, 1, 2, 2.0]:
  146. with pytest.raises(ConfigurationError) as excinfo:
  147. config.load(
  148. build_config_details(
  149. {'version': version},
  150. filename='filename.yml',
  151. )
  152. )
  153. assert 'Version in "filename.yml" is invalid - it should be a string.' \
  154. in excinfo.exconly()
  155. def test_unsupported_version(self):
  156. with pytest.raises(ConfigurationError) as excinfo:
  157. config.load(
  158. build_config_details(
  159. {'version': '2.1'},
  160. filename='filename.yml',
  161. )
  162. )
  163. assert 'Version in "filename.yml" is unsupported' in excinfo.exconly()
  164. assert VERSION_EXPLANATION in excinfo.exconly()
  165. def test_version_1_is_invalid(self):
  166. with pytest.raises(ConfigurationError) as excinfo:
  167. config.load(
  168. build_config_details(
  169. {
  170. 'version': '1',
  171. 'web': {'image': 'busybox'},
  172. },
  173. filename='filename.yml',
  174. )
  175. )
  176. assert 'Version in "filename.yml" is invalid' in excinfo.exconly()
  177. assert VERSION_EXPLANATION in excinfo.exconly()
  178. def test_v1_file_with_version_is_invalid(self):
  179. with pytest.raises(ConfigurationError) as excinfo:
  180. config.load(
  181. build_config_details(
  182. {
  183. 'version': '2',
  184. 'web': {'image': 'busybox'},
  185. },
  186. filename='filename.yml',
  187. )
  188. )
  189. assert 'Additional properties are not allowed' in excinfo.exconly()
  190. assert VERSION_EXPLANATION in excinfo.exconly()
  191. def test_named_volume_config_empty(self):
  192. config_details = build_config_details({
  193. 'version': '2',
  194. 'services': {
  195. 'simple': {'image': 'busybox'}
  196. },
  197. 'volumes': {
  198. 'simple': None,
  199. 'other': {},
  200. }
  201. })
  202. config_result = config.load(config_details)
  203. volumes = config_result.volumes
  204. assert 'simple' in volumes
  205. assert volumes['simple'] == {}
  206. assert volumes['other'] == {}
  207. def test_load_service_with_name_version(self):
  208. with mock.patch('compose.config.config.log') as mock_logging:
  209. config_data = config.load(
  210. build_config_details({
  211. 'version': {
  212. 'image': 'busybox'
  213. }
  214. }, 'working_dir', 'filename.yml')
  215. )
  216. assert 'Unexpected type for "version" key in "filename.yml"' \
  217. in mock_logging.warn.call_args[0][0]
  218. service_dicts = config_data.services
  219. self.assertEqual(
  220. service_sort(service_dicts),
  221. service_sort([
  222. {
  223. 'name': 'version',
  224. 'image': 'busybox',
  225. }
  226. ])
  227. )
  228. def test_load_throws_error_when_not_dict(self):
  229. with self.assertRaises(ConfigurationError):
  230. config.load(
  231. build_config_details(
  232. {'web': 'busybox:latest'},
  233. 'working_dir',
  234. 'filename.yml'
  235. )
  236. )
  237. def test_load_throws_error_when_not_dict_v2(self):
  238. with self.assertRaises(ConfigurationError):
  239. config.load(
  240. build_config_details(
  241. {'version': '2', 'services': {'web': 'busybox:latest'}},
  242. 'working_dir',
  243. 'filename.yml'
  244. )
  245. )
  246. def test_load_throws_error_with_invalid_network_fields(self):
  247. with self.assertRaises(ConfigurationError):
  248. config.load(
  249. build_config_details({
  250. 'version': '2',
  251. 'services': {'web': 'busybox:latest'},
  252. 'networks': {
  253. 'invalid': {'foo', 'bar'}
  254. }
  255. }, 'working_dir', 'filename.yml')
  256. )
  257. def test_load_config_invalid_service_names(self):
  258. for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']:
  259. with pytest.raises(ConfigurationError) as exc:
  260. config.load(build_config_details(
  261. {invalid_name: {'image': 'busybox'}},
  262. 'working_dir',
  263. 'filename.yml'))
  264. assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly()
  265. def test_config_invalid_service_names_v2(self):
  266. for invalid_name in ['?not?allowed', ' ', '', '!', '/', '\xe2']:
  267. with pytest.raises(ConfigurationError) as exc:
  268. config.load(
  269. build_config_details({
  270. 'version': '2',
  271. 'services': {invalid_name: {'image': 'busybox'}}
  272. }, 'working_dir', 'filename.yml')
  273. )
  274. assert 'Invalid service name \'%s\'' % invalid_name in exc.exconly()
  275. def test_load_with_invalid_field_name(self):
  276. with pytest.raises(ConfigurationError) as exc:
  277. config.load(build_config_details(
  278. {
  279. 'version': '2',
  280. 'services': {
  281. 'web': {'image': 'busybox', 'name': 'bogus'},
  282. }
  283. },
  284. 'working_dir',
  285. 'filename.yml',
  286. ))
  287. assert "Unsupported config option for services.web: 'name'" in exc.exconly()
  288. def test_load_with_invalid_field_name_v1(self):
  289. with pytest.raises(ConfigurationError) as exc:
  290. config.load(build_config_details(
  291. {
  292. 'web': {'image': 'busybox', 'name': 'bogus'},
  293. },
  294. 'working_dir',
  295. 'filename.yml',
  296. ))
  297. assert "Unsupported config option for web: 'name'" in exc.exconly()
  298. def test_load_invalid_service_definition(self):
  299. config_details = build_config_details(
  300. {'web': 'wrong'},
  301. 'working_dir',
  302. 'filename.yml')
  303. with pytest.raises(ConfigurationError) as exc:
  304. config.load(config_details)
  305. error_msg = "service 'web' doesn't have any configuration options"
  306. assert error_msg in exc.exconly()
  307. def test_config_integer_service_name_raise_validation_error(self):
  308. with pytest.raises(ConfigurationError) as excinfo:
  309. config.load(
  310. build_config_details(
  311. {1: {'image': 'busybox'}},
  312. 'working_dir',
  313. 'filename.yml'
  314. )
  315. )
  316. assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \
  317. in excinfo.exconly()
  318. def test_config_integer_service_name_raise_validation_error_v2(self):
  319. with pytest.raises(ConfigurationError) as excinfo:
  320. config.load(
  321. build_config_details(
  322. {
  323. 'version': '2',
  324. 'services': {1: {'image': 'busybox'}}
  325. },
  326. 'working_dir',
  327. 'filename.yml'
  328. )
  329. )
  330. assert "In file 'filename.yml' service name: 1 needs to be a string, eg '1'" \
  331. in excinfo.exconly()
  332. def test_load_with_multiple_files_v1(self):
  333. base_file = config.ConfigFile(
  334. 'base.yaml',
  335. {
  336. 'web': {
  337. 'image': 'example/web',
  338. 'links': ['db'],
  339. },
  340. 'db': {
  341. 'image': 'example/db',
  342. },
  343. })
  344. override_file = config.ConfigFile(
  345. 'override.yaml',
  346. {
  347. 'web': {
  348. 'build': '/',
  349. 'volumes': ['/home/user/project:/code'],
  350. },
  351. })
  352. details = config.ConfigDetails('.', [base_file, override_file])
  353. service_dicts = config.load(details).services
  354. expected = [
  355. {
  356. 'name': 'web',
  357. 'build': {'context': os.path.abspath('/')},
  358. 'volumes': [VolumeSpec.parse('/home/user/project:/code')],
  359. 'links': ['db'],
  360. },
  361. {
  362. 'name': 'db',
  363. 'image': 'example/db',
  364. },
  365. ]
  366. assert service_sort(service_dicts) == service_sort(expected)
  367. def test_load_with_multiple_files_and_empty_override(self):
  368. base_file = config.ConfigFile(
  369. 'base.yml',
  370. {'web': {'image': 'example/web'}})
  371. override_file = config.ConfigFile('override.yml', None)
  372. details = config.ConfigDetails('.', [base_file, override_file])
  373. with pytest.raises(ConfigurationError) as exc:
  374. config.load(details)
  375. error_msg = "Top level object in 'override.yml' needs to be an object"
  376. assert error_msg in exc.exconly()
  377. def test_load_with_multiple_files_and_empty_override_v2(self):
  378. base_file = config.ConfigFile(
  379. 'base.yml',
  380. {'version': '2', 'services': {'web': {'image': 'example/web'}}})
  381. override_file = config.ConfigFile('override.yml', None)
  382. details = config.ConfigDetails('.', [base_file, override_file])
  383. with pytest.raises(ConfigurationError) as exc:
  384. config.load(details)
  385. error_msg = "Top level object in 'override.yml' needs to be an object"
  386. assert error_msg in exc.exconly()
  387. def test_load_with_multiple_files_and_empty_base(self):
  388. base_file = config.ConfigFile('base.yml', None)
  389. override_file = config.ConfigFile(
  390. 'override.yml',
  391. {'web': {'image': 'example/web'}})
  392. details = config.ConfigDetails('.', [base_file, override_file])
  393. with pytest.raises(ConfigurationError) as exc:
  394. config.load(details)
  395. assert "Top level object in 'base.yml' needs to be an object" in exc.exconly()
  396. def test_load_with_multiple_files_and_empty_base_v2(self):
  397. base_file = config.ConfigFile('base.yml', None)
  398. override_file = config.ConfigFile(
  399. 'override.tml',
  400. {'version': '2', 'services': {'web': {'image': 'example/web'}}}
  401. )
  402. details = config.ConfigDetails('.', [base_file, override_file])
  403. with pytest.raises(ConfigurationError) as exc:
  404. config.load(details)
  405. assert "Top level object in 'base.yml' needs to be an object" in exc.exconly()
  406. def test_load_with_multiple_files_and_extends_in_override_file(self):
  407. base_file = config.ConfigFile(
  408. 'base.yaml',
  409. {
  410. 'web': {'image': 'example/web'},
  411. })
  412. override_file = config.ConfigFile(
  413. 'override.yaml',
  414. {
  415. 'web': {
  416. 'extends': {
  417. 'file': 'common.yml',
  418. 'service': 'base',
  419. },
  420. 'volumes': ['/home/user/project:/code'],
  421. },
  422. })
  423. details = config.ConfigDetails('.', [base_file, override_file])
  424. tmpdir = py.test.ensuretemp('config_test')
  425. self.addCleanup(tmpdir.remove)
  426. tmpdir.join('common.yml').write("""
  427. base:
  428. labels: ['label=one']
  429. """)
  430. with tmpdir.as_cwd():
  431. service_dicts = config.load(details).services
  432. expected = [
  433. {
  434. 'name': 'web',
  435. 'image': 'example/web',
  436. 'volumes': [VolumeSpec.parse('/home/user/project:/code')],
  437. 'labels': {'label': 'one'},
  438. },
  439. ]
  440. self.assertEqual(service_sort(service_dicts), service_sort(expected))
  441. def test_load_with_multiple_files_and_invalid_override(self):
  442. base_file = config.ConfigFile(
  443. 'base.yaml',
  444. {'web': {'image': 'example/web'}})
  445. override_file = config.ConfigFile(
  446. 'override.yaml',
  447. {'bogus': 'thing'})
  448. details = config.ConfigDetails('.', [base_file, override_file])
  449. with pytest.raises(ConfigurationError) as exc:
  450. config.load(details)
  451. assert "service 'bogus' doesn't have any configuration" in exc.exconly()
  452. assert "In file 'override.yaml'" in exc.exconly()
  453. def test_load_sorts_in_dependency_order(self):
  454. config_details = build_config_details({
  455. 'web': {
  456. 'image': 'busybox:latest',
  457. 'links': ['db'],
  458. },
  459. 'db': {
  460. 'image': 'busybox:latest',
  461. 'volumes_from': ['volume:ro']
  462. },
  463. 'volume': {
  464. 'image': 'busybox:latest',
  465. 'volumes': ['/tmp'],
  466. }
  467. })
  468. services = config.load(config_details).services
  469. assert services[0]['name'] == 'volume'
  470. assert services[1]['name'] == 'db'
  471. assert services[2]['name'] == 'web'
  472. def test_config_build_configuration(self):
  473. service = config.load(
  474. build_config_details(
  475. {'web': {
  476. 'build': '.',
  477. 'dockerfile': 'Dockerfile-alt'
  478. }},
  479. 'tests/fixtures/extends',
  480. 'filename.yml'
  481. )
  482. ).services
  483. self.assertTrue('context' in service[0]['build'])
  484. self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt')
  485. def test_config_build_configuration_v2(self):
  486. # service.dockerfile is invalid in v2
  487. with self.assertRaises(ConfigurationError):
  488. config.load(
  489. build_config_details(
  490. {
  491. 'version': '2',
  492. 'services': {
  493. 'web': {
  494. 'build': '.',
  495. 'dockerfile': 'Dockerfile-alt'
  496. }
  497. }
  498. },
  499. 'tests/fixtures/extends',
  500. 'filename.yml'
  501. )
  502. )
  503. service = config.load(
  504. build_config_details({
  505. 'version': '2',
  506. 'services': {
  507. 'web': {
  508. 'build': '.'
  509. }
  510. }
  511. }, 'tests/fixtures/extends', 'filename.yml')
  512. ).services[0]
  513. self.assertTrue('context' in service['build'])
  514. service = config.load(
  515. build_config_details(
  516. {
  517. 'version': '2',
  518. 'services': {
  519. 'web': {
  520. 'build': {
  521. 'context': '.',
  522. 'dockerfile': 'Dockerfile-alt'
  523. }
  524. }
  525. }
  526. },
  527. 'tests/fixtures/extends',
  528. 'filename.yml'
  529. )
  530. ).services
  531. self.assertTrue('context' in service[0]['build'])
  532. self.assertEqual(service[0]['build']['dockerfile'], 'Dockerfile-alt')
  533. def test_load_with_multiple_files_v2(self):
  534. base_file = config.ConfigFile(
  535. 'base.yaml',
  536. {
  537. 'version': '2',
  538. 'services': {
  539. 'web': {
  540. 'image': 'example/web',
  541. 'depends_on': ['db'],
  542. },
  543. 'db': {
  544. 'image': 'example/db',
  545. }
  546. },
  547. })
  548. override_file = config.ConfigFile(
  549. 'override.yaml',
  550. {
  551. 'version': '2',
  552. 'services': {
  553. 'web': {
  554. 'build': '/',
  555. 'volumes': ['/home/user/project:/code'],
  556. 'depends_on': ['other'],
  557. },
  558. 'other': {
  559. 'image': 'example/other',
  560. }
  561. }
  562. })
  563. details = config.ConfigDetails('.', [base_file, override_file])
  564. service_dicts = config.load(details).services
  565. expected = [
  566. {
  567. 'name': 'web',
  568. 'build': {'context': os.path.abspath('/')},
  569. 'image': 'example/web',
  570. 'volumes': [VolumeSpec.parse('/home/user/project:/code')],
  571. 'depends_on': ['db', 'other'],
  572. },
  573. {
  574. 'name': 'db',
  575. 'image': 'example/db',
  576. },
  577. {
  578. 'name': 'other',
  579. 'image': 'example/other',
  580. },
  581. ]
  582. assert service_sort(service_dicts) == service_sort(expected)
  583. def test_undeclared_volume_v2(self):
  584. base_file = config.ConfigFile(
  585. 'base.yaml',
  586. {
  587. 'version': '2',
  588. 'services': {
  589. 'web': {
  590. 'image': 'busybox:latest',
  591. 'volumes': ['data0028:/data:ro'],
  592. },
  593. },
  594. }
  595. )
  596. details = config.ConfigDetails('.', [base_file])
  597. with self.assertRaises(ConfigurationError):
  598. config.load(details)
  599. base_file = config.ConfigFile(
  600. 'base.yaml',
  601. {
  602. 'version': '2',
  603. 'services': {
  604. 'web': {
  605. 'image': 'busybox:latest',
  606. 'volumes': ['./data0028:/data:ro'],
  607. },
  608. },
  609. }
  610. )
  611. details = config.ConfigDetails('.', [base_file])
  612. config_data = config.load(details)
  613. volume = config_data.services[0].get('volumes')[0]
  614. assert not volume.is_named_volume
  615. def test_undeclared_volume_v1(self):
  616. base_file = config.ConfigFile(
  617. 'base.yaml',
  618. {
  619. 'web': {
  620. 'image': 'busybox:latest',
  621. 'volumes': ['data0028:/data:ro'],
  622. },
  623. }
  624. )
  625. details = config.ConfigDetails('.', [base_file])
  626. config_data = config.load(details)
  627. volume = config_data.services[0].get('volumes')[0]
  628. assert volume.external == 'data0028'
  629. assert volume.is_named_volume
  630. def test_config_valid_service_names(self):
  631. for valid_name in ['_', '-', '.__.', '_what-up.', 'what_.up----', 'whatup']:
  632. services = config.load(
  633. build_config_details(
  634. {valid_name: {'image': 'busybox'}},
  635. 'tests/fixtures/extends',
  636. 'common.yml')).services
  637. assert services[0]['name'] == valid_name
  638. def test_config_hint(self):
  639. with pytest.raises(ConfigurationError) as excinfo:
  640. config.load(
  641. build_config_details(
  642. {
  643. 'foo': {'image': 'busybox', 'privilige': 'something'},
  644. },
  645. 'tests/fixtures/extends',
  646. 'filename.yml'
  647. )
  648. )
  649. assert "(did you mean 'privileged'?)" in excinfo.exconly()
  650. def test_load_errors_on_uppercase_with_no_image(self):
  651. with pytest.raises(ConfigurationError) as exc:
  652. config.load(build_config_details({
  653. 'Foo': {'build': '.'},
  654. }, 'tests/fixtures/build-ctx'))
  655. assert "Service 'Foo' contains uppercase characters" in exc.exconly()
  656. def test_invalid_config_v1(self):
  657. with pytest.raises(ConfigurationError) as excinfo:
  658. config.load(
  659. build_config_details(
  660. {
  661. 'foo': {'image': 1},
  662. },
  663. 'tests/fixtures/extends',
  664. 'filename.yml'
  665. )
  666. )
  667. assert "foo.image contains an invalid type, it should be a string" \
  668. in excinfo.exconly()
  669. def test_invalid_config_v2(self):
  670. with pytest.raises(ConfigurationError) as excinfo:
  671. config.load(
  672. build_config_details(
  673. {
  674. 'version': '2',
  675. 'services': {
  676. 'foo': {'image': 1},
  677. },
  678. },
  679. 'tests/fixtures/extends',
  680. 'filename.yml'
  681. )
  682. )
  683. assert "services.foo.image contains an invalid type, it should be a string" \
  684. in excinfo.exconly()
  685. def test_invalid_config_build_and_image_specified_v1(self):
  686. with pytest.raises(ConfigurationError) as excinfo:
  687. config.load(
  688. build_config_details(
  689. {
  690. 'foo': {'image': 'busybox', 'build': '.'},
  691. },
  692. 'tests/fixtures/extends',
  693. 'filename.yml'
  694. )
  695. )
  696. assert "foo has both an image and build path specified." in excinfo.exconly()
  697. def test_invalid_config_type_should_be_an_array(self):
  698. with pytest.raises(ConfigurationError) as excinfo:
  699. config.load(
  700. build_config_details(
  701. {
  702. 'foo': {'image': 'busybox', 'links': 'an_link'},
  703. },
  704. 'tests/fixtures/extends',
  705. 'filename.yml'
  706. )
  707. )
  708. assert "foo.links contains an invalid type, it should be an array" \
  709. in excinfo.exconly()
  710. def test_invalid_config_not_a_dictionary(self):
  711. with pytest.raises(ConfigurationError) as excinfo:
  712. config.load(
  713. build_config_details(
  714. ['foo', 'lol'],
  715. 'tests/fixtures/extends',
  716. 'filename.yml'
  717. )
  718. )
  719. assert "Top level object in 'filename.yml' needs to be an object" \
  720. in excinfo.exconly()
  721. def test_invalid_config_not_unique_items(self):
  722. with pytest.raises(ConfigurationError) as excinfo:
  723. config.load(
  724. build_config_details(
  725. {
  726. 'web': {'build': '.', 'devices': ['/dev/foo:/dev/foo', '/dev/foo:/dev/foo']}
  727. },
  728. 'tests/fixtures/extends',
  729. 'filename.yml'
  730. )
  731. )
  732. assert "has non-unique elements" in excinfo.exconly()
  733. def test_invalid_list_of_strings_format(self):
  734. with pytest.raises(ConfigurationError) as excinfo:
  735. config.load(
  736. build_config_details(
  737. {
  738. 'web': {'build': '.', 'command': [1]}
  739. },
  740. 'tests/fixtures/extends',
  741. 'filename.yml'
  742. )
  743. )
  744. assert "web.command contains 1, which is an invalid type, it should be a string" \
  745. in excinfo.exconly()
  746. def test_load_config_dockerfile_without_build_raises_error_v1(self):
  747. with pytest.raises(ConfigurationError) as exc:
  748. config.load(build_config_details({
  749. 'web': {
  750. 'image': 'busybox',
  751. 'dockerfile': 'Dockerfile.alt'
  752. }
  753. }))
  754. assert "web has both an image and alternate Dockerfile." in exc.exconly()
  755. def test_config_extra_hosts_string_raises_validation_error(self):
  756. with pytest.raises(ConfigurationError) as excinfo:
  757. config.load(
  758. build_config_details(
  759. {'web': {
  760. 'image': 'busybox',
  761. 'extra_hosts': 'somehost:162.242.195.82'
  762. }},
  763. 'working_dir',
  764. 'filename.yml'
  765. )
  766. )
  767. assert "web.extra_hosts contains an invalid type" \
  768. in excinfo.exconly()
  769. def test_config_extra_hosts_list_of_dicts_validation_error(self):
  770. with pytest.raises(ConfigurationError) as excinfo:
  771. config.load(
  772. build_config_details(
  773. {'web': {
  774. 'image': 'busybox',
  775. 'extra_hosts': [
  776. {'somehost': '162.242.195.82'},
  777. {'otherhost': '50.31.209.229'}
  778. ]
  779. }},
  780. 'working_dir',
  781. 'filename.yml'
  782. )
  783. )
  784. assert "web.extra_hosts contains {\"somehost\": \"162.242.195.82\"}, " \
  785. "which is an invalid type, it should be a string" \
  786. in excinfo.exconly()
  787. def test_config_ulimits_invalid_keys_validation_error(self):
  788. with pytest.raises(ConfigurationError) as exc:
  789. config.load(build_config_details(
  790. {
  791. 'web': {
  792. 'image': 'busybox',
  793. 'ulimits': {
  794. 'nofile': {
  795. "not_soft_or_hard": 100,
  796. "soft": 10000,
  797. "hard": 20000,
  798. }
  799. }
  800. }
  801. },
  802. 'working_dir',
  803. 'filename.yml'))
  804. assert "web.ulimits.nofile contains unsupported option: 'not_soft_or_hard'" \
  805. in exc.exconly()
  806. def test_config_ulimits_required_keys_validation_error(self):
  807. with pytest.raises(ConfigurationError) as exc:
  808. config.load(build_config_details(
  809. {
  810. 'web': {
  811. 'image': 'busybox',
  812. 'ulimits': {'nofile': {"soft": 10000}}
  813. }
  814. },
  815. 'working_dir',
  816. 'filename.yml'))
  817. assert "web.ulimits.nofile" in exc.exconly()
  818. assert "'hard' is a required property" in exc.exconly()
  819. def test_config_ulimits_soft_greater_than_hard_error(self):
  820. expected = "'soft' value can not be greater than 'hard' value"
  821. with pytest.raises(ConfigurationError) as exc:
  822. config.load(build_config_details(
  823. {
  824. 'web': {
  825. 'image': 'busybox',
  826. 'ulimits': {
  827. 'nofile': {"soft": 10000, "hard": 1000}
  828. }
  829. }
  830. },
  831. 'working_dir',
  832. 'filename.yml'))
  833. assert expected in exc.exconly()
  834. def test_valid_config_which_allows_two_type_definitions(self):
  835. expose_values = [["8000"], [8000]]
  836. for expose in expose_values:
  837. service = config.load(
  838. build_config_details(
  839. {'web': {
  840. 'image': 'busybox',
  841. 'expose': expose
  842. }},
  843. 'working_dir',
  844. 'filename.yml'
  845. )
  846. ).services
  847. self.assertEqual(service[0]['expose'], expose)
  848. def test_valid_config_oneof_string_or_list(self):
  849. entrypoint_values = [["sh"], "sh"]
  850. for entrypoint in entrypoint_values:
  851. service = config.load(
  852. build_config_details(
  853. {'web': {
  854. 'image': 'busybox',
  855. 'entrypoint': entrypoint
  856. }},
  857. 'working_dir',
  858. 'filename.yml'
  859. )
  860. ).services
  861. self.assertEqual(service[0]['entrypoint'], entrypoint)
  862. @mock.patch('compose.config.validation.log')
  863. def test_logs_warning_for_boolean_in_environment(self, mock_logging):
  864. expected_warning_msg = "There is a boolean value in the 'environment' key."
  865. config.load(
  866. build_config_details(
  867. {'web': {
  868. 'image': 'busybox',
  869. 'environment': {'SHOW_STUFF': True}
  870. }},
  871. 'working_dir',
  872. 'filename.yml'
  873. )
  874. )
  875. assert mock_logging.warn.called
  876. assert expected_warning_msg in mock_logging.warn.call_args[0][0]
  877. def test_config_valid_environment_dict_key_contains_dashes(self):
  878. services = config.load(
  879. build_config_details(
  880. {'web': {
  881. 'image': 'busybox',
  882. 'environment': {'SPRING_JPA_HIBERNATE_DDL-AUTO': 'none'}
  883. }},
  884. 'working_dir',
  885. 'filename.yml'
  886. )
  887. ).services
  888. self.assertEqual(services[0]['environment']['SPRING_JPA_HIBERNATE_DDL-AUTO'], 'none')
  889. def test_load_yaml_with_yaml_error(self):
  890. tmpdir = py.test.ensuretemp('invalid_yaml_test')
  891. self.addCleanup(tmpdir.remove)
  892. invalid_yaml_file = tmpdir.join('docker-compose.yml')
  893. invalid_yaml_file.write("""
  894. web:
  895. this is bogus: ok: what
  896. """)
  897. with pytest.raises(ConfigurationError) as exc:
  898. config.load_yaml(str(invalid_yaml_file))
  899. assert 'line 3, column 32' in exc.exconly()
  900. def test_validate_extra_hosts_invalid(self):
  901. with pytest.raises(ConfigurationError) as exc:
  902. config.load(build_config_details({
  903. 'web': {
  904. 'image': 'alpine',
  905. 'extra_hosts': "www.example.com: 192.168.0.17",
  906. }
  907. }))
  908. assert "web.extra_hosts contains an invalid type" in exc.exconly()
  909. def test_validate_extra_hosts_invalid_list(self):
  910. with pytest.raises(ConfigurationError) as exc:
  911. config.load(build_config_details({
  912. 'web': {
  913. 'image': 'alpine',
  914. 'extra_hosts': [
  915. {'www.example.com': '192.168.0.17'},
  916. {'api.example.com': '192.168.0.18'}
  917. ],
  918. }
  919. }))
  920. assert "which is an invalid type" in exc.exconly()
  921. def test_normalize_dns_options(self):
  922. actual = config.load(build_config_details({
  923. 'web': {
  924. 'image': 'alpine',
  925. 'dns': '8.8.8.8',
  926. 'dns_search': 'domain.local',
  927. }
  928. }))
  929. assert actual.services == [
  930. {
  931. 'name': 'web',
  932. 'image': 'alpine',
  933. 'dns': ['8.8.8.8'],
  934. 'dns_search': ['domain.local'],
  935. }
  936. ]
  937. def test_merge_service_dicts_from_files_with_extends_in_base(self):
  938. base = {
  939. 'volumes': ['.:/app'],
  940. 'extends': {'service': 'app'}
  941. }
  942. override = {
  943. 'image': 'alpine:edge',
  944. }
  945. actual = config.merge_service_dicts_from_files(
  946. base,
  947. override,
  948. DEFAULT_VERSION)
  949. assert actual == {
  950. 'image': 'alpine:edge',
  951. 'volumes': ['.:/app'],
  952. 'extends': {'service': 'app'}
  953. }
  954. def test_merge_service_dicts_from_files_with_extends_in_override(self):
  955. base = {
  956. 'volumes': ['.:/app'],
  957. 'extends': {'service': 'app'}
  958. }
  959. override = {
  960. 'image': 'alpine:edge',
  961. 'extends': {'service': 'foo'}
  962. }
  963. actual = config.merge_service_dicts_from_files(
  964. base,
  965. override,
  966. DEFAULT_VERSION)
  967. assert actual == {
  968. 'image': 'alpine:edge',
  969. 'volumes': ['.:/app'],
  970. 'extends': {'service': 'foo'}
  971. }
  972. def test_external_volume_config(self):
  973. config_details = build_config_details({
  974. 'version': '2',
  975. 'services': {
  976. 'bogus': {'image': 'busybox'}
  977. },
  978. 'volumes': {
  979. 'ext': {'external': True},
  980. 'ext2': {'external': {'name': 'aliased'}}
  981. }
  982. })
  983. config_result = config.load(config_details)
  984. volumes = config_result.volumes
  985. assert 'ext' in volumes
  986. assert volumes['ext']['external'] is True
  987. assert 'ext2' in volumes
  988. assert volumes['ext2']['external']['name'] == 'aliased'
  989. def test_external_volume_invalid_config(self):
  990. config_details = build_config_details({
  991. 'version': '2',
  992. 'services': {
  993. 'bogus': {'image': 'busybox'}
  994. },
  995. 'volumes': {
  996. 'ext': {'external': True, 'driver': 'foo'}
  997. }
  998. })
  999. with pytest.raises(ConfigurationError):
  1000. config.load(config_details)
  1001. def test_depends_on_orders_services(self):
  1002. config_details = build_config_details({
  1003. 'version': '2',
  1004. 'services': {
  1005. 'one': {'image': 'busybox', 'depends_on': ['three', 'two']},
  1006. 'two': {'image': 'busybox', 'depends_on': ['three']},
  1007. 'three': {'image': 'busybox'},
  1008. },
  1009. })
  1010. actual = config.load(config_details)
  1011. assert (
  1012. [service['name'] for service in actual.services] ==
  1013. ['three', 'two', 'one']
  1014. )
  1015. def test_depends_on_unknown_service_errors(self):
  1016. config_details = build_config_details({
  1017. 'version': '2',
  1018. 'services': {
  1019. 'one': {'image': 'busybox', 'depends_on': ['three']},
  1020. },
  1021. })
  1022. with pytest.raises(ConfigurationError) as exc:
  1023. config.load(config_details)
  1024. assert "Service 'one' depends on service 'three'" in exc.exconly()
  1025. class NetworkModeTest(unittest.TestCase):
  1026. def test_network_mode_standard(self):
  1027. config_data = config.load(build_config_details({
  1028. 'version': '2',
  1029. 'services': {
  1030. 'web': {
  1031. 'image': 'busybox',
  1032. 'command': "top",
  1033. 'network_mode': 'bridge',
  1034. },
  1035. },
  1036. }))
  1037. assert config_data.services[0]['network_mode'] == 'bridge'
  1038. def test_network_mode_standard_v1(self):
  1039. config_data = config.load(build_config_details({
  1040. 'web': {
  1041. 'image': 'busybox',
  1042. 'command': "top",
  1043. 'net': 'bridge',
  1044. },
  1045. }))
  1046. assert config_data.services[0]['network_mode'] == 'bridge'
  1047. assert 'net' not in config_data.services[0]
  1048. def test_network_mode_container(self):
  1049. config_data = config.load(build_config_details({
  1050. 'version': '2',
  1051. 'services': {
  1052. 'web': {
  1053. 'image': 'busybox',
  1054. 'command': "top",
  1055. 'network_mode': 'container:foo',
  1056. },
  1057. },
  1058. }))
  1059. assert config_data.services[0]['network_mode'] == 'container:foo'
  1060. def test_network_mode_container_v1(self):
  1061. config_data = config.load(build_config_details({
  1062. 'web': {
  1063. 'image': 'busybox',
  1064. 'command': "top",
  1065. 'net': 'container:foo',
  1066. },
  1067. }))
  1068. assert config_data.services[0]['network_mode'] == 'container:foo'
  1069. def test_network_mode_service(self):
  1070. config_data = config.load(build_config_details({
  1071. 'version': '2',
  1072. 'services': {
  1073. 'web': {
  1074. 'image': 'busybox',
  1075. 'command': "top",
  1076. 'network_mode': 'service:foo',
  1077. },
  1078. 'foo': {
  1079. 'image': 'busybox',
  1080. 'command': "top",
  1081. },
  1082. },
  1083. }))
  1084. assert config_data.services[1]['network_mode'] == 'service:foo'
  1085. def test_network_mode_service_v1(self):
  1086. config_data = config.load(build_config_details({
  1087. 'web': {
  1088. 'image': 'busybox',
  1089. 'command': "top",
  1090. 'net': 'container:foo',
  1091. },
  1092. 'foo': {
  1093. 'image': 'busybox',
  1094. 'command': "top",
  1095. },
  1096. }))
  1097. assert config_data.services[1]['network_mode'] == 'service:foo'
  1098. def test_network_mode_service_nonexistent(self):
  1099. with pytest.raises(ConfigurationError) as excinfo:
  1100. config.load(build_config_details({
  1101. 'version': '2',
  1102. 'services': {
  1103. 'web': {
  1104. 'image': 'busybox',
  1105. 'command': "top",
  1106. 'network_mode': 'service:foo',
  1107. },
  1108. },
  1109. }))
  1110. assert "service 'foo' which is undefined" in excinfo.exconly()
  1111. def test_network_mode_plus_networks_is_invalid(self):
  1112. with pytest.raises(ConfigurationError) as excinfo:
  1113. config.load(build_config_details({
  1114. 'version': '2',
  1115. 'services': {
  1116. 'web': {
  1117. 'image': 'busybox',
  1118. 'command': "top",
  1119. 'network_mode': 'bridge',
  1120. 'networks': ['front'],
  1121. },
  1122. },
  1123. 'networks': {
  1124. 'front': None,
  1125. }
  1126. }))
  1127. assert "'network_mode' and 'networks' cannot be combined" in excinfo.exconly()
  1128. class PortsTest(unittest.TestCase):
  1129. INVALID_PORTS_TYPES = [
  1130. {"1": "8000"},
  1131. False,
  1132. "8000",
  1133. 8000,
  1134. ]
  1135. NON_UNIQUE_SINGLE_PORTS = [
  1136. ["8000", "8000"],
  1137. ]
  1138. INVALID_PORT_MAPPINGS = [
  1139. ["8000-8001:8000"],
  1140. ]
  1141. VALID_SINGLE_PORTS = [
  1142. ["8000"],
  1143. ["8000/tcp"],
  1144. ["8000", "9000"],
  1145. [8000],
  1146. [8000, 9000],
  1147. ]
  1148. VALID_PORT_MAPPINGS = [
  1149. ["8000:8050"],
  1150. ["49153-49154:3002-3003"],
  1151. ]
  1152. def test_config_invalid_ports_type_validation(self):
  1153. for invalid_ports in self.INVALID_PORTS_TYPES:
  1154. with pytest.raises(ConfigurationError) as exc:
  1155. self.check_config({'ports': invalid_ports})
  1156. assert "contains an invalid type" in exc.value.msg
  1157. def test_config_non_unique_ports_validation(self):
  1158. for invalid_ports in self.NON_UNIQUE_SINGLE_PORTS:
  1159. with pytest.raises(ConfigurationError) as exc:
  1160. self.check_config({'ports': invalid_ports})
  1161. assert "non-unique" in exc.value.msg
  1162. def test_config_invalid_ports_format_validation(self):
  1163. for invalid_ports in self.INVALID_PORT_MAPPINGS:
  1164. with pytest.raises(ConfigurationError) as exc:
  1165. self.check_config({'ports': invalid_ports})
  1166. assert "Port ranges don't match in length" in exc.value.msg
  1167. def test_config_valid_ports_format_validation(self):
  1168. for valid_ports in self.VALID_SINGLE_PORTS + self.VALID_PORT_MAPPINGS:
  1169. self.check_config({'ports': valid_ports})
  1170. def test_config_invalid_expose_type_validation(self):
  1171. for invalid_expose in self.INVALID_PORTS_TYPES:
  1172. with pytest.raises(ConfigurationError) as exc:
  1173. self.check_config({'expose': invalid_expose})
  1174. assert "contains an invalid type" in exc.value.msg
  1175. def test_config_non_unique_expose_validation(self):
  1176. for invalid_expose in self.NON_UNIQUE_SINGLE_PORTS:
  1177. with pytest.raises(ConfigurationError) as exc:
  1178. self.check_config({'expose': invalid_expose})
  1179. assert "non-unique" in exc.value.msg
  1180. def test_config_invalid_expose_format_validation(self):
  1181. # Valid port mappings ARE NOT valid 'expose' entries
  1182. for invalid_expose in self.INVALID_PORT_MAPPINGS + self.VALID_PORT_MAPPINGS:
  1183. with pytest.raises(ConfigurationError) as exc:
  1184. self.check_config({'expose': invalid_expose})
  1185. assert "should be of the format" in exc.value.msg
  1186. def test_config_valid_expose_format_validation(self):
  1187. # Valid single ports ARE valid 'expose' entries
  1188. for valid_expose in self.VALID_SINGLE_PORTS:
  1189. self.check_config({'expose': valid_expose})
  1190. def check_config(self, cfg):
  1191. config.load(
  1192. build_config_details(
  1193. {'web': dict(image='busybox', **cfg)},
  1194. 'working_dir',
  1195. 'filename.yml'
  1196. )
  1197. )
  1198. class InterpolationTest(unittest.TestCase):
  1199. @mock.patch.dict(os.environ)
  1200. def test_config_file_with_environment_variable(self):
  1201. os.environ.update(
  1202. IMAGE="busybox",
  1203. HOST_PORT="80",
  1204. LABEL_VALUE="myvalue",
  1205. )
  1206. service_dicts = config.load(
  1207. config.find('tests/fixtures/environment-interpolation', None),
  1208. ).services
  1209. self.assertEqual(service_dicts, [
  1210. {
  1211. 'name': 'web',
  1212. 'image': 'busybox',
  1213. 'ports': ['80:8000'],
  1214. 'labels': {'mylabel': 'myvalue'},
  1215. 'hostname': 'host-',
  1216. 'command': '${ESCAPED}',
  1217. }
  1218. ])
  1219. @mock.patch.dict(os.environ)
  1220. def test_unset_variable_produces_warning(self):
  1221. os.environ.pop('FOO', None)
  1222. os.environ.pop('BAR', None)
  1223. config_details = build_config_details(
  1224. {
  1225. 'web': {
  1226. 'image': '${FOO}',
  1227. 'command': '${BAR}',
  1228. 'container_name': '${BAR}',
  1229. },
  1230. },
  1231. '.',
  1232. None,
  1233. )
  1234. with mock.patch('compose.config.interpolation.log') as log:
  1235. config.load(config_details)
  1236. self.assertEqual(2, log.warn.call_count)
  1237. warnings = sorted(args[0][0] for args in log.warn.call_args_list)
  1238. self.assertIn('BAR', warnings[0])
  1239. self.assertIn('FOO', warnings[1])
  1240. @mock.patch.dict(os.environ)
  1241. def test_invalid_interpolation(self):
  1242. with self.assertRaises(config.ConfigurationError) as cm:
  1243. config.load(
  1244. build_config_details(
  1245. {'web': {'image': '${'}},
  1246. 'working_dir',
  1247. 'filename.yml'
  1248. )
  1249. )
  1250. self.assertIn('Invalid', cm.exception.msg)
  1251. self.assertIn('for "image" option', cm.exception.msg)
  1252. self.assertIn('in service "web"', cm.exception.msg)
  1253. self.assertIn('"${"', cm.exception.msg)
  1254. def test_empty_environment_key_allowed(self):
  1255. service_dict = config.load(
  1256. build_config_details(
  1257. {
  1258. 'web': {
  1259. 'build': '.',
  1260. 'environment': {
  1261. 'POSTGRES_PASSWORD': ''
  1262. },
  1263. },
  1264. },
  1265. '.',
  1266. None,
  1267. )
  1268. ).services[0]
  1269. self.assertEquals(service_dict['environment']['POSTGRES_PASSWORD'], '')
  1270. class VolumeConfigTest(unittest.TestCase):
  1271. def test_no_binding(self):
  1272. d = make_service_dict('foo', {'build': '.', 'volumes': ['/data']}, working_dir='.')
  1273. self.assertEqual(d['volumes'], ['/data'])
  1274. @mock.patch.dict(os.environ)
  1275. def test_volume_binding_with_environment_variable(self):
  1276. os.environ['VOLUME_PATH'] = '/host/path'
  1277. d = config.load(
  1278. build_config_details(
  1279. {'foo': {'build': '.', 'volumes': ['${VOLUME_PATH}:/container/path']}},
  1280. '.',
  1281. None,
  1282. )
  1283. ).services[0]
  1284. self.assertEqual(d['volumes'], [VolumeSpec.parse('/host/path:/container/path')])
  1285. @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths')
  1286. @mock.patch.dict(os.environ)
  1287. def test_volume_binding_with_home(self):
  1288. os.environ['HOME'] = '/home/user'
  1289. d = make_service_dict('foo', {'build': '.', 'volumes': ['~:/container/path']}, working_dir='.')
  1290. self.assertEqual(d['volumes'], ['/home/user:/container/path'])
  1291. def test_name_does_not_expand(self):
  1292. d = make_service_dict('foo', {'build': '.', 'volumes': ['mydatavolume:/data']}, working_dir='.')
  1293. self.assertEqual(d['volumes'], ['mydatavolume:/data'])
  1294. def test_absolute_posix_path_does_not_expand(self):
  1295. d = make_service_dict('foo', {'build': '.', 'volumes': ['/var/lib/data:/data']}, working_dir='.')
  1296. self.assertEqual(d['volumes'], ['/var/lib/data:/data'])
  1297. def test_absolute_windows_path_does_not_expand(self):
  1298. d = make_service_dict('foo', {'build': '.', 'volumes': ['c:\\data:/data']}, working_dir='.')
  1299. self.assertEqual(d['volumes'], ['c:\\data:/data'])
  1300. @pytest.mark.skipif(IS_WINDOWS_PLATFORM, reason='posix paths')
  1301. def test_relative_path_does_expand_posix(self):
  1302. d = make_service_dict(
  1303. 'foo',
  1304. {'build': '.', 'volumes': ['./data:/data']},
  1305. working_dir='/home/me/myproject')
  1306. self.assertEqual(d['volumes'], ['/home/me/myproject/data:/data'])
  1307. d = make_service_dict(
  1308. 'foo',
  1309. {'build': '.', 'volumes': ['.:/data']},
  1310. working_dir='/home/me/myproject')
  1311. self.assertEqual(d['volumes'], ['/home/me/myproject:/data'])
  1312. d = make_service_dict(
  1313. 'foo',
  1314. {'build': '.', 'volumes': ['../otherproject:/data']},
  1315. working_dir='/home/me/myproject')
  1316. self.assertEqual(d['volumes'], ['/home/me/otherproject:/data'])
  1317. @pytest.mark.skipif(not IS_WINDOWS_PLATFORM, reason='windows paths')
  1318. def test_relative_path_does_expand_windows(self):
  1319. d = make_service_dict(
  1320. 'foo',
  1321. {'build': '.', 'volumes': ['./data:/data']},
  1322. working_dir='c:\\Users\\me\\myproject')
  1323. self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject\\data:/data'])
  1324. d = make_service_dict(
  1325. 'foo',
  1326. {'build': '.', 'volumes': ['.:/data']},
  1327. working_dir='c:\\Users\\me\\myproject')
  1328. self.assertEqual(d['volumes'], ['c:\\Users\\me\\myproject:/data'])
  1329. d = make_service_dict(
  1330. 'foo',
  1331. {'build': '.', 'volumes': ['../otherproject:/data']},
  1332. working_dir='c:\\Users\\me\\myproject')
  1333. self.assertEqual(d['volumes'], ['c:\\Users\\me\\otherproject:/data'])
  1334. @mock.patch.dict(os.environ)
  1335. def test_home_directory_with_driver_does_not_expand(self):
  1336. os.environ['NAME'] = 'surprise!'
  1337. d = make_service_dict('foo', {
  1338. 'build': '.',
  1339. 'volumes': ['~:/data'],
  1340. 'volume_driver': 'foodriver',
  1341. }, working_dir='.')
  1342. self.assertEqual(d['volumes'], ['~:/data'])
  1343. def test_volume_path_with_non_ascii_directory(self):
  1344. volume = u'/Füü/data:/data'
  1345. container_path = config.resolve_volume_path(".", volume)
  1346. self.assertEqual(container_path, volume)
  1347. class MergePathMappingTest(object):
  1348. def config_name(self):
  1349. return ""
  1350. def test_empty(self):
  1351. service_dict = config.merge_service_dicts({}, {}, DEFAULT_VERSION)
  1352. assert self.config_name() not in service_dict
  1353. def test_no_override(self):
  1354. service_dict = config.merge_service_dicts(
  1355. {self.config_name(): ['/foo:/code', '/data']},
  1356. {},
  1357. DEFAULT_VERSION)
  1358. assert set(service_dict[self.config_name()]) == set(['/foo:/code', '/data'])
  1359. def test_no_base(self):
  1360. service_dict = config.merge_service_dicts(
  1361. {},
  1362. {self.config_name(): ['/bar:/code']},
  1363. DEFAULT_VERSION)
  1364. assert set(service_dict[self.config_name()]) == set(['/bar:/code'])
  1365. def test_override_explicit_path(self):
  1366. service_dict = config.merge_service_dicts(
  1367. {self.config_name(): ['/foo:/code', '/data']},
  1368. {self.config_name(): ['/bar:/code']},
  1369. DEFAULT_VERSION)
  1370. assert set(service_dict[self.config_name()]) == set(['/bar:/code', '/data'])
  1371. def test_add_explicit_path(self):
  1372. service_dict = config.merge_service_dicts(
  1373. {self.config_name(): ['/foo:/code', '/data']},
  1374. {self.config_name(): ['/bar:/code', '/quux:/data']},
  1375. DEFAULT_VERSION)
  1376. assert set(service_dict[self.config_name()]) == set(['/bar:/code', '/quux:/data'])
  1377. def test_remove_explicit_path(self):
  1378. service_dict = config.merge_service_dicts(
  1379. {self.config_name(): ['/foo:/code', '/quux:/data']},
  1380. {self.config_name(): ['/bar:/code', '/data']},
  1381. DEFAULT_VERSION)
  1382. assert set(service_dict[self.config_name()]) == set(['/bar:/code', '/data'])
  1383. class MergeVolumesTest(unittest.TestCase, MergePathMappingTest):
  1384. def config_name(self):
  1385. return 'volumes'
  1386. class MergeDevicesTest(unittest.TestCase, MergePathMappingTest):
  1387. def config_name(self):
  1388. return 'devices'
  1389. class BuildOrImageMergeTest(unittest.TestCase):
  1390. def test_merge_build_or_image_no_override(self):
  1391. self.assertEqual(
  1392. config.merge_service_dicts({'build': '.'}, {}, V1),
  1393. {'build': '.'},
  1394. )
  1395. self.assertEqual(
  1396. config.merge_service_dicts({'image': 'redis'}, {}, V1),
  1397. {'image': 'redis'},
  1398. )
  1399. def test_merge_build_or_image_override_with_same(self):
  1400. self.assertEqual(
  1401. config.merge_service_dicts({'build': '.'}, {'build': './web'}, V1),
  1402. {'build': './web'},
  1403. )
  1404. self.assertEqual(
  1405. config.merge_service_dicts({'image': 'redis'}, {'image': 'postgres'}, V1),
  1406. {'image': 'postgres'},
  1407. )
  1408. def test_merge_build_or_image_override_with_other(self):
  1409. self.assertEqual(
  1410. config.merge_service_dicts({'build': '.'}, {'image': 'redis'}, V1),
  1411. {'image': 'redis'},
  1412. )
  1413. self.assertEqual(
  1414. config.merge_service_dicts({'image': 'redis'}, {'build': '.'}, V1),
  1415. {'build': '.'}
  1416. )
  1417. class MergeListsTest(unittest.TestCase):
  1418. def test_empty(self):
  1419. assert 'ports' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION)
  1420. def test_no_override(self):
  1421. service_dict = config.merge_service_dicts(
  1422. {'ports': ['10:8000', '9000']},
  1423. {},
  1424. DEFAULT_VERSION)
  1425. assert set(service_dict['ports']) == set(['10:8000', '9000'])
  1426. def test_no_base(self):
  1427. service_dict = config.merge_service_dicts(
  1428. {},
  1429. {'ports': ['10:8000', '9000']},
  1430. DEFAULT_VERSION)
  1431. assert set(service_dict['ports']) == set(['10:8000', '9000'])
  1432. def test_add_item(self):
  1433. service_dict = config.merge_service_dicts(
  1434. {'ports': ['10:8000', '9000']},
  1435. {'ports': ['20:8000']},
  1436. DEFAULT_VERSION)
  1437. assert set(service_dict['ports']) == set(['10:8000', '9000', '20:8000'])
  1438. class MergeStringsOrListsTest(unittest.TestCase):
  1439. def test_no_override(self):
  1440. service_dict = config.merge_service_dicts(
  1441. {'dns': '8.8.8.8'},
  1442. {},
  1443. DEFAULT_VERSION)
  1444. assert set(service_dict['dns']) == set(['8.8.8.8'])
  1445. def test_no_base(self):
  1446. service_dict = config.merge_service_dicts(
  1447. {},
  1448. {'dns': '8.8.8.8'},
  1449. DEFAULT_VERSION)
  1450. assert set(service_dict['dns']) == set(['8.8.8.8'])
  1451. def test_add_string(self):
  1452. service_dict = config.merge_service_dicts(
  1453. {'dns': ['8.8.8.8']},
  1454. {'dns': '9.9.9.9'},
  1455. DEFAULT_VERSION)
  1456. assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9'])
  1457. def test_add_list(self):
  1458. service_dict = config.merge_service_dicts(
  1459. {'dns': '8.8.8.8'},
  1460. {'dns': ['9.9.9.9']},
  1461. DEFAULT_VERSION)
  1462. assert set(service_dict['dns']) == set(['8.8.8.8', '9.9.9.9'])
  1463. class MergeLabelsTest(unittest.TestCase):
  1464. def test_empty(self):
  1465. assert 'labels' not in config.merge_service_dicts({}, {}, DEFAULT_VERSION)
  1466. def test_no_override(self):
  1467. service_dict = config.merge_service_dicts(
  1468. make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'),
  1469. make_service_dict('foo', {'build': '.'}, 'tests/'),
  1470. DEFAULT_VERSION)
  1471. assert service_dict['labels'] == {'foo': '1', 'bar': ''}
  1472. def test_no_base(self):
  1473. service_dict = config.merge_service_dicts(
  1474. make_service_dict('foo', {'build': '.'}, 'tests/'),
  1475. make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'),
  1476. DEFAULT_VERSION)
  1477. assert service_dict['labels'] == {'foo': '2'}
  1478. def test_override_explicit_value(self):
  1479. service_dict = config.merge_service_dicts(
  1480. make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'),
  1481. make_service_dict('foo', {'build': '.', 'labels': ['foo=2']}, 'tests/'),
  1482. DEFAULT_VERSION)
  1483. assert service_dict['labels'] == {'foo': '2', 'bar': ''}
  1484. def test_add_explicit_value(self):
  1485. service_dict = config.merge_service_dicts(
  1486. make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar']}, 'tests/'),
  1487. make_service_dict('foo', {'build': '.', 'labels': ['bar=2']}, 'tests/'),
  1488. DEFAULT_VERSION)
  1489. assert service_dict['labels'] == {'foo': '1', 'bar': '2'}
  1490. def test_remove_explicit_value(self):
  1491. service_dict = config.merge_service_dicts(
  1492. make_service_dict('foo', {'build': '.', 'labels': ['foo=1', 'bar=2']}, 'tests/'),
  1493. make_service_dict('foo', {'build': '.', 'labels': ['bar']}, 'tests/'),
  1494. DEFAULT_VERSION)
  1495. assert service_dict['labels'] == {'foo': '1', 'bar': ''}
  1496. class MemoryOptionsTest(unittest.TestCase):
  1497. def test_validation_fails_with_just_memswap_limit(self):
  1498. """
  1499. When you set a 'memswap_limit' it is invalid config unless you also set
  1500. a mem_limit
  1501. """
  1502. with pytest.raises(ConfigurationError) as excinfo:
  1503. config.load(
  1504. build_config_details(
  1505. {
  1506. 'foo': {'image': 'busybox', 'memswap_limit': 2000000},
  1507. },
  1508. 'tests/fixtures/extends',
  1509. 'filename.yml'
  1510. )
  1511. )
  1512. assert "foo.memswap_limit is invalid: when defining " \
  1513. "'memswap_limit' you must set 'mem_limit' as well" \
  1514. in excinfo.exconly()
  1515. def test_validation_with_correct_memswap_values(self):
  1516. service_dict = config.load(
  1517. build_config_details(
  1518. {'foo': {'image': 'busybox', 'mem_limit': 1000000, 'memswap_limit': 2000000}},
  1519. 'tests/fixtures/extends',
  1520. 'common.yml'
  1521. )
  1522. ).services
  1523. self.assertEqual(service_dict[0]['memswap_limit'], 2000000)
  1524. def test_memswap_can_be_a_string(self):
  1525. service_dict = config.load(
  1526. build_config_details(
  1527. {'foo': {'image': 'busybox', 'mem_limit': "1G", 'memswap_limit': "512M"}},
  1528. 'tests/fixtures/extends',
  1529. 'common.yml'
  1530. )
  1531. ).services
  1532. self.assertEqual(service_dict[0]['memswap_limit'], "512M")
  1533. class EnvTest(unittest.TestCase):
  1534. def test_parse_environment_as_list(self):
  1535. environment = [
  1536. 'NORMAL=F1',
  1537. 'CONTAINS_EQUALS=F=2',
  1538. 'TRAILING_EQUALS=',
  1539. ]
  1540. self.assertEqual(
  1541. config.parse_environment(environment),
  1542. {'NORMAL': 'F1', 'CONTAINS_EQUALS': 'F=2', 'TRAILING_EQUALS': ''},
  1543. )
  1544. def test_parse_environment_as_dict(self):
  1545. environment = {
  1546. 'NORMAL': 'F1',
  1547. 'CONTAINS_EQUALS': 'F=2',
  1548. 'TRAILING_EQUALS': None,
  1549. }
  1550. self.assertEqual(config.parse_environment(environment), environment)
  1551. def test_parse_environment_invalid(self):
  1552. with self.assertRaises(ConfigurationError):
  1553. config.parse_environment('a=b')
  1554. def test_parse_environment_empty(self):
  1555. self.assertEqual(config.parse_environment(None), {})
  1556. @mock.patch.dict(os.environ)
  1557. def test_resolve_environment(self):
  1558. os.environ['FILE_DEF'] = 'E1'
  1559. os.environ['FILE_DEF_EMPTY'] = 'E2'
  1560. os.environ['ENV_DEF'] = 'E3'
  1561. service_dict = {
  1562. 'build': '.',
  1563. 'environment': {
  1564. 'FILE_DEF': 'F1',
  1565. 'FILE_DEF_EMPTY': '',
  1566. 'ENV_DEF': None,
  1567. 'NO_DEF': None
  1568. },
  1569. }
  1570. self.assertEqual(
  1571. resolve_environment(service_dict),
  1572. {'FILE_DEF': 'F1', 'FILE_DEF_EMPTY': '', 'ENV_DEF': 'E3', 'NO_DEF': ''},
  1573. )
  1574. def test_resolve_environment_from_env_file(self):
  1575. self.assertEqual(
  1576. resolve_environment({'env_file': ['tests/fixtures/env/one.env']}),
  1577. {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'bar'},
  1578. )
  1579. def test_resolve_environment_with_multiple_env_files(self):
  1580. service_dict = {
  1581. 'env_file': [
  1582. 'tests/fixtures/env/one.env',
  1583. 'tests/fixtures/env/two.env'
  1584. ]
  1585. }
  1586. self.assertEqual(
  1587. resolve_environment(service_dict),
  1588. {'ONE': '2', 'TWO': '1', 'THREE': '3', 'FOO': 'baz', 'DOO': 'dah'},
  1589. )
  1590. def test_resolve_environment_nonexistent_file(self):
  1591. with pytest.raises(ConfigurationError) as exc:
  1592. config.load(build_config_details(
  1593. {'foo': {'image': 'example', 'env_file': 'nonexistent.env'}},
  1594. working_dir='tests/fixtures/env'))
  1595. assert 'Couldn\'t find env file' in exc.exconly()
  1596. assert 'nonexistent.env' in exc.exconly()
  1597. @mock.patch.dict(os.environ)
  1598. def test_resolve_environment_from_env_file_with_empty_values(self):
  1599. os.environ['FILE_DEF'] = 'E1'
  1600. os.environ['FILE_DEF_EMPTY'] = 'E2'
  1601. os.environ['ENV_DEF'] = 'E3'
  1602. self.assertEqual(
  1603. resolve_environment({'env_file': ['tests/fixtures/env/resolve.env']}),
  1604. {
  1605. 'FILE_DEF': u'bär',
  1606. 'FILE_DEF_EMPTY': '',
  1607. 'ENV_DEF': 'E3',
  1608. 'NO_DEF': ''
  1609. },
  1610. )
  1611. @mock.patch.dict(os.environ)
  1612. def test_resolve_build_args(self):
  1613. os.environ['env_arg'] = 'value2'
  1614. build = {
  1615. 'context': '.',
  1616. 'args': {
  1617. 'arg1': 'value1',
  1618. 'empty_arg': '',
  1619. 'env_arg': None,
  1620. 'no_env': None
  1621. }
  1622. }
  1623. self.assertEqual(
  1624. resolve_build_args(build),
  1625. {'arg1': 'value1', 'empty_arg': '', 'env_arg': 'value2', 'no_env': ''},
  1626. )
  1627. @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
  1628. @mock.patch.dict(os.environ)
  1629. def test_resolve_path(self):
  1630. os.environ['HOSTENV'] = '/tmp'
  1631. os.environ['CONTAINERENV'] = '/host/tmp'
  1632. service_dict = config.load(
  1633. build_config_details(
  1634. {'foo': {'build': '.', 'volumes': ['$HOSTENV:$CONTAINERENV']}},
  1635. "tests/fixtures/env",
  1636. )
  1637. ).services[0]
  1638. self.assertEqual(
  1639. set(service_dict['volumes']),
  1640. set([VolumeSpec.parse('/tmp:/host/tmp')]))
  1641. service_dict = config.load(
  1642. build_config_details(
  1643. {'foo': {'build': '.', 'volumes': ['/opt${HOSTENV}:/opt${CONTAINERENV}']}},
  1644. "tests/fixtures/env",
  1645. )
  1646. ).services[0]
  1647. self.assertEqual(
  1648. set(service_dict['volumes']),
  1649. set([VolumeSpec.parse('/opt/tmp:/opt/host/tmp')]))
  1650. def load_from_filename(filename):
  1651. return config.load(config.find('.', [filename])).services
  1652. class ExtendsTest(unittest.TestCase):
  1653. def test_extends(self):
  1654. service_dicts = load_from_filename('tests/fixtures/extends/docker-compose.yml')
  1655. self.assertEqual(service_sort(service_dicts), service_sort([
  1656. {
  1657. 'name': 'mydb',
  1658. 'image': 'busybox',
  1659. 'command': 'top',
  1660. },
  1661. {
  1662. 'name': 'myweb',
  1663. 'image': 'busybox',
  1664. 'command': 'top',
  1665. 'network_mode': 'bridge',
  1666. 'links': ['mydb:db'],
  1667. 'environment': {
  1668. "FOO": "1",
  1669. "BAR": "2",
  1670. "BAZ": "2",
  1671. },
  1672. }
  1673. ]))
  1674. def test_merging_env_labels_ulimits(self):
  1675. service_dicts = load_from_filename('tests/fixtures/extends/common-env-labels-ulimits.yml')
  1676. self.assertEqual(service_sort(service_dicts), service_sort([
  1677. {
  1678. 'name': 'web',
  1679. 'image': 'busybox',
  1680. 'command': '/bin/true',
  1681. 'network_mode': 'host',
  1682. 'environment': {
  1683. "FOO": "2",
  1684. "BAR": "1",
  1685. "BAZ": "3",
  1686. },
  1687. 'labels': {'label': 'one'},
  1688. 'ulimits': {'nproc': 65535, 'memlock': {'soft': 1024, 'hard': 2048}}
  1689. }
  1690. ]))
  1691. def test_nested(self):
  1692. service_dicts = load_from_filename('tests/fixtures/extends/nested.yml')
  1693. self.assertEqual(service_dicts, [
  1694. {
  1695. 'name': 'myweb',
  1696. 'image': 'busybox',
  1697. 'command': '/bin/true',
  1698. 'network_mode': 'host',
  1699. 'environment': {
  1700. "FOO": "2",
  1701. "BAR": "2",
  1702. },
  1703. },
  1704. ])
  1705. def test_self_referencing_file(self):
  1706. """
  1707. We specify a 'file' key that is the filename we're already in.
  1708. """
  1709. service_dicts = load_from_filename('tests/fixtures/extends/specify-file-as-self.yml')
  1710. self.assertEqual(service_sort(service_dicts), service_sort([
  1711. {
  1712. 'environment':
  1713. {
  1714. 'YEP': '1', 'BAR': '1', 'BAZ': '3'
  1715. },
  1716. 'image': 'busybox',
  1717. 'name': 'myweb'
  1718. },
  1719. {
  1720. 'environment':
  1721. {'YEP': '1'},
  1722. 'image': 'busybox',
  1723. 'name': 'otherweb'
  1724. },
  1725. {
  1726. 'environment':
  1727. {'YEP': '1', 'BAZ': '3'},
  1728. 'image': 'busybox',
  1729. 'name': 'web'
  1730. }
  1731. ]))
  1732. def test_circular(self):
  1733. with pytest.raises(config.CircularReference) as exc:
  1734. load_from_filename('tests/fixtures/extends/circle-1.yml')
  1735. path = [
  1736. (os.path.basename(filename), service_name)
  1737. for (filename, service_name) in exc.value.trail
  1738. ]
  1739. expected = [
  1740. ('circle-1.yml', 'web'),
  1741. ('circle-2.yml', 'other'),
  1742. ('circle-1.yml', 'web'),
  1743. ]
  1744. self.assertEqual(path, expected)
  1745. def test_extends_validation_empty_dictionary(self):
  1746. with pytest.raises(ConfigurationError) as excinfo:
  1747. config.load(
  1748. build_config_details(
  1749. {
  1750. 'web': {'image': 'busybox', 'extends': {}},
  1751. },
  1752. 'tests/fixtures/extends',
  1753. 'filename.yml'
  1754. )
  1755. )
  1756. assert 'service' in excinfo.exconly()
  1757. def test_extends_validation_missing_service_key(self):
  1758. with pytest.raises(ConfigurationError) as excinfo:
  1759. config.load(
  1760. build_config_details(
  1761. {
  1762. 'web': {'image': 'busybox', 'extends': {'file': 'common.yml'}},
  1763. },
  1764. 'tests/fixtures/extends',
  1765. 'filename.yml'
  1766. )
  1767. )
  1768. assert "'service' is a required property" in excinfo.exconly()
  1769. def test_extends_validation_invalid_key(self):
  1770. with pytest.raises(ConfigurationError) as excinfo:
  1771. config.load(
  1772. build_config_details(
  1773. {
  1774. 'web': {
  1775. 'image': 'busybox',
  1776. 'extends': {
  1777. 'file': 'common.yml',
  1778. 'service': 'web',
  1779. 'rogue_key': 'is not allowed'
  1780. }
  1781. },
  1782. },
  1783. 'tests/fixtures/extends',
  1784. 'filename.yml'
  1785. )
  1786. )
  1787. assert "web.extends contains unsupported option: 'rogue_key'" \
  1788. in excinfo.exconly()
  1789. def test_extends_validation_sub_property_key(self):
  1790. with pytest.raises(ConfigurationError) as excinfo:
  1791. config.load(
  1792. build_config_details(
  1793. {
  1794. 'web': {
  1795. 'image': 'busybox',
  1796. 'extends': {
  1797. 'file': 1,
  1798. 'service': 'web',
  1799. }
  1800. },
  1801. },
  1802. 'tests/fixtures/extends',
  1803. 'filename.yml'
  1804. )
  1805. )
  1806. assert "web.extends.file contains 1, which is an invalid type, it should be a string" \
  1807. in excinfo.exconly()
  1808. def test_extends_validation_no_file_key_no_filename_set(self):
  1809. dictionary = {'extends': {'service': 'web'}}
  1810. with pytest.raises(ConfigurationError) as excinfo:
  1811. make_service_dict('myweb', dictionary, working_dir='tests/fixtures/extends')
  1812. assert 'file' in excinfo.exconly()
  1813. def test_extends_validation_valid_config(self):
  1814. service = config.load(
  1815. build_config_details(
  1816. {
  1817. 'web': {'image': 'busybox', 'extends': {'service': 'web', 'file': 'common.yml'}},
  1818. },
  1819. 'tests/fixtures/extends',
  1820. 'common.yml'
  1821. )
  1822. ).services
  1823. self.assertEquals(len(service), 1)
  1824. self.assertIsInstance(service[0], dict)
  1825. self.assertEquals(service[0]['command'], "/bin/true")
  1826. def test_extended_service_with_invalid_config(self):
  1827. with pytest.raises(ConfigurationError) as exc:
  1828. load_from_filename('tests/fixtures/extends/service-with-invalid-schema.yml')
  1829. assert (
  1830. "myweb has neither an image nor a build path specified" in
  1831. exc.exconly()
  1832. )
  1833. def test_extended_service_with_valid_config(self):
  1834. service = load_from_filename('tests/fixtures/extends/service-with-valid-composite-extends.yml')
  1835. self.assertEquals(service[0]['command'], "top")
  1836. def test_extends_file_defaults_to_self(self):
  1837. """
  1838. Test not specifying a file in our extends options that the
  1839. config is valid and correctly extends from itself.
  1840. """
  1841. service_dicts = load_from_filename('tests/fixtures/extends/no-file-specified.yml')
  1842. self.assertEqual(service_sort(service_dicts), service_sort([
  1843. {
  1844. 'name': 'myweb',
  1845. 'image': 'busybox',
  1846. 'environment': {
  1847. "BAR": "1",
  1848. "BAZ": "3",
  1849. }
  1850. },
  1851. {
  1852. 'name': 'web',
  1853. 'image': 'busybox',
  1854. 'environment': {
  1855. "BAZ": "3",
  1856. }
  1857. }
  1858. ]))
  1859. def test_invalid_links_in_extended_service(self):
  1860. with pytest.raises(ConfigurationError) as excinfo:
  1861. load_from_filename('tests/fixtures/extends/invalid-links.yml')
  1862. assert "services with 'links' cannot be extended" in excinfo.exconly()
  1863. def test_invalid_volumes_from_in_extended_service(self):
  1864. with pytest.raises(ConfigurationError) as excinfo:
  1865. load_from_filename('tests/fixtures/extends/invalid-volumes.yml')
  1866. assert "services with 'volumes_from' cannot be extended" in excinfo.exconly()
  1867. def test_invalid_net_in_extended_service(self):
  1868. with pytest.raises(ConfigurationError) as excinfo:
  1869. load_from_filename('tests/fixtures/extends/invalid-net-v2.yml')
  1870. assert 'network_mode: service' in excinfo.exconly()
  1871. assert 'cannot be extended' in excinfo.exconly()
  1872. with pytest.raises(ConfigurationError) as excinfo:
  1873. load_from_filename('tests/fixtures/extends/invalid-net.yml')
  1874. assert 'net: container' in excinfo.exconly()
  1875. assert 'cannot be extended' in excinfo.exconly()
  1876. @mock.patch.dict(os.environ)
  1877. def test_load_config_runs_interpolation_in_extended_service(self):
  1878. os.environ.update(HOSTNAME_VALUE="penguin")
  1879. expected_interpolated_value = "host-penguin"
  1880. service_dicts = load_from_filename(
  1881. 'tests/fixtures/extends/valid-interpolation.yml')
  1882. for service in service_dicts:
  1883. assert service['hostname'] == expected_interpolated_value
  1884. @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
  1885. def test_volume_path(self):
  1886. dicts = load_from_filename('tests/fixtures/volume-path/docker-compose.yml')
  1887. paths = [
  1888. VolumeSpec(
  1889. os.path.abspath('tests/fixtures/volume-path/common/foo'),
  1890. '/foo',
  1891. 'rw'),
  1892. VolumeSpec(
  1893. os.path.abspath('tests/fixtures/volume-path/bar'),
  1894. '/bar',
  1895. 'rw')
  1896. ]
  1897. self.assertEqual(set(dicts[0]['volumes']), set(paths))
  1898. def test_parent_build_path_dne(self):
  1899. child = load_from_filename('tests/fixtures/extends/nonexistent-path-child.yml')
  1900. self.assertEqual(child, [
  1901. {
  1902. 'name': 'dnechild',
  1903. 'image': 'busybox',
  1904. 'command': '/bin/true',
  1905. 'environment': {
  1906. "FOO": "1",
  1907. "BAR": "2",
  1908. },
  1909. },
  1910. ])
  1911. def test_load_throws_error_when_base_service_does_not_exist(self):
  1912. with pytest.raises(ConfigurationError) as excinfo:
  1913. load_from_filename('tests/fixtures/extends/nonexistent-service.yml')
  1914. assert "Cannot extend service 'foo'" in excinfo.exconly()
  1915. assert "Service not found" in excinfo.exconly()
  1916. def test_partial_service_config_in_extends_is_still_valid(self):
  1917. dicts = load_from_filename('tests/fixtures/extends/valid-common-config.yml')
  1918. self.assertEqual(dicts[0]['environment'], {'FOO': '1'})
  1919. def test_extended_service_with_verbose_and_shorthand_way(self):
  1920. services = load_from_filename('tests/fixtures/extends/verbose-and-shorthand.yml')
  1921. self.assertEqual(service_sort(services), service_sort([
  1922. {
  1923. 'name': 'base',
  1924. 'image': 'busybox',
  1925. 'environment': {'BAR': '1'},
  1926. },
  1927. {
  1928. 'name': 'verbose',
  1929. 'image': 'busybox',
  1930. 'environment': {'BAR': '1', 'FOO': '1'},
  1931. },
  1932. {
  1933. 'name': 'shorthand',
  1934. 'image': 'busybox',
  1935. 'environment': {'BAR': '1', 'FOO': '2'},
  1936. },
  1937. ]))
  1938. def test_extends_with_environment_and_env_files(self):
  1939. tmpdir = py.test.ensuretemp('test_extends_with_environment')
  1940. self.addCleanup(tmpdir.remove)
  1941. commondir = tmpdir.mkdir('common')
  1942. commondir.join('base.yml').write("""
  1943. app:
  1944. image: 'example/app'
  1945. env_file:
  1946. - 'envs'
  1947. environment:
  1948. - SECRET
  1949. - TEST_ONE=common
  1950. - TEST_TWO=common
  1951. """)
  1952. tmpdir.join('docker-compose.yml').write("""
  1953. ext:
  1954. extends:
  1955. file: common/base.yml
  1956. service: app
  1957. env_file:
  1958. - 'envs'
  1959. environment:
  1960. - THING
  1961. - TEST_ONE=top
  1962. """)
  1963. commondir.join('envs').write("""
  1964. COMMON_ENV_FILE
  1965. TEST_ONE=common-env-file
  1966. TEST_TWO=common-env-file
  1967. TEST_THREE=common-env-file
  1968. TEST_FOUR=common-env-file
  1969. """)
  1970. tmpdir.join('envs').write("""
  1971. TOP_ENV_FILE
  1972. TEST_ONE=top-env-file
  1973. TEST_TWO=top-env-file
  1974. TEST_THREE=top-env-file
  1975. """)
  1976. expected = [
  1977. {
  1978. 'name': 'ext',
  1979. 'image': 'example/app',
  1980. 'environment': {
  1981. 'SECRET': 'secret',
  1982. 'TOP_ENV_FILE': 'secret',
  1983. 'COMMON_ENV_FILE': 'secret',
  1984. 'THING': 'thing',
  1985. 'TEST_ONE': 'top',
  1986. 'TEST_TWO': 'common',
  1987. 'TEST_THREE': 'top-env-file',
  1988. 'TEST_FOUR': 'common-env-file',
  1989. },
  1990. },
  1991. ]
  1992. with mock.patch.dict(os.environ):
  1993. os.environ['SECRET'] = 'secret'
  1994. os.environ['THING'] = 'thing'
  1995. os.environ['COMMON_ENV_FILE'] = 'secret'
  1996. os.environ['TOP_ENV_FILE'] = 'secret'
  1997. config = load_from_filename(str(tmpdir.join('docker-compose.yml')))
  1998. assert config == expected
  1999. def test_extends_with_mixed_versions_is_error(self):
  2000. tmpdir = py.test.ensuretemp('test_extends_with_mixed_version')
  2001. self.addCleanup(tmpdir.remove)
  2002. tmpdir.join('docker-compose.yml').write("""
  2003. version: "2"
  2004. services:
  2005. web:
  2006. extends:
  2007. file: base.yml
  2008. service: base
  2009. image: busybox
  2010. """)
  2011. tmpdir.join('base.yml').write("""
  2012. base:
  2013. volumes: ['/foo']
  2014. ports: ['3000:3000']
  2015. """)
  2016. with pytest.raises(ConfigurationError) as exc:
  2017. load_from_filename(str(tmpdir.join('docker-compose.yml')))
  2018. assert 'Version mismatch' in exc.exconly()
  2019. def test_extends_with_defined_version_passes(self):
  2020. tmpdir = py.test.ensuretemp('test_extends_with_defined_version')
  2021. self.addCleanup(tmpdir.remove)
  2022. tmpdir.join('docker-compose.yml').write("""
  2023. version: "2"
  2024. services:
  2025. web:
  2026. extends:
  2027. file: base.yml
  2028. service: base
  2029. image: busybox
  2030. """)
  2031. tmpdir.join('base.yml').write("""
  2032. version: "2"
  2033. services:
  2034. base:
  2035. volumes: ['/foo']
  2036. ports: ['3000:3000']
  2037. command: top
  2038. """)
  2039. service = load_from_filename(str(tmpdir.join('docker-compose.yml')))
  2040. self.assertEquals(service[0]['command'], "top")
  2041. def test_extends_with_depends_on(self):
  2042. tmpdir = py.test.ensuretemp('test_extends_with_defined_version')
  2043. self.addCleanup(tmpdir.remove)
  2044. tmpdir.join('docker-compose.yml').write("""
  2045. version: "2"
  2046. services:
  2047. base:
  2048. image: example
  2049. web:
  2050. extends: base
  2051. image: busybox
  2052. depends_on: ['other']
  2053. other:
  2054. image: example
  2055. """)
  2056. services = load_from_filename(str(tmpdir.join('docker-compose.yml')))
  2057. assert service_sort(services)[2]['depends_on'] == ['other']
  2058. @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
  2059. class ExpandPathTest(unittest.TestCase):
  2060. working_dir = '/home/user/somedir'
  2061. def test_expand_path_normal(self):
  2062. result = config.expand_path(self.working_dir, 'myfile')
  2063. self.assertEqual(result, self.working_dir + '/' + 'myfile')
  2064. def test_expand_path_absolute(self):
  2065. abs_path = '/home/user/otherdir/somefile'
  2066. result = config.expand_path(self.working_dir, abs_path)
  2067. self.assertEqual(result, abs_path)
  2068. def test_expand_path_with_tilde(self):
  2069. test_path = '~/otherdir/somefile'
  2070. with mock.patch.dict(os.environ):
  2071. os.environ['HOME'] = user_path = '/home/user/'
  2072. result = config.expand_path(self.working_dir, test_path)
  2073. self.assertEqual(result, user_path + 'otherdir/somefile')
  2074. class VolumePathTest(unittest.TestCase):
  2075. @pytest.mark.xfail((not IS_WINDOWS_PLATFORM), reason='does not have a drive')
  2076. def test_split_path_mapping_with_windows_path(self):
  2077. host_path = "c:\\Users\\msamblanet\\Documents\\anvil\\connect\\config"
  2078. windows_volume_path = host_path + ":/opt/connect/config:ro"
  2079. expected_mapping = ("/opt/connect/config:ro", host_path)
  2080. mapping = config.split_path_mapping(windows_volume_path)
  2081. self.assertEqual(mapping, expected_mapping)
  2082. @pytest.mark.xfail(IS_WINDOWS_PLATFORM, reason='paths use slash')
  2083. class BuildPathTest(unittest.TestCase):
  2084. def setUp(self):
  2085. self.abs_context_path = os.path.join(os.getcwd(), 'tests/fixtures/build-ctx')
  2086. def test_nonexistent_path(self):
  2087. with self.assertRaises(ConfigurationError):
  2088. config.load(
  2089. build_config_details(
  2090. {
  2091. 'foo': {'build': 'nonexistent.path'},
  2092. },
  2093. 'working_dir',
  2094. 'filename.yml'
  2095. )
  2096. )
  2097. def test_relative_path(self):
  2098. relative_build_path = '../build-ctx/'
  2099. service_dict = make_service_dict(
  2100. 'relpath',
  2101. {'build': relative_build_path},
  2102. working_dir='tests/fixtures/build-path'
  2103. )
  2104. self.assertEquals(service_dict['build'], self.abs_context_path)
  2105. def test_absolute_path(self):
  2106. service_dict = make_service_dict(
  2107. 'abspath',
  2108. {'build': self.abs_context_path},
  2109. working_dir='tests/fixtures/build-path'
  2110. )
  2111. self.assertEquals(service_dict['build'], self.abs_context_path)
  2112. def test_from_file(self):
  2113. service_dict = load_from_filename('tests/fixtures/build-path/docker-compose.yml')
  2114. self.assertEquals(service_dict, [{'name': 'foo', 'build': {'context': self.abs_context_path}}])
  2115. def test_valid_url_in_build_path(self):
  2116. valid_urls = [
  2117. 'git://github.com/docker/docker',
  2118. '[email protected]:docker/docker.git',
  2119. '[email protected]:atlassianlabs/atlassian-docker.git',
  2120. 'https://github.com/docker/docker.git',
  2121. 'http://github.com/docker/docker.git',
  2122. 'github.com/docker/docker.git',
  2123. ]
  2124. for valid_url in valid_urls:
  2125. service_dict = config.load(build_config_details({
  2126. 'validurl': {'build': valid_url},
  2127. }, '.', None)).services
  2128. assert service_dict[0]['build'] == {'context': valid_url}
  2129. def test_invalid_url_in_build_path(self):
  2130. invalid_urls = [
  2131. 'example.com/bogus',
  2132. 'ftp://example.com/',
  2133. '/path/does/not/exist',
  2134. ]
  2135. for invalid_url in invalid_urls:
  2136. with pytest.raises(ConfigurationError) as exc:
  2137. config.load(build_config_details({
  2138. 'invalidurl': {'build': invalid_url},
  2139. }, '.', None))
  2140. assert 'build path' in exc.exconly()
  2141. class GetDefaultConfigFilesTestCase(unittest.TestCase):
  2142. files = [
  2143. 'docker-compose.yml',
  2144. 'docker-compose.yaml',
  2145. ]
  2146. def test_get_config_path_default_file_in_basedir(self):
  2147. for index, filename in enumerate(self.files):
  2148. self.assertEqual(
  2149. filename,
  2150. get_config_filename_for_files(self.files[index:]))
  2151. with self.assertRaises(config.ComposeFileNotFound):
  2152. get_config_filename_for_files([])
  2153. def test_get_config_path_default_file_in_parent_dir(self):
  2154. """Test with files placed in the subdir"""
  2155. def get_config_in_subdir(files):
  2156. return get_config_filename_for_files(files, subdir=True)
  2157. for index, filename in enumerate(self.files):
  2158. self.assertEqual(filename, get_config_in_subdir(self.files[index:]))
  2159. with self.assertRaises(config.ComposeFileNotFound):
  2160. get_config_in_subdir([])
  2161. def get_config_filename_for_files(filenames, subdir=None):
  2162. def make_files(dirname, filenames):
  2163. for fname in filenames:
  2164. with open(os.path.join(dirname, fname), 'w') as f:
  2165. f.write('')
  2166. project_dir = tempfile.mkdtemp()
  2167. try:
  2168. make_files(project_dir, filenames)
  2169. if subdir:
  2170. base_dir = tempfile.mkdtemp(dir=project_dir)
  2171. else:
  2172. base_dir = project_dir
  2173. filename, = config.get_default_config_files(base_dir)
  2174. return os.path.basename(filename)
  2175. finally:
  2176. shutil.rmtree(project_dir)