network.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. import logging
  2. import re
  3. from collections import OrderedDict
  4. from operator import itemgetter
  5. from docker.errors import NotFound
  6. from docker.types import IPAMConfig
  7. from docker.types import IPAMPool
  8. from docker.utils import version_gte
  9. from docker.utils import version_lt
  10. from . import __version__
  11. from .config import ConfigurationError
  12. from .const import LABEL_NETWORK
  13. from .const import LABEL_PROJECT
  14. from .const import LABEL_VERSION
  15. log = logging.getLogger(__name__)
  16. OPTS_EXCEPTIONS = [
  17. 'com.docker.network.driver.overlay.vxlanid_list',
  18. 'com.docker.network.windowsshim.hnsid',
  19. 'com.docker.network.windowsshim.networkname'
  20. ]
  21. class Network:
  22. def __init__(self, client, project, name, driver=None, driver_opts=None,
  23. ipam=None, external=False, internal=False, enable_ipv6=False,
  24. labels=None, custom_name=False):
  25. self.client = client
  26. self.project = project
  27. self.name = name
  28. self.driver = driver
  29. self.driver_opts = driver_opts
  30. self.ipam = create_ipam_config_from_dict(ipam)
  31. self.external = external
  32. self.internal = internal
  33. self.enable_ipv6 = enable_ipv6
  34. self.labels = labels
  35. self.custom_name = custom_name
  36. self.legacy = None
  37. def ensure(self):
  38. if self.external:
  39. if self.driver == 'overlay':
  40. # Swarm nodes do not register overlay networks that were
  41. # created on a different node unless they're in use.
  42. # See docker/compose#4399
  43. return
  44. try:
  45. self.inspect()
  46. log.debug(
  47. 'Network {} declared as external. No new '
  48. 'network will be created.'.format(self.name)
  49. )
  50. except NotFound:
  51. raise ConfigurationError(
  52. 'Network {name} declared as external, but could'
  53. ' not be found. Please create the network manually'
  54. ' using `{command} {name}` and try again.'.format(
  55. name=self.full_name,
  56. command='docker network create'
  57. )
  58. )
  59. return
  60. self._set_legacy_flag()
  61. try:
  62. data = self.inspect(legacy=self.legacy)
  63. check_remote_network_config(data, self)
  64. except NotFound:
  65. driver_name = 'the default driver'
  66. if self.driver:
  67. driver_name = 'driver "{}"'.format(self.driver)
  68. log.info(
  69. 'Creating network "{}" with {}'.format(self.full_name, driver_name)
  70. )
  71. self.client.create_network(
  72. name=self.full_name,
  73. driver=self.driver,
  74. options=self.driver_opts,
  75. ipam=self.ipam,
  76. internal=self.internal,
  77. enable_ipv6=self.enable_ipv6,
  78. labels=self._labels,
  79. attachable=version_gte(self.client._version, '1.24') or None,
  80. check_duplicate=True,
  81. )
  82. def remove(self):
  83. if self.external:
  84. log.info("Network %s is external, skipping", self.true_name)
  85. return
  86. log.info("Removing network {}".format(self.true_name))
  87. self.client.remove_network(self.true_name)
  88. def inspect(self, legacy=False):
  89. if legacy:
  90. return self.client.inspect_network(self.legacy_full_name)
  91. return self.client.inspect_network(self.full_name)
  92. @property
  93. def legacy_full_name(self):
  94. if self.custom_name:
  95. return self.name
  96. return '{}_{}'.format(
  97. re.sub(r'[_-]', '', self.project), self.name
  98. )
  99. @property
  100. def full_name(self):
  101. if self.custom_name:
  102. return self.name
  103. return '{}_{}'.format(self.project, self.name)
  104. @property
  105. def true_name(self):
  106. self._set_legacy_flag()
  107. if self.legacy:
  108. return self.legacy_full_name
  109. return self.full_name
  110. @property
  111. def _labels(self):
  112. if version_lt(self.client._version, '1.23'):
  113. return None
  114. labels = self.labels.copy() if self.labels else {}
  115. labels.update({
  116. LABEL_PROJECT: self.project,
  117. LABEL_NETWORK: self.name,
  118. LABEL_VERSION: __version__,
  119. })
  120. return labels
  121. def _set_legacy_flag(self):
  122. if self.legacy is not None:
  123. return
  124. try:
  125. data = self.inspect(legacy=True)
  126. self.legacy = data is not None
  127. except NotFound:
  128. self.legacy = False
  129. def create_ipam_config_from_dict(ipam_dict):
  130. if not ipam_dict:
  131. return None
  132. return IPAMConfig(
  133. driver=ipam_dict.get('driver') or 'default',
  134. pool_configs=[
  135. IPAMPool(
  136. subnet=config.get('subnet'),
  137. iprange=config.get('ip_range'),
  138. gateway=config.get('gateway'),
  139. aux_addresses=config.get('aux_addresses'),
  140. )
  141. for config in ipam_dict.get('config', [])
  142. ],
  143. options=ipam_dict.get('options')
  144. )
  145. class NetworkConfigChangedError(ConfigurationError):
  146. def __init__(self, net_name, property_name):
  147. super().__init__(
  148. 'Network "{}" needs to be recreated - {} has changed'.format(
  149. net_name, property_name
  150. )
  151. )
  152. def check_remote_ipam_config(remote, local):
  153. remote_ipam = remote.get('IPAM')
  154. ipam_dict = create_ipam_config_from_dict(local.ipam)
  155. if local.ipam.get('driver') and local.ipam.get('driver') != remote_ipam.get('Driver'):
  156. raise NetworkConfigChangedError(local.true_name, 'IPAM driver')
  157. if len(ipam_dict['Config']) != 0:
  158. if len(ipam_dict['Config']) != len(remote_ipam['Config']):
  159. raise NetworkConfigChangedError(local.true_name, 'IPAM configs')
  160. remote_configs = sorted(remote_ipam['Config'], key='Subnet')
  161. local_configs = sorted(ipam_dict['Config'], key='Subnet')
  162. while local_configs:
  163. lc = local_configs.pop()
  164. rc = remote_configs.pop()
  165. if lc.get('Subnet') != rc.get('Subnet'):
  166. raise NetworkConfigChangedError(local.true_name, 'IPAM config subnet')
  167. if lc.get('Gateway') is not None and lc.get('Gateway') != rc.get('Gateway'):
  168. raise NetworkConfigChangedError(local.true_name, 'IPAM config gateway')
  169. if lc.get('IPRange') != rc.get('IPRange'):
  170. raise NetworkConfigChangedError(local.true_name, 'IPAM config ip_range')
  171. if sorted(lc.get('AuxiliaryAddresses')) != sorted(rc.get('AuxiliaryAddresses')):
  172. raise NetworkConfigChangedError(local.true_name, 'IPAM config aux_addresses')
  173. remote_opts = remote_ipam.get('Options') or {}
  174. local_opts = local.ipam.get('Options') or {}
  175. for k in set.union(set(remote_opts.keys()), set(local_opts.keys())):
  176. if remote_opts.get(k) != local_opts.get(k):
  177. raise NetworkConfigChangedError(local.true_name, 'IPAM option "{}"'.format(k))
  178. def check_remote_network_config(remote, local):
  179. if local.driver and remote.get('Driver') != local.driver:
  180. raise NetworkConfigChangedError(local.true_name, 'driver')
  181. local_opts = local.driver_opts or {}
  182. remote_opts = remote.get('Options') or {}
  183. for k in set.union(set(remote_opts.keys()), set(local_opts.keys())):
  184. if k in OPTS_EXCEPTIONS:
  185. continue
  186. if remote_opts.get(k) != local_opts.get(k):
  187. raise NetworkConfigChangedError(local.true_name, 'option "{}"'.format(k))
  188. if local.ipam is not None:
  189. check_remote_ipam_config(remote, local)
  190. if local.internal is not None and local.internal != remote.get('Internal', False):
  191. raise NetworkConfigChangedError(local.true_name, 'internal')
  192. if local.enable_ipv6 is not None and local.enable_ipv6 != remote.get('EnableIPv6', False):
  193. raise NetworkConfigChangedError(local.true_name, 'enable_ipv6')
  194. local_labels = local.labels or {}
  195. remote_labels = remote.get('Labels') or {}
  196. for k in set.union(set(remote_labels.keys()), set(local_labels.keys())):
  197. if k.startswith('com.docker.'): # We are only interested in user-specified labels
  198. continue
  199. if remote_labels.get(k) != local_labels.get(k):
  200. log.warning(
  201. 'Network {}: label "{}" has changed. It may need to be'
  202. ' recreated.'.format(local.true_name, k)
  203. )
  204. def build_networks(name, config_data, client):
  205. network_config = config_data.networks or {}
  206. networks = {
  207. network_name: Network(
  208. client=client, project=name,
  209. name=data.get('name', network_name),
  210. driver=data.get('driver'),
  211. driver_opts=data.get('driver_opts'),
  212. ipam=data.get('ipam'),
  213. external=bool(data.get('external', False)),
  214. internal=data.get('internal'),
  215. enable_ipv6=data.get('enable_ipv6'),
  216. labels=data.get('labels'),
  217. custom_name=data.get('name') is not None,
  218. )
  219. for network_name, data in network_config.items()
  220. }
  221. if 'default' not in networks:
  222. networks['default'] = Network(client, name, 'default')
  223. return networks
  224. class ProjectNetworks:
  225. def __init__(self, networks, use_networking):
  226. self.networks = networks or {}
  227. self.use_networking = use_networking
  228. @classmethod
  229. def from_services(cls, services, networks, use_networking):
  230. service_networks = {
  231. network: networks.get(network)
  232. for service in services
  233. for network in get_network_names_for_service(service)
  234. }
  235. unused = set(networks) - set(service_networks) - {'default'}
  236. if unused:
  237. log.warning(
  238. "Some networks were defined but are not used by any service: "
  239. "{}".format(", ".join(unused)))
  240. return cls(service_networks, use_networking)
  241. def remove(self):
  242. if not self.use_networking:
  243. return
  244. for network in self.networks.values():
  245. try:
  246. network.remove()
  247. except NotFound:
  248. log.warning("Network %s not found.", network.true_name)
  249. def initialize(self):
  250. if not self.use_networking:
  251. return
  252. for network in self.networks.values():
  253. network.ensure()
  254. def get_network_defs_for_service(service_dict):
  255. if 'network_mode' in service_dict:
  256. return {}
  257. networks = service_dict.get('networks', {'default': None})
  258. return {
  259. net: (config or {})
  260. for net, config in networks.items()
  261. }
  262. def get_network_names_for_service(service_dict):
  263. return get_network_defs_for_service(service_dict).keys()
  264. def get_networks(service_dict, network_definitions):
  265. networks = {}
  266. for name, netdef in get_network_defs_for_service(service_dict).items():
  267. network = network_definitions.get(name)
  268. if network:
  269. networks[network.true_name] = netdef
  270. else:
  271. raise ConfigurationError(
  272. 'Service "{}" uses an undefined network "{}"'
  273. .format(service_dict['name'], name))
  274. if any([v.get('priority') for v in networks.values()]):
  275. return OrderedDict(sorted(
  276. networks.items(),
  277. key=lambda t: t[1].get('priority') or 0, reverse=True
  278. ))
  279. else:
  280. # Ensure Compose will pick a consistent primary network if no
  281. # priority is set
  282. return OrderedDict(sorted(networks.items(), key=itemgetter(0)))