config_test.py 81 KB

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