config_test.py 177 KB

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