Many improvements to docker network and container states

Much Improved Support for Docker Networking
===========================================

The `docker_network.present` state has undergone a full rewrite, which
includes the following improvements:

Full API Support for Network Management
---------------------------------------

The improvements made to input handling in the
`docker_container.running` state for 2017.7.0 have now been expanded to
docker_network.present`.  This brings with it full support for all
tunable configuration arguments.

Custom Subnets
--------------

Custom subnets can now be configured. Both IPv4 and mixed IPv4/IPv6
networks are supported.

Network Configuration in :py:func:`docker_container.running` States
-------------------------------------------------------------------

It is now possible to configure static IPv4/IPv6 addresses, as well as
links and labels.

Improved Handling of Images from Custom Registries
==================================================

Rather than attempting to parse the tag from the passed image name, Salt
will now resolve that tag down to an image ID and use that ID instead.

Due to this change, there are some backward-incompatible changes to
image management. See below for a full list of these changes.

Backward-incompatible Changes to Docker Image Management
--------------------------------------------------------

Passing image names to the following functions must now be done using separate
`repository` and `tag` arguments:

- `docker.build`
- `docker.commit`
- `docker.import`
- `docker.load`
- `docker.tag`
- `docker.sls_build`

Additionally, the `tag` argument must now be explicitly passed to the
`docker_image.present` state, unless the image is being pulled from a
docker registry.
This commit is contained in:
Erik Johnson 2017-10-31 21:04:41 -05:00
parent f7a0979449
commit 64aa4fbbec
No known key found for this signature in database
GPG key ID: 5E5583C437808F3F
20 changed files with 6093 additions and 3077 deletions

View file

@ -1292,8 +1292,42 @@ The password used for HTTP proxy access.
proxy_password: obolus
.. conf_minion:: docker.compare_container_networks
``docker.compare_container_networks``
-------------------------------------
.. versionadded:: Oxygen
Default: ``{'static': ['Aliases', 'Links', 'IPAMConfig'], 'automatic': ['IPAddress', 'Gateway', 'GlobalIPv6Address', 'IPv6Gateway']}``
Specifies which keys are examined by
:py:func:`docker.compare_container_networks
<salt.modules.dockermod.compare_container_networks>`.
.. note::
This should not need to be modified unless new features added to Docker
result in new keys added to the network configuration which must be
compared to determine if two containers have different network configs.
This config option exists solely as a way to allow users to continue using
Salt to manage their containers after an API change, without waiting for a
new Salt release to catch up to the changes in the Docker API.
.. code-block:: yaml
docker.compare_container_networks:
static:
- Aliases
- Links
- IPAMConfig
automatic:
- IPAddress
- Gateway
- GlobalIPv6Address
- IPv6Gateway
Minion Execution Module Management
========================
==================================
.. conf_minion:: disable_modules
@ -1303,7 +1337,7 @@ Minion Execution Module Management
Default: ``[]`` (all execution modules are enabled by default)
The event may occur in which the administrator desires that a minion should not
be able to execute a certain module.
be able to execute a certain module.
However, the ``sys`` module is built into the minion and cannot be disabled.

View file

@ -4,6 +4,80 @@
Salt Release Notes - Codename Oxygen
====================================
Lots of Docker Improvements
---------------------------
Much Improved Support for Docker Networking
===========================================
The :py:func:`docker_network.present <salt.states.docker_network.present>`
state has undergone a full rewrite, which includes the following improvements:
Full API Support for Network Management
---------------------------------------
The improvements made to input handling in the
:py:func:`docker_container.running <salt.states.docker_container.running>`
state for 2017.7.0 have now been expanded to :py:func:`docker_network.present
<salt.states.docker_network.present>`. This brings with it full support for all
tunable configuration arguments.
Custom Subnets
--------------
Custom subnets can now be configured. Both IPv4 and mixed IPv4/IPv6 networks
are supported. See :ref:`here <salt-states-docker-network-present-ipam>` for
more information.
Network Configuration in :py:func:`docker_container.running` States
-------------------------------------------------------------------
A long-requested feature has finally been added! It is now possible to
configure static IPv4/IPv6 addresses, as well as links and labels. See
:ref:`here <salt-states-docker-container-network-management>` for more
informaion.
.. note::
While the ``containers`` argument to :py:func:`docker_network.present`
will continue to be supported, it will no longer be the recommended way of
ensuring that a container is attached to a network.
Improved Handling of Images from Custom Registries
==================================================
Rather than attempting to parse the tag from the passed image name, Salt will
now resolve that tag down to an image ID and use that ID instead.
.. important::
Due to this change, there are some backward-incompatible changes to image
management. See below for a full list of these changes.
Backward-incompatible Changes to Docker Image Management
********************************************************
Passing image names to the following functions must now be done using separate
``repository`` and ``tag`` arguments:
- :py:func:`docker.build <salt.modules.dockermod.build>`
- :py:func:`docker.commit <salt.modules.dockermod.commit>`
- :py:func:`docker.import <salt.modules.dockermod.import_>`
- :py:func:`docker.load <salt.modules.dockermod.load>`
- :py:func:`docker.tag <salt.modules.dockermod.tag_>`
- :py:func:`docker.sls_build <salt.modules.dockermod.sls_build>`
Additionally, the ``tag`` argument must now be explicitly passed to the
:py:func:`docker_image.present <salt.states.docker_image.present>` state,
unless the image is being pulled from a docker registry.
`start` Argument Added to :py:func:`docker.create <salt.modules.dockermod.create>` Function
-------------------------------------------------------------------------------------------
This allows for ``docker run``-like functionality. For example:
.. code-block:: bash
salt myminion docker.create image=foo/bar:baz command=/path/to/command start=True
Comparison Operators in Package Installation
--------------------------------------------

View file

@ -1153,6 +1153,9 @@ VALID_OPTS = {
# part of the extra_minion_data param
# Subconfig entries can be specified by using the ':' notation (e.g. key:subkey)
'pass_to_ext_pillars': (six.string_types, list),
# Used by salt.modules.dockermod.compare_container_networks to specify which keys are compared
'docker.compare_container_networks': dict,
}
# default configurations
@ -1432,6 +1435,11 @@ DEFAULT_MINION_OPTS = {
'extmod_whitelist': {},
'extmod_blacklist': {},
'minion_sign_messages': False,
'docker.compare_container_networks': {
'static': ['Aliases', 'Links', 'IPAMConfig'],
'automatic': ['IPAddress', 'Gateway',
'GlobalIPv6Address', 'IPv6Gateway'],
},
}
DEFAULT_MASTER_OPTS = {

File diff suppressed because it is too large Load diff

View file

@ -52,6 +52,7 @@ import logging
from salt.exceptions import CommandExecutionError
import copy
import salt.utils.args
import salt.utils.data
import salt.utils.docker
from salt.ext import six
@ -94,6 +95,8 @@ def _check_diff(changes):
https://github.com/saltstack/salt/pull/39996#issuecomment-288025200
'''
for conf_dict in changes:
if conf_dict == 'Networks':
continue
for item in changes[conf_dict]:
if changes[conf_dict][item]['new'] is None:
old = changes[conf_dict][item]['old']
@ -109,6 +112,16 @@ def _check_diff(changes):
return False
def _get_networks(data):
'''
Pass either a container name/ID or the result of inspecting the container,
and get back the container's networks.
'''
if not isinstance(data, dict):
data = __salt__['docker.inspect_container'](data)
return data.get('NetworkSettings', {}).get('Networks', {})
def running(name,
image=None,
skip_translate=None,
@ -117,8 +130,9 @@ def running(name,
force=False,
watch_action='force',
start=True,
shutdown_timeout=salt.utils.docker.SHUTDOWN_TIMEOUT,
shutdown_timeout=None,
client_timeout=salt.utils.docker.CLIENT_TIMEOUT,
networks=None,
**kwargs):
'''
Ensure that a container with a specific configuration is present and
@ -128,12 +142,9 @@ def running(name,
Name of the container
image
Image to use for the container. Image names can be specified either
using ``repo:tag`` notation, or just the repo name (in which case a tag
of ``latest`` is assumed).
Image to use for the container
.. note::
This state will pull the image if it is not present. However, if
the image needs to be built from a Dockerfile or loaded from a
saved image, or if you would like to use requisites to trigger a
@ -182,7 +193,7 @@ def running(name,
docker_container.running:
- image: 7.3.1611
- skip_translate: port_bindings
- port_bindings: {8080: [('10.2.9.10', 80)], '4193/udp', 9314}
- port_bindings: {8080: [('10.2.9.10', 80)], '4193/udp': 9314}
See the following links for more information:
@ -191,7 +202,7 @@ def running(name,
.. _docker-py: https://pypi.python.org/pypi/docker-py
.. _`docker-py Low-level API`: http://docker-py.readthedocs.io/en/stable/api.html#docker.api.container.ContainerApiMixin.create_container
.. _`Docker Engine API`: https://docs.docker.com/engine/api/v1.26/#operation/ContainerCreate
.. _`Docker Engine API`: https://docs.docker.com/engine/api/v1.33/#operation/ContainerCreate
ignore_collisions : False
Since many of docker-py_'s arguments differ in name from their CLI
@ -247,12 +258,12 @@ def running(name,
instances such as this, the container only needs to be started the
first time.
shutdown_timeout : 10
shutdown_timeout
If the container needs to be replaced, the container will be stopped
using :py:func:`docker.stop <salt.modules.dockermod.stop>`. The value
of this parameter will be passed to :py:func:`docker.stop
<salt.modules.dockermod.stop>` as the ``timeout`` value, telling Docker
how long to wait for a graceful shutdown before killing the container.
using :py:func:`docker.stop <salt.modules.dockermod.stop>`. If a
``shutdown_timout`` is not set, and the container was created using
``stop_timeout``, that timeout will be used. If neither of these values
were set, then a timeout of 10 seconds will be used.
.. versionchanged:: 2017.7.0
This option was renamed from ``stop_timeout`` to
@ -266,6 +277,44 @@ def running(name,
.. note::
This is only used if Salt needs to pull the requested image.
.. _salt-states-docker-container-network-management:
**NETWORK MANAGEMENT**
.. versionadded:: Oxygen
The ``networks`` argument can be used to ensure that a container is
attached to one or more networks. Optionally, arguments can be passed to
the networks. In the example below, ``net1`` is being configured with
arguments, while ``net2`` is being configured *without* arguments:
.. code-block:: yaml
foo:
docker_container.running:
- image: myuser/myimage:foo
- networks:
- net1:
- aliases:
- bar
- baz
- ipv4_address: 10.0.20.50
- net2
- require:
- docker_network: net1
- docker_network: net2
The supported arguments are the ones from the docker-py's
`connect_container_to_network`_ function (other than ``container`` and
``net_id``).
.. important::
Unlike with the arguments described in the **CONTAINER CONFIGURATION
PARAMETERS** section below, these network configuration parameters are
not translated at all. Consult the `connect_container_to_network`_
documentation for the correct type/format of data to pass.
.. _`connect_container_to_network`: https://docker-py.readthedocs.io/en/stable/api.html#docker.api.network.NetworkApiMixin.connect_container_to_network
**CONTAINER CONFIGURATION PARAMETERS**
@ -865,69 +914,36 @@ def running(name,
labels
Add metadata to the container. Labels can be set both with and without
values:
**WITH VALUES**
The below three examples are equivalent:
values, and labels with values can be passed either as ``key=value`` or
``key: value `` pairs. For example, while the below would be very
confusing to read, it is technically valid, and demonstrates the
different ways in which labels can be passed:
.. code-block:: yaml
foo:
docker_container.running:
- image: bar/baz:latest
- labels: label1=value1,label2=value2
.. code-block:: yaml
foo:
docker_container.running:
- image: bar/baz:latest
mynet:
docker_network.present:
- labels:
- label1=value1
- label2=value2
- foo
- bar=baz
- hello: world
The labels can also simply be passed as a YAML dictionary, though this
can be error-prone due to some :ref:`idiosyncrasies
<yaml-idiosyncrasies>` with how PyYAML loads nested data structures:
.. code-block:: yaml
foo:
docker_container.running:
- image: bar/baz:latest
docker_network.present:
- labels:
- label1: value1
- label2: value2
foo: ''
bar: baz
hello: world
The labels can also simply be passed as a dictionary, though this can
be error-prone due to some :ref:`idiosyncrasies <yaml-idiosyncrasies>`
with how PyYAML loads nested data structures:
.. code-block:: yaml
foo:
docker_container.running:
- image: bar/baz:latest
- labels:
label1: value1
label2: value2
**WITHOUT VALUES**
The below two examples are equivalent:
.. code-block:: yaml
foo:
docker_container.running:
- image: bar/baz:latest
- labels: label1,label2
.. code-block:: yaml
foo:
docker_container.running:
- image: bar/baz:latest
- labels:
- label1
- label2
.. versionchanged:: Oxygen
Methods for specifying labels can now be mixed. Earlier releases
required either labels with or without values.
links
Link this container to another. Links can be specified as a list of
@ -1543,105 +1559,137 @@ def running(name,
'''
ret = {'name': name,
'changes': {},
'result': False,
'result': True,
'comment': ''}
if image is None:
ret['result'] = False
ret['comment'] = 'The \'image\' argument is required'
return ret
elif not isinstance(image, six.string_types):
image = str(image)
networks = salt.utils.args.split_input(networks or [])
if not networks:
networks = {}
else:
# We don't want to recurse the repack, as the values of the kwargs
# being passed when connecting to the network will not be dictlists.
networks = salt.utils.data.repack_dictlist(networks)
if not networks:
ret['result'] = False
ret['comment'] = 'Invalid network configuration (see documentation)'
return ret
for net_name, net_conf in six.iteritems(networks):
if net_conf is None:
networks[net_name] = {}
else:
networks[net_name] = salt.utils.data.repack_dictlist(net_conf)
if not networks[net_name]:
ret['result'] = False
ret['comment'] = (
'Invalid configuration for network \'{0}\' '
'(see documentation)'.format(net_name)
)
return ret
for key in ('links', 'aliases'):
try:
networks[net_name][key] = salt.utils.args.split_input(
networks[net_name][key]
)
except KeyError:
continue
# Iterate over the networks again now, looking for
# incorrectly-formatted arguments
errors = []
for net_name, net_conf in six.iteritems(networks):
if net_conf is not None:
for key, val in six.iteritems(net_conf):
if val is None:
errors.append(
'Config option \'{0}\' for network \'{1}\' is '
'missing a value'.format(key, net_name)
)
if errors:
ret['result'] = False
return _format_comments(ret, errors)
comments = []
if networks:
try:
all_networks = [
x['Name'] for x in __salt__['docker.networks']()
if 'Name' in x
]
except CommandExecutionError as exc:
ret.setdefault('warnings', []).append(
'Failed to get list of networks: {0}.'.format(exc)
)
else:
missing_networks = [
x for x in sorted(networks) if x not in all_networks]
if missing_networks:
ret['result'] = False
comments.append(
'The following networks are not present: {0}'.format(
', '.join(missing_networks)
)
)
return _format_comments(ret, comments)
# Pop off the send_signal argument passed by the watch requisite
send_signal = kwargs.pop('send_signal', False)
# Resolve the full name of the tag
expanded_tag = ':'.join(salt.utils.docker.get_repo_tag(image))
# Get all tags on the minion
all_tags = __salt__['docker.list_tags']()
image_id = __salt__['docker.resolve_image_id'](image)
resolved_id = None
if expanded_tag in all_tags:
image = expanded_tag
else:
# The passed image may have been an ID. Try to resolve the image ID by
# using docker.inspect_image.
try:
resolved_id = __salt__['docker.inspect_image'](image)['Id']
except CommandExecutionError:
# No matching image ID
pass
except KeyError:
ret.setdefault('warnings', []).append(
'No \'Id\' key in image inspect API return, this may be due '
'to a change in docker-py.'
)
if resolved_id is not None:
image = resolved_id
elif not __opts__['test']:
# Since we did not resolve the passed image to one of the image IDs
# on the minion, assume that the passed image is a tag that is not
# present on the minion (and must therefore be pulled).
if image_id is False:
if not __opts__['test']:
# Image not pulled locally, so try pulling it
try:
pull_result = __salt__['docker.pull'](
image,
client_timeout=client_timeout,
)
except Exception as exc:
ret['comment'] = 'Failed to pull {0}: {1}'.format(image, exc)
return ret
ret['result'] = False
comments.append('Failed to pull {0}: {1}'.format(image, exc))
return _format_comments(ret, comments)
else:
ret['changes']['image'] = pull_result
# Try resolving again now that we've pulled
image_id = __salt__['docker.resolve_image_id'](image)
if image_id is False:
# Shouldn't happen unless the pull failed
ret['result'] = False
comments.append(
'Image \'{0}\' not present despite a docker pull '
'raising no errors'.format(image)
)
return _format_comments(ret, comments)
if resolved_id is not None:
# No need to repeat the inspect
image_id = resolved_id
else:
try:
image_id = __salt__['docker.inspect_image'](image)['Id']
except CommandExecutionError:
if not __opts__['test']:
# Shouldn't happen unless the pull failed
ret['comment'] = (
'Image \'{0}\' not present despite a docker pull raising '
'no errors'.format(image)
)
return ret
image_id = None
if name in __salt__['docker.list_containers'](all=True):
try:
try:
current_image_id = \
__salt__['docker.inspect_container'](name)['Image']
except KeyError:
ret['comment'] = (
'Unable to detect current image for container \'{0}\'. '
'This might be due to a change in the Docker API.'
.format(name)
)
return ret
except CommandExecutionError as exc:
ret['comment'] = ('Error occurred checking for existence of '
'container \'{0}\': {1}'.format(name, exc))
return ret
else:
try:
current_image_id = __salt__['docker.inspect_container'](name)['Image']
except CommandExecutionError:
current_image_id = None
except KeyError:
ret['result'] = False
comments.append(
'Unable to detect current image for container \'{0}\'. '
'This might be due to a change in the Docker API.'.format(name)
)
return _format_comments(ret, comments)
# Shorthand to make the below code more understandable
exists = current_image_id is not None
if exists:
pre_state = __salt__['docker.state'](name)
else:
pre_state = None
pre_state = __salt__['docker.state'](name) if exists else None
# If True, we're definitely going to be using the temp container as the new
# container (because we're forcing the change, or because the image IDs
# differ). If False, we'll need to perform a comparison between it and the
# new container.
# If skip_comparison is True, we're definitely going to be using the temp
# container as the new container (because we're forcing the change, or
# because the image IDs differ). If False, we'll need to perform a
# comparison between it and the new container.
skip_comparison = force or not exists or current_image_id != image_id
if skip_comparison and __opts__['test']:
@ -1658,10 +1706,11 @@ def running(name,
)
return _format_comments(ret, comments)
# Create temp container
# Create temp container (or just create the named container if the
# container does not already exist)
try:
temp_container = __salt__['docker.create'](
image,
image_id,
name=name if not exists else None,
skip_translate=skip_translate,
ignore_collisions=ignore_collisions,
@ -1670,6 +1719,7 @@ def running(name,
**kwargs)
temp_container_name = temp_container['Name']
except KeyError as exc:
ret['result'] = False
comments.append(
'Key \'{0}\' missing from API response, this may be due to a '
'change in the Docker Remote API. Please report this on the '
@ -1678,6 +1728,7 @@ def running(name,
)
return _format_comments(ret, comments)
except Exception as exc:
ret['result'] = False
msg = exc.__str__()
if isinstance(exc, CommandExecutionError) \
and isinstance(exc.info, dict) and 'invalid' in exc.info:
@ -1691,8 +1742,11 @@ def running(name,
return _format_comments(ret, comments)
def _replace(orig, new):
rm_kwargs = {'stop': True}
if shutdown_timeout is not None:
rm_kwargs['timeout'] = shutdown_timeout
ret['changes'].setdefault('container_id', {})['removed'] = \
__salt__['docker.rm'](name, stop=True)
__salt__['docker.rm'](name, **rm_kwargs)
try:
result = __salt__['docker.rename'](new, orig)
except CommandExecutionError as exc:
@ -1704,22 +1758,46 @@ def running(name,
comments.append('Failed to replace container \'{0}\'')
return result
def _delete_temp_container():
log.debug('Removing temp container \'%s\'', temp_container_name)
__salt__['docker.rm'](temp_container_name)
# If we're not skipping the comparison, then the assumption is that
# temp_container will be discarded, unless the comparison reveals
# differences, in which case we'll set cleanup_temp = False to prevent it
# from being cleaned.
cleanup_temp = not skip_comparison
try:
pre_net_connect = __salt__['docker.inspect_container'](
name if exists else temp_container_name)
for net_name, net_conf in six.iteritems(networks):
try:
__salt__['docker.connect_container_to_network'](
temp_container_name,
net_name,
**net_conf)
except CommandExecutionError as exc:
# Shouldn't happen, stopped docker containers can be
# attached to networks even if the static IP lies outside
# of the network's subnet. An exception will be raised once
# you try to start the container, however.
ret['result'] = False
comments.append(exc.__str__())
return _format_comments(ret, comments)
post_net_connect = __salt__['docker.inspect_container'](
temp_container_name)
net_changes = __salt__['docker.compare_container_networks'](
pre_net_connect, post_net_connect)
if not skip_comparison:
changes = __salt__['docker.compare_container'](
container_changes = __salt__['docker.compare_containers'](
name,
temp_container_name,
ignore='Hostname',
)
if changes:
ret['changes']['container'] = changes
if _check_diff(changes):
if container_changes:
if _check_diff(container_changes):
ret.setdefault('warnings', []).append(
'The detected changes may be due to incorrect '
'handling of arguments in earlier Salt releases. If '
@ -1730,6 +1808,8 @@ def running(name,
)
)
changes_ptr = ret['changes'].setdefault('container', {})
changes_ptr.update(container_changes)
if __opts__['test']:
ret['result'] = None
comments.append(
@ -1738,41 +1818,53 @@ def running(name,
'created' if not exists else 'replaced'
)
)
return _format_comments(ret, comments)
else:
# We don't want to clean the temp container, we'll be
# replacing the existing one with it.
cleanup_temp = False
# Replace the container
if not _replace(name, temp_container_name):
ret['result'] = False
return _format_comments(ret, comments)
ret['changes'].setdefault('container_id', {})['added'] = \
temp_container['Id']
else:
# No changes between existing container and temp container.
# First check if a requisite is asking to send a signal to the
# existing container.
if send_signal:
try:
__salt__['docker.signal'](name, signal=watch_action)
except CommandExecutionError as exc:
if __opts__['test']:
comments.append(
'Failed to signal container: {0}'.format(exc)
'Signal {0} would be sent to container'.format(
watch_action
)
)
return _format_comments(ret, comments)
else:
ret['changes']['signal'] = watch_action
comments.append(
'Sent signal {0} to container'.format(watch_action)
)
elif ret['changes']:
try:
__salt__['docker.signal'](name, signal=watch_action)
except CommandExecutionError as exc:
ret['result'] = False
comments.append(
'Failed to signal container: {0}'.format(exc)
)
return _format_comments(ret, comments)
else:
ret['changes']['signal'] = watch_action
comments.append(
'Sent signal {0} to container'.format(watch_action)
)
elif container_changes:
if not comments:
log.warning(
'docker_container.running: detected changes without '
'a specific comment for container \'%s\'', name
)
comments.append('Container \'{0}\' changed.'.format(name))
comments.append(
'Container \'{0}\'{1} updated.'.format(
name,
' would be' if __opts__['test'] else ''
)
)
else:
# Container was not replaced, no differences between the
# existing container and the temp container were detected,
@ -1781,14 +1873,89 @@ def running(name,
'Container \'{0}\' is already configured as specified'
.format(name)
)
if net_changes:
ret['changes'].setdefault('container', {})['Networks'] = net_changes
if __opts__['test']:
ret['result'] = None
comments.append('Network configuration would be updated')
elif cleanup_temp:
# We only need to make network changes if the container
# isn't being replaced, since we would already have
# attached all the networks for purposes of comparison.
network_failure = False
for net_name in sorted(net_changes):
errors = []
disconnected = connected = False
try:
if name in __salt__['docker.connected'](net_name):
__salt__['docker.disconnect_container_from_network'](
name,
net_name)
disconnected = True
except CommandExecutionError as exc:
errors.append(exc.__str__())
if net_name in networks:
try:
__salt__['docker.connect_container_to_network'](
name,
net_name,
**networks[net_name])
connected = True
except CommandExecutionError as exc:
errors.append(exc.__str__())
if disconnected:
# We succeeded in disconnecting but failed
# to reconnect. This can happen if the
# network's subnet has changed and we try
# to reconnect with the same IP address
# from the old subnet.
for item in list(net_changes[net_name]):
if net_changes[net_name][item]['old'] is None:
# Since they'd both be None, just
# delete this key from the changes
del net_changes[net_name][item]
else:
net_changes[net_name][item]['new'] = None
if errors:
comments.extend(errors)
network_failure = True
ret['changes'].setdefault(
'container', {}).setdefault(
'Networks', {})[net_name] = net_changes[net_name]
if disconnected and connected:
comments.append(
'Reconnected to network \'{0}\' with updated '
'configuration'.format(net_name)
)
elif disconnected:
comments.append(
'Disconnected from network \'{0}\''.format(
net_name
)
)
elif connected:
comments.append(
'Connected to network \'{0}\''.format(net_name)
)
if network_failure:
ret['result'] = False
return _format_comments(ret, comments)
finally:
if cleanup_temp:
log.debug('Removing temp container \'%s\'', temp_container_name)
__salt__['docker.rm'](temp_container_name)
_delete_temp_container()
if skip_comparison:
if exists:
if not exists:
comments.append('Created container \'{0}\''.format(name))
else:
if not _replace(name, temp_container):
ret['result'] = False
return _format_comments(ret, comments)
ret['changes'].setdefault('container_id', {})['added'] = \
temp_container['Id']
@ -1804,17 +1971,49 @@ def running(name,
if __opts__['test']:
ret['result'] = None
comments.append('Container would be started')
return _format_comments(ret, comments)
try:
post_state = __salt__['docker.start'](name)['state']['new']
except Exception as exc:
comments.append(
'Failed to start container \'{0}\': \'{1}\''.format(name, exc)
)
return _format_comments(ret, comments)
else:
try:
post_state = __salt__['docker.start'](name)['state']['new']
except Exception as exc:
ret['result'] = False
comments.append(
'Failed to start container \'{0}\': \'{1}\''.format(name, exc)
)
return _format_comments(ret, comments)
else:
post_state = __salt__['docker.state'](name)
if not __opts__['test'] and post_state == 'running':
# Now that we're certain the container is running, check each modified
# network to see if the network went from static (or disconnected) to
# automatic IP configuration. If so, grab the automatically-assigned
# IPs and munge the changes dict to include them. Note that this can
# only be done after the container is started bceause automatic IPs are
# assigned at runtime.
contextkey = '.'.join((name, 'docker_container.running'))
def _get_nets():
if contextkey not in __context__:
new_container_info = \
__salt__['docker.inspect_container'](name)
__context__[contextkey] = new_container_info.get(
'NetworkSettings', {}).get('Networks', {})
return __context__[contextkey]
autoip_keys = __opts__['docker.compare_container_networks'].get('automatic', [])
for net_name, net_changes in six.iteritems(
ret['changes'].get('container', {}).get('Networks', {})):
if 'IPConfiguration' in net_changes \
and net_changes['IPConfiguration']['new'] == 'automatic':
for key in autoip_keys:
val = _get_nets().get(net_name, {}).get(key)
if val:
net_changes[key] = {'old': None, 'new': val}
try:
net_changes.pop('IPConfiguration')
except KeyError:
pass
__context__.pop(contextkey, None)
if pre_state != post_state:
ret['changes']['state'] = {'old': pre_state, 'new': post_state}
if pre_state is not None:
@ -1829,18 +2028,18 @@ def running(name,
ret['changes']['image'] = {'old': current_image_id, 'new': image_id}
if post_state != 'running' and start:
ret['result'] = False
comments.append('Container is not running')
else:
ret['result'] = True
return _format_comments(ret, comments)
def stopped(name=None,
containers=None,
shutdown_timeout=salt.utils.docker.SHUTDOWN_TIMEOUT,
shutdown_timeout=None,
unpause=False,
error_on_absent=True):
error_on_absent=True,
**kwargs):
'''
Ensure that a container (or containers) is stopped
@ -1873,9 +2072,12 @@ def stopped(name=None,
all specified containers in a single run, rather than executing the
state separately on each image (as it would in the first example).
shutdown_timeout : 10
shutdown_timeout
Timeout for graceful shutdown of the container. If this timeout is
exceeded, the container will be killed.
exceeded, the container will be killed. If this value is not passed,
then the container's configured ``stop_timeout`` will be observed. If
``stop_timeout`` was also unset on the container, then a timeout of 10
seconds will be used.
unpause : False
Set to ``True`` to unpause any paused containers before stopping. If
@ -1961,7 +2163,10 @@ def stopped(name=None,
stop_errors = []
for target in to_stop:
changes = __salt__['docker.stop'](target, unpause=unpause)
stop_kwargs = {'unpause': unpause}
if shutdown_timeout:
stop_kwargs['timeout'] = shutdown_timeout
changes = __salt__['docker.stop'](target, **stop_kwargs)
if changes['result'] is True:
ret['changes'][target] = changes
else:

View file

@ -42,6 +42,8 @@ import logging
import salt.utils.docker
import salt.utils.args
from salt.ext.six.moves import zip
from salt.ext import six
from salt.exceptions import CommandExecutionError
# Enable proper logging
log = logging.getLogger(__name__) # pylint: disable=invalid-name
@ -61,6 +63,7 @@ def __virtual__():
def present(name,
tag=None,
build=None,
load=None,
force=False,
@ -72,45 +75,56 @@ def present(name,
saltenv='base',
**kwargs):
'''
Ensure that an image is present. The image can either be pulled from a
Docker registry, built from a Dockerfile, or loaded from a saved image.
Image names can be specified either using ``repo:tag`` notation, or just
the repo name (in which case a tag of ``latest`` is assumed).
Repo identifier is mandatory, we don't assume the default repository
is docker hub.
.. versionchanged:: Oxygen
The ``tag`` argument has been added. It is now required unless pulling
from a registry.
If neither of the ``build`` or ``load`` arguments are used, then Salt will
pull from the :ref:`configured registries <docker-authentication>`. If the
specified image already exists, it will not be pulled unless ``force`` is
set to ``True``. Here is an example of a state that will pull an image from
the Docker Hub:
Ensure that an image is present. The image can either be pulled from a
Docker registry, built from a Dockerfile, loaded from a saved image, or
built by running SLS files against a base image.
If none of the ``build``, ``load``, or ``sls`` arguments are used, then Salt
will pull from the :ref:`configured registries <docker-authentication>`. If
the specified image already exists, it will not be pulled unless ``force``
is set to ``True``. Here is an example of a state that will pull an image
from the Docker Hub:
.. code-block:: yaml
myuser/myimage:mytag:
docker_image.present
myuser/myimage:
docker_image.present:
- tag: mytag
tag
Tag name for the image. Required when using ``build``, ``load``, or
``sls`` to create the image, but optional if pulling from a repository.
.. versionadded:: Oxygen
build
Path to directory on the Minion containing a Dockerfile
.. code-block:: yaml
myuser/myimage:mytag:
myuser/myimage:
docker_image.present:
- build: /home/myuser/docker/myimage
- tag: mytag
myuser/myimage:mytag:
myuser/myimage:
docker_image.present:
- build: /home/myuser/docker/myimage
- tag: mytag
- dockerfile: Dockerfile.alternative
.. versionadded:: 2016.11.0
The image will be built using :py:func:`docker.build
<salt.modules.dockermod.build>` and the specified image name and tag
will be applied to it.
.. versionadded:: 2016.11.0
.. versionchanged: Oxygen
The ``tag`` must be manually specified using the ``tag`` argument.
load
Loads a tar archive created with :py:func:`docker.load
<salt.modules.dockermod.load>` (or the ``docker load`` Docker CLI
@ -118,9 +132,13 @@ def present(name,
.. code-block:: yaml
myuser/myimage:mytag:
myuser/myimage:
docker_image.present:
- load: salt://path/to/image.tar
- tag: mytag
.. versionchanged: Oxygen
The ``tag`` must be manually specified using the ``tag`` argument.
force : False
Set this parameter to ``True`` to force Salt to pull/build/load the
@ -143,8 +161,9 @@ def present(name,
.. code-block:: yaml
myuser/myimage:mytag:
myuser/myimage:
docker_image.present:
- tag: latest
- sls:
- webapp1
- webapp2
@ -152,6 +171,8 @@ def present(name,
- saltenv: base
.. versionadded: 2017.7.0
.. versionchanged: Oxygen
The ``tag`` must be manually specified using the ``tag`` argument.
base
Base image with which to start :py:func:`docker.sls_build
@ -170,29 +191,48 @@ def present(name,
'result': False,
'comment': ''}
if build is not None and load is not None:
ret['comment'] = 'Only one of \'build\' or \'load\' is permitted.'
if not isinstance(name, six.string_types):
name = str(name)
# At most one of the args that result in an image being built can be used
num_build_args = len([x for x in (build, load, sls) if x is not None])
if num_build_args > 1:
ret['comment'] = \
'Only one of \'build\', \'load\', or \'sls\' is permitted.'
return ret
image = ':'.join(salt.utils.docker.get_repo_tag(name))
resolved_tag = __salt__['docker.resolve_tag'](image)
if resolved_tag is False:
# Specified image is not present
image_info = None
elif num_build_args == 1:
# If building, we need the tag to be specified
if not tag:
ret['comment'] = (
'The \'tag\' argument is required if any one of \'build\', '
'\'load\', or \'sls\' is used.'
)
return ret
if not isinstance(tag, six.string_types):
tag = str(tag)
full_image = ':'.join((name, tag))
else:
if tag:
name = '{0}:{1}'.format(name, tag)
full_image = name
try:
image_info = __salt__['docker.inspect_image'](full_image)
except CommandExecutionError as exc:
msg = exc.__str__()
if '404' in msg:
# Image not present
image_info = None
else:
ret['comment'] = msg
return ret
if image_info is not None:
# Specified image is present
if not force:
ret['result'] = True
ret['comment'] = 'Image \'{0}\' already present'.format(name)
ret['comment'] = 'Image {0} already present'.format(full_image)
return ret
else:
try:
image_info = __salt__['docker.inspect_image'](name)
except Exception as exc:
ret['comment'] = \
'Unable to get info for image \'{0}\': {1}'.format(name, exc)
return ret
if build or sls:
action = 'built'
@ -203,12 +243,12 @@ def present(name,
if __opts__['test']:
ret['result'] = None
if (resolved_tag is not False and force) or resolved_tag is False:
ret['comment'] = 'Image \'{0}\' will be {1}'.format(name, action)
if (image_info is not None and force) or image_info is None:
ret['comment'] = 'Image {0} will be {1}'.format(full_image, action)
return ret
if build:
# get the functions default value and args
# Get the functions default value and args
argspec = salt.utils.args.get_function_argspec(__salt__['docker.build'])
# Map any if existing args from kwargs into the build_args dictionary
build_args = dict(list(zip(argspec.args, argspec.defaults)))
@ -218,30 +258,30 @@ def present(name,
try:
# map values passed from the state to the build args
build_args['path'] = build
build_args['image'] = image
build_args['image'] = full_image
build_args['dockerfile'] = dockerfile
image_update = __salt__['docker.build'](**build_args)
except Exception as exc:
ret['comment'] = (
'Encountered error building {0} as {1}: {2}'
.format(build, image, exc)
'Encountered error building {0} as {1}: {2}'.format(
build, full_image, exc
)
)
return ret
if image_info is None or image_update['Id'] != image_info['Id'][:12]:
ret['changes'] = image_update
elif sls:
if isinstance(sls, list):
sls = ','.join(sls)
try:
image_update = __salt__['docker.sls_build'](name=image,
image_update = __salt__['docker.sls_build'](repository=name,
tag=tag,
base=base,
mods=sls,
saltenv=saltenv)
except Exception as exc:
ret['comment'] = (
'Encountered error using sls {0} for building {1}: {2}'
.format(sls, image, exc)
'Encountered error using SLS {0} for building {1}: {2}'
.format(sls, full_image, exc)
)
return ret
if image_info is None or image_update['Id'] != image_info['Id'][:12]:
@ -249,11 +289,13 @@ def present(name,
elif load:
try:
image_update = __salt__['docker.load'](path=load, image=image)
image_update = __salt__['docker.load'](path=load,
repository=name,
tag=tag)
except Exception as exc:
ret['comment'] = (
'Encountered error loading {0} as {1}: {2}'
.format(load, image, exc)
.format(load, full_image, exc)
)
return ret
if image_info is None or image_update.get('Layers', []):
@ -262,13 +304,13 @@ def present(name,
else:
try:
image_update = __salt__['docker.pull'](
image,
name,
insecure_registry=insecure_registry,
client_timeout=client_timeout
)
except Exception as exc:
ret['comment'] = \
'Encountered error pulling {0}: {1}'.format(image, exc)
'Encountered error pulling {0}: {1}'.format(full_image, exc)
return ret
if (image_info is not None and image_info['Id'][:12] == image_update
.get('Layers', {})
@ -280,18 +322,28 @@ def present(name,
# Only add to the changes dict if layers were pulled
ret['changes'] = image_update
ret['result'] = bool(__salt__['docker.resolve_tag'](image))
try:
__salt__['docker.inspect_image'](full_image)
error = False
except CommandExecutionError:
msg = exc.__str__()
if '404' not in msg:
error = 'Failed to inspect image \'{0}\' after it was {1}: {2}'.format(
full_image, action, msg
)
if not ret['result']:
# This shouldn't happen, failure to pull should be caught above
ret['comment'] = 'Image \'{0}\' could not be {1}'.format(name, action)
elif not ret['changes']:
ret['comment'] = (
'Image \'{0}\' was {1}, but there were no changes'
.format(name, action)
)
if error:
ret['comment'] = error
else:
ret['comment'] = 'Image \'{0}\' was {1}'.format(name, action)
ret['result'] = True
if not ret['changes']:
ret['comment'] = (
'Image \'{0}\' was {1}, but there were no changes'.format(
name, action
)
)
else:
ret['comment'] = 'Image \'{0}\' was {1}'.format(full_image, action)
return ret
@ -362,19 +414,16 @@ def absent(name=None, images=None, force=False):
elif name:
targets = [name]
pre_tags = __salt__['docker.list_tags']()
to_delete = []
for target in targets:
resolved_tag = __salt__['docker.resolve_tag'](target, tags=pre_tags)
resolved_tag = __salt__['docker.resolve_tag'](target)
if resolved_tag is not False:
to_delete.append(resolved_tag)
log.debug('targets = {0}'.format(targets))
log.debug('to_delete = {0}'.format(to_delete))
if not to_delete:
ret['result'] = True
if len(targets) == 1:
ret['comment'] = 'Image \'{0}\' is not present'.format(name)
ret['comment'] = 'Image {0} is not present'.format(name)
else:
ret['comment'] = 'All specified images are not present'
return ret
@ -382,11 +431,13 @@ def absent(name=None, images=None, force=False):
if __opts__['test']:
ret['result'] = None
if len(to_delete) == 1:
ret['comment'] = ('Image \'{0}\' will be removed'
.format(to_delete[0]))
ret['comment'] = 'Image {0} will be removed'.format(to_delete[0])
else:
ret['comment'] = ('The following images will be removed: {0}'
.format(', '.join(to_delete)))
ret['comment'] = (
'The following images will be removed: {0}'.format(
', '.join(to_delete)
)
)
return ret
result = __salt__['docker.rmi'](*to_delete, force=force)
@ -397,8 +448,9 @@ def absent(name=None, images=None, force=False):
if [x for x in to_delete if x not in post_tags]:
ret['changes'] = result
ret['comment'] = (
'The following image(s) failed to be removed: {0}'
.format(', '.join(failed))
'The following image(s) failed to be removed: {0}'.format(
', '.join(failed)
)
)
else:
ret['comment'] = 'None of the specified images were removed'
@ -410,11 +462,12 @@ def absent(name=None, images=None, force=False):
else:
ret['changes'] = result
if len(to_delete) == 1:
ret['comment'] = 'Image \'{0}\' was removed'.format(to_delete[0])
ret['comment'] = 'Image {0} was removed'.format(to_delete[0])
else:
ret['comment'] = (
'The following images were removed: {0}'
.format(', '.join(to_delete))
'The following images were removed: {0}'.format(
', '.join(to_delete)
)
)
ret['result'] = True
@ -430,5 +483,5 @@ def mod_watch(name, sfun=None, **kwargs):
return {'name': name,
'changes': {},
'result': False,
'comment': ('watch requisite is not'
' implemented for {0}'.format(sfun))}
'comment': 'watch requisite is not implemented for '
'{0}'.format(sfun)}

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ import os
import salt.utils.args
import salt.utils.data
import salt.utils.docker.translate
from salt.utils.docker.translate.helpers import split as _split
from salt.exceptions import CommandExecutionError, SaltInvocationError
from salt.utils.args import get_function_argspec as _argspec
@ -40,29 +41,6 @@ except ImportError:
pass
NOTSET = object()
ALIASES = {
'cmd': 'command',
'cpuset': 'cpuset_cpus',
'dns_option': 'dns_opt',
'env': 'environment',
'expose': 'ports',
'interactive': 'stdin_open',
'ipc': 'ipc_mode',
'label': 'labels',
'memory': 'mem_limit',
'memory_swap': 'memswap_limit',
'publish': 'port_bindings',
'publish_all': 'publish_all_ports',
'restart': 'restart_policy',
'rm': 'auto_remove',
'sysctl': 'sysctls',
'security_opts': 'security_opt',
'ulimit': 'ulimits',
'user_ns_mode': 'userns_mode',
'volume': 'volumes',
'workdir': 'working_dir',
}
ALIASES_REVMAP = dict([(y, x) for x, y in six.iteritems(ALIASES)])
# Default timeout as of docker-py 1.0.0
CLIENT_TIMEOUT = 60
@ -72,13 +50,10 @@ SHUTDOWN_TIMEOUT = 10
log = logging.getLogger(__name__)
def _split(item, sep=',', maxsplit=-1):
return [x.strip() for x in item.split(sep, maxsplit)]
def get_client_args():
if not HAS_DOCKER_PY:
raise CommandExecutionError('docker Python module not imported')
try:
create_args = _argspec(docker.APIClient.create_container).args
except AttributeError:
@ -100,6 +75,28 @@ def get_client_args():
'Could not get create_host_config argspec'
)
try:
func_ref = docker.api.network.NetworkApiMixin.create_network
if six.PY2:
try:
# create_network is decorated, so we have to dig into the closure
# created by functools.wraps
network_config_args = \
_argspec(func_ref.__func__.__closure__[0].cell_contents).args
except (AttributeError, IndexError):
# functools.wraps changed (unlikely), bail out
network_config_args = []
else:
try:
# functools.wraps makes things a little easier in Python 3
network_config_args = _argspec(func_ref.__wrapped__).args
except AttributeError:
# functools.wraps changed (unlikely), bail out
network_config_args = []
except AttributeError:
# Function moved, bail out
network_config_args = []
try:
endpoint_config_args = \
_argspec(docker.types.EndpointConfig.__init__).args
@ -116,14 +113,25 @@ def get_client_args():
'Could not get create_endpoint_config argspec'
)
for arglist in (create_args, host_config_args, endpoint_config_args):
try:
ipam_args = _argspec(docker.types.IPAMPool.__init__).args
except AttributeError:
try:
# The API version is passed automagically by the API code that
# imports these classes/functions and is not an arg that we will be
# passing, so remove it if present.
arglist.remove('version')
except ValueError:
pass
ipam_args = _argspec(docker.utils.create_ipam_pool).args
except AttributeError:
raise CommandExecutionError('Could not get ipam args')
for arglist in (create_args, host_config_args, network_config_args,
endpoint_config_args, ipam_args):
# The API version is passed automagically by the API code that imports
# these classes/functions and is not an arg that we will be passing, so
# remove it if present. Similarly, don't include "self" if it shows up
# in the arglist.
for argname in ('version', 'self'):
try:
arglist.remove(argname)
except ValueError:
pass
# Remove any args in host or networking config from the main config dict.
# This keeps us from accidentally allowing args that docker-py has moved
@ -138,51 +146,32 @@ def get_client_args():
return {'create_container': create_args,
'host_config': host_config_args,
'networking_config': endpoint_config_args}
'endpoint_config': endpoint_config_args,
'network_config': network_config_args,
'ipam_config': ipam_args}
def get_repo_tag(image, default_tag='latest'):
def translate_input(translator,
skip_translate=None,
ignore_collisions=False,
validate_ip_addrs=True,
**kwargs):
'''
Resolves the docker repo:tag notation and returns repo name and tag
'''
if not isinstance(image, six.string_types):
image = str(image)
try:
r_name, r_tag = image.rsplit(':', 1)
except ValueError:
r_name = image
r_tag = default_tag
if not r_tag:
# Would happen if some wiseguy requests a tag ending in a colon
# (e.g. 'somerepo:')
log.warning(
'Assuming tag \'%s\' for repo \'%s\'', default_tag, image
)
r_tag = default_tag
elif '/' in r_tag:
# Public registry notation with no tag specified
# (e.g. foo.bar.com:5000/imagename)
return image, default_tag
return r_name, r_tag
def translate_input(**kwargs):
'''
Translate CLI/SLS input into the format the API expects. A
``skip_translate`` kwarg can be passed to control which arguments are
translated. It can be either a comma-separated list or an iterable
containing strings (e.g. a list or tuple), and members of that tuple will
have their translation skipped. Optionally, skip_translate can be set to
True to skip *all* translation.
Translate CLI/SLS input into the format the API expects. The ``translator``
argument must be a module containing translation functions, within
salt.utils.docker.translate. A ``skip_translate`` kwarg can be passed to
control which arguments are translated. It can be either a comma-separated
list or an iterable containing strings (e.g. a list or tuple), and members
of that tuple will have their translation skipped. Optionally,
skip_translate can be set to True to skip *all* translation.
'''
kwargs = salt.utils.args.clean_kwargs(**kwargs)
invalid = {}
collisions = []
skip_translate = kwargs.pop('skip_translate', None)
if skip_translate is True:
# Skip all translation
return kwargs, invalid, collisions
return kwargs
else:
if not skip_translate:
skip_translate = ()
@ -195,120 +184,148 @@ def translate_input(**kwargs):
log.error('skip_translate is not an iterable, ignoring')
skip_translate = ()
validate_ip_addrs = kwargs.pop('validate_ip_addrs', True)
try:
# Using list(kwargs) here because if there are any invalid arguments we
# will be popping them from the kwargs.
for key in list(kwargs):
real_key = translator.ALIASES.get(key, key)
if real_key in skip_translate:
continue
# Using list(kwargs) here because if there are any invalid arguments we
# will be popping them from the kwargs.
for key in list(kwargs):
real_key = ALIASES.get(key, key)
if real_key in skip_translate:
continue
# ipam_pools is designed to be passed as a list of actual
# dictionaries, but if each of the dictionaries passed has a single
# element, it will be incorrectly repacked.
if key != 'ipam_pools' and salt.utils.data.is_dictlist(kwargs[key]):
kwargs[key] = salt.utils.data.repack_dictlist(kwargs[key])
if salt.utils.data.is_dictlist(kwargs[key]):
kwargs[key] = salt.utils.data.repack_dictlist(kwargs[key])
try:
kwargs[key] = getattr(translator, real_key)(
kwargs[key],
validate_ip_addrs=validate_ip_addrs,
skip_translate=skip_translate)
except AttributeError:
log.debug('No translation function for argument \'%s\'', key)
continue
except SaltInvocationError as exc:
kwargs.pop(key)
invalid[key] = exc.strerror
try:
func = getattr(salt.utils.docker.translate, real_key)
kwargs[key] = func(kwargs[key], validate_ip_addrs=validate_ip_addrs)
translator._merge_keys(kwargs)
except AttributeError:
log.debug('No translation function for argument \'%s\'', key)
continue
except SaltInvocationError as exc:
kwargs.pop(key)
invalid[key] = exc.strerror
pass
log_driver = kwargs.pop('log_driver', NOTSET)
log_opt = kwargs.pop('log_opt', NOTSET)
if 'log_config' not in kwargs:
# The log_config is a mixture of the CLI options --log-driver and
# --log-opt (which we support in Salt as log_driver and log_opt,
# respectively), but it must be submitted to the host config in the
# format {'Type': log_driver, 'Config': log_opt}. So, we need to
# construct this argument to be passed to the API from those two
# arguments.
if log_driver is not NOTSET or log_opt is not NOTSET:
kwargs['log_config'] = {
'Type': log_driver if log_driver is not NOTSET else 'none',
'Config': log_opt if log_opt is not NOTSET else {}
}
# Convert CLI versions of commands to their API counterparts
for key in ALIASES:
if key in kwargs:
new_key = ALIASES[key]
value = kwargs.pop(key)
if new_key in kwargs:
collisions.append(new_key)
else:
kwargs[new_key] = value
# Don't allow conflicting options to be set
if kwargs.get('port_bindings') is not None \
and kwargs.get('publish_all_ports'):
kwargs.pop('port_bindings')
invalid['port_bindings'] = 'Cannot be used when publish_all_ports=True'
if kwargs.get('hostname') is not None \
and kwargs.get('network_mode') == 'host':
kwargs.pop('hostname')
invalid['hostname'] = 'Cannot be used when network_mode=True'
# Make sure volumes and ports are defined to match the binds and port_bindings
if kwargs.get('binds') is not None \
and (skip_translate is True or
all(x not in skip_translate
for x in ('binds', 'volume', 'volumes'))):
# Make sure that all volumes defined in "binds" are included in the
# "volumes" param.
auto_volumes = []
if isinstance(kwargs['binds'], dict):
for val in six.itervalues(kwargs['binds']):
try:
if 'bind' in val:
auto_volumes.append(val['bind'])
except TypeError:
continue
else:
if isinstance(kwargs['binds'], list):
auto_volume_defs = kwargs['binds']
else:
try:
auto_volume_defs = _split(kwargs['binds'])
except AttributeError:
auto_volume_defs = []
for val in auto_volume_defs:
try:
auto_volumes.append(_split(val, ':')[1])
except IndexError:
continue
if auto_volumes:
actual_volumes = kwargs.setdefault('volumes', [])
actual_volumes.extend([x for x in auto_volumes
if x not in actual_volumes])
# Sort list to make unit tests more reliable
actual_volumes.sort()
if kwargs.get('port_bindings') is not None \
and (skip_translate is True or
all(x not in skip_translate
for x in ('port_bindings', 'expose', 'ports'))):
# Make sure that all ports defined in "port_bindings" are included in
# the "ports" param.
auto_ports = list(kwargs['port_bindings'])
if auto_ports:
actual_ports = []
# Sort list to make unit tests more reliable
for port in auto_ports:
if port in actual_ports:
continue
if isinstance(port, six.integer_types):
actual_ports.append((port, 'tcp'))
# Convert CLI versions of commands to their docker-py counterparts
for key in translator.ALIASES:
if key in kwargs:
new_key = translator.ALIASES[key]
value = kwargs.pop(key)
if new_key in kwargs:
collisions.append(new_key)
else:
port, proto = port.split('/')
actual_ports.append((int(port), proto))
actual_ports.sort()
actual_ports = [
port if proto == 'tcp' else '{}/{}'.format(port, proto) for (port, proto) in actual_ports
]
kwargs.setdefault('ports', actual_ports)
kwargs[new_key] = value
return kwargs, invalid, sorted(collisions)
try:
translator._post_processing(kwargs, skip_translate, invalid)
except AttributeError:
pass
except Exception as exc:
error_message = exc.__str__()
log.error(
'Error translating input: \'%s\'', error_message, exc_info=True)
else:
error_message = None
error_data = {}
if error_message is not None:
error_data['error_message'] = error_message
if invalid:
error_data['invalid'] = invalid
if collisions and not ignore_collisions:
for item in collisions:
error_data.setdefault('collisions', []).append(
'\'{0}\' is an alias for \'{1}\', they cannot both be used'
.format(translator.ALIASES_REVMAP[item], item)
)
if error_data:
raise CommandExecutionError(
'Failed to translate input', info=error_data)
return kwargs
def create_ipam_config(*pools, **kwargs):
'''
Builds an IP address management (IPAM) config dictionary
'''
kwargs = salt.utils.args.clean_kwargs(**kwargs)
try:
# docker-py 2.0 and newer
pool_args = salt.utils.args.get_function_argspec(
docker.types.IPAMPool.__init__).args
create_pool = docker.types.IPAMPool
create_config = docker.types.IPAMConfig
except AttributeError:
# docker-py < 2.0
pool_args = salt.utils.args.get_function_argspec(
docker.utils.create_ipam_pool).args
create_pool = docker.utils.create_ipam_pool
create_config = docker.utils.create_ipam_config
for primary_key, alias_key in (('driver', 'ipam_driver'),
('options', 'ipam_opts')):
if alias_key in kwargs:
alias_val = kwargs.pop(alias_key)
if primary_key in kwargs:
log.warning(
'docker.create_ipam_config: Both \'%s\' and \'%s\' '
'passed. Ignoring \'%s\'',
alias_key, primary_key, alias_key
)
else:
kwargs[primary_key] = alias_val
if salt.utils.data.is_dictlist(kwargs.get('options')):
kwargs['options'] = salt.utils.data.repack_dictlist(kwargs['options'])
# Get all of the IPAM pool args that were passed as individual kwargs
# instead of in the *pools tuple
pool_kwargs = {}
for key in list(kwargs):
if key in pool_args:
pool_kwargs[key] = kwargs.pop(key)
pool_configs = []
if pool_kwargs:
pool_configs.append(create_pool(**pool_kwargs))
pool_configs.extend([create_pool(**pool) for pool in pools])
if pool_configs:
# Sanity check the IPAM pools. docker-py's type/function for creating
# an IPAM pool will allow you to create a pool with a gateway, IP
# range, or map of aux addresses, even when no subnet is passed.
# However, attempting to use this IPAM pool when creating the network
# will cause the Docker Engine to throw an error.
if any('Subnet' not in pool for pool in pool_configs):
raise SaltInvocationError('A subnet is required in each IPAM pool')
else:
kwargs['pool_configs'] = pool_configs
ret = create_config(**kwargs)
pool_dicts = ret.get('Config')
if pool_dicts:
# When you inspect a network with custom IPAM configuration, only
# arguments which were explictly passed are reflected. By contrast,
# docker-py will include keys for arguments which were not passed in
# but set the value to None. Thus, for ease of comparison, the below
# loop will remove all keys with a value of None from the generated
# pool configs.
for idx, _ in enumerate(pool_dicts):
for key in list(pool_dicts[idx]):
if pool_dicts[idx][key] is None:
del pool_dicts[idx][key]
return ret

View file

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View file

@ -1,278 +1,146 @@
# -*- coding: utf-8 -*-
'''
Functions to translate input in the docker CLI format to the format desired by
by the API.
Functions to translate input for container creation
'''
# Import Python libs
from __future__ import absolute_import
import logging
import os
# Import Salt libs
import salt.utils.network
from salt.exceptions import SaltInvocationError
# Import 3rd-party libs
from salt.ext import six
from salt.ext.six.moves import range, zip # pylint: disable=import-error,redefined-builtin
from salt.ext.six.moves import range # pylint: disable=import-error,redefined-builtin
log = logging.getLogger(__name__)
NOTSET = object()
# Import helpers
from . import helpers
ALIASES = {
'cmd': 'command',
'cpuset': 'cpuset_cpus',
'dns_option': 'dns_opt',
'env': 'environment',
'expose': 'ports',
'interactive': 'stdin_open',
'ipc': 'ipc_mode',
'label': 'labels',
'memory': 'mem_limit',
'memory_swap': 'memswap_limit',
'publish': 'port_bindings',
'publish_all': 'publish_all_ports',
'restart': 'restart_policy',
'rm': 'auto_remove',
'sysctl': 'sysctls',
'security_opts': 'security_opt',
'ulimit': 'ulimits',
'user_ns_mode': 'userns_mode',
'volume': 'volumes',
'workdir': 'working_dir',
}
ALIASES_REVMAP = dict([(y, x) for x, y in six.iteritems(ALIASES)])
def _split(item, sep=',', maxsplit=-1):
return [x.strip() for x in item.split(sep, maxsplit)]
def _get_port_def(port_num, proto='tcp'):
def _merge_keys(kwargs):
'''
Given a port number and protocol, returns the port definition expected by
docker-py. For TCP ports this is simply an integer, for UDP ports this is
(port_num, 'udp').
port_num can also be a string in the format 'port_num/udp'. If so, the
"proto" argument will be ignored. The reason we need to be able to pass in
the protocol separately is because this function is sometimes invoked on
data derived from a port range (e.g. '2222-2223/udp'). In these cases the
protocol has already been stripped off and the port range resolved into the
start and end of the range, and get_port_def() is invoked once for each
port number in that range. So, rather than munge udp ports back into
strings before passing them to this function, the function will see if it
has a string and use the protocol from it if present.
This function does not catch the TypeError or ValueError which would be
raised if the port number is non-numeric. This function either needs to be
run on known good input, or should be run within a try/except that catches
these two exceptions.
The log_config is a mixture of the CLI options --log-driver and --log-opt
(which we support in Salt as log_driver and log_opt, respectively), but it
must be submitted to the host config in the format {'Type': log_driver,
'Config': log_opt}. So, we need to construct this argument to be passed to
the API from those two arguments.
'''
try:
port_num, _, port_num_proto = port_num.partition('/')
except AttributeError:
pass
else:
if port_num_proto:
proto = port_num_proto
try:
if proto.lower() == 'udp':
return int(port_num), 'udp'
except AttributeError:
pass
return int(port_num)
log_driver = kwargs.pop('log_driver', helpers.NOTSET)
log_opt = kwargs.pop('log_opt', helpers.NOTSET)
if 'log_config' not in kwargs:
if log_driver is not helpers.NOTSET \
or log_opt is not helpers.NOTSET:
kwargs['log_config'] = {
'Type': log_driver
if log_driver is not helpers.NOTSET
else 'none',
'Config': log_opt
if log_opt is not helpers.NOTSET
else {}
}
def _get_port_range(port_def):
def _post_processing(kwargs, skip_translate, invalid):
'''
Given a port number or range, return a start and end to that range. Port
ranges are defined as a string containing two numbers separated by a dash
(e.g. '4505-4506').
A ValueError will be raised if bad input is provided.
Additional container-specific post-translation processing
'''
if isinstance(port_def, six.integer_types):
# Single integer, start/end of range is the same
return port_def, port_def
try:
comps = [int(x) for x in _split(port_def, '-')]
if len(comps) == 1:
range_start = range_end = comps[0]
# Don't allow conflicting options to be set
if kwargs.get('port_bindings') is not None \
and kwargs.get('publish_all_ports'):
kwargs.pop('port_bindings')
invalid['port_bindings'] = 'Cannot be used when publish_all_ports=True'
if kwargs.get('hostname') is not None \
and kwargs.get('network_mode') == 'host':
kwargs.pop('hostname')
invalid['hostname'] = 'Cannot be used when network_mode=True'
# Make sure volumes and ports are defined to match the binds and port_bindings
if kwargs.get('binds') is not None \
and (skip_translate is True or
all(x not in skip_translate
for x in ('binds', 'volume', 'volumes'))):
# Make sure that all volumes defined in "binds" are included in the
# "volumes" param.
auto_volumes = []
if isinstance(kwargs['binds'], dict):
for val in six.itervalues(kwargs['binds']):
try:
if 'bind' in val:
auto_volumes.append(val['bind'])
except TypeError:
continue
else:
range_start, range_end = comps
if range_start > range_end:
raise ValueError('start > end')
except (TypeError, ValueError) as exc:
if exc.__str__() == 'start > end':
msg = (
'Start of port range ({0}) cannot be greater than end of '
'port range ({1})'.format(range_start, range_end)
)
else:
msg = '\'{0}\' is non-numeric or an invalid port range'.format(
port_def
)
raise ValueError(msg)
else:
return range_start, range_end
if isinstance(kwargs['binds'], list):
auto_volume_defs = kwargs['binds']
else:
try:
auto_volume_defs = helpers.split(kwargs['binds'])
except AttributeError:
auto_volume_defs = []
for val in auto_volume_defs:
try:
auto_volumes.append(helpers.split(val, ':')[1])
except IndexError:
continue
if auto_volumes:
actual_volumes = kwargs.setdefault('volumes', [])
actual_volumes.extend([x for x in auto_volumes
if x not in actual_volumes])
# Sort list to make unit tests more reliable
actual_volumes.sort()
if kwargs.get('port_bindings') is not None \
and (skip_translate is True or
all(x not in skip_translate
for x in ('port_bindings', 'expose', 'ports'))):
# Make sure that all ports defined in "port_bindings" are included in
# the "ports" param.
auto_ports = list(kwargs['port_bindings'])
if auto_ports:
actual_ports = []
# Sort list to make unit tests more reliable
for port in auto_ports:
if port in actual_ports:
continue
if isinstance(port, six.integer_types):
actual_ports.append((port, 'tcp'))
else:
port, proto = port.split('/')
actual_ports.append((int(port), proto))
actual_ports.sort()
actual_ports = [
port if proto == 'tcp' else '{}/{}'.format(port, proto) for (port, proto) in actual_ports
]
kwargs.setdefault('ports', actual_ports)
def _map_vals(val, *names, **extra_opts):
'''
Many arguments come in as a list of VAL1:VAL2 pairs, but map to a list
of dicts in the format {NAME1: VAL1, NAME2: VAL2}. This function
provides common code to handle these instances.
'''
fill = extra_opts.pop('fill', NOTSET)
expected_num_elements = len(names)
val = _translate_stringlist(val)
for idx, item in enumerate(val):
if not isinstance(item, dict):
elements = [x.strip() for x in item.split(':')]
num_elements = len(elements)
if num_elements < expected_num_elements:
if fill is NOTSET:
raise SaltInvocationError(
'\'{0}\' contains {1} value(s) (expected {2})'.format(
item, num_elements, expected_num_elements
)
)
elements.extend([fill] * (expected_num_elements - num_elements))
elif num_elements > expected_num_elements:
raise SaltInvocationError(
'\'{0}\' contains {1} value(s) (expected {2})'.format(
item,
num_elements,
expected_num_elements if fill is NOTSET
else 'up to {0}'.format(expected_num_elements)
)
)
val[idx] = dict(zip(names, elements))
return val
def _validate_ip(val):
try:
if not salt.utils.network.is_ip(val):
raise SaltInvocationError(
'\'{0}\' is not a valid IP address'.format(val)
)
except RuntimeError:
pass
# Helpers to perform common translation actions
def _translate_str(val):
return str(val) if not isinstance(val, six.string_types) else val
def _translate_int(val):
if not isinstance(val, six.integer_types):
try:
val = int(val)
except (TypeError, ValueError):
raise SaltInvocationError('\'{0}\' is not an integer'.format(val))
return val
def _translate_bool(val):
return bool(val) if not isinstance(val, bool) else val
def _translate_dict(val):
'''
Not really translating, just raising an exception if it's not a dict
'''
if not isinstance(val, dict):
raise SaltInvocationError('\'{0}\' is not a dictionary'.format(val))
return val
def _translate_command(val):
'''
Input should either be a single string, or a list of strings. This is used
for the two args that deal with commands ("command" and "entrypoint").
'''
if isinstance(val, six.string_types):
return val
elif isinstance(val, list):
for idx in range(len(val)):
if not isinstance(val[idx], six.string_types):
val[idx] = str(val[idx])
else:
# Make sure we have a string
val = str(val)
return val
def _translate_bytes(val):
'''
These values can be expressed as an integer number of bytes, or a string
expression (i.e. 100mb, 1gb, etc.).
'''
try:
val = int(val)
except (TypeError, ValueError):
if not isinstance(val, six.string_types):
val = str(val)
return val
def _translate_stringlist(val):
'''
On the CLI, these are passed as multiple instances of a given CLI option.
In Salt, we accept these as a comma-delimited list but the API expects a
Python list. This function accepts input and returns it back as a Python
list of strings. If the input is a string which is a comma-separated list
of items, split that string and return it.
'''
if not isinstance(val, list):
try:
val = _split(val)
except AttributeError:
val = _split(str(val))
for idx in range(len(val)):
if not isinstance(val[idx], six.string_types):
val[idx] = str(val[idx])
return val
def _translate_device_rates(val, numeric_rate=True):
'''
CLI input is a list of PATH:RATE pairs, but the API expects a list of
dictionaries in the format [{'Path': path, 'Rate': rate}]
'''
val = _map_vals(val, 'Path', 'Rate')
for idx in range(len(val)):
try:
is_abs = os.path.isabs(val[idx]['Path'])
except AttributeError:
is_abs = False
if not is_abs:
raise SaltInvocationError(
'Path \'{Path}\' is not absolute'.format(**val[idx])
)
# Attempt to convert to an integer. Will fail if rate was specified as
# a shorthand (e.g. 1mb), this is OK as we will check to make sure the
# value is an integer below if that is what is required.
try:
val[idx]['Rate'] = int(val[idx]['Rate'])
except (TypeError, ValueError):
pass
if numeric_rate:
try:
val[idx]['Rate'] = int(val[idx]['Rate'])
except ValueError:
raise SaltInvocationError(
'Rate \'{Rate}\' for path \'{Path}\' is '
'non-numeric'.format(**val[idx])
)
return val
def _translate_key_val(val, delimiter='='):
'''
CLI input is a list of key/val pairs, but the API expects a dictionary in
the format {key: val}
'''
if isinstance(val, dict):
return val
val = _translate_stringlist(val)
new_val = {}
for item in val:
try:
lvalue, rvalue = _split(item, delimiter, 1)
except (AttributeError, TypeError, ValueError):
raise SaltInvocationError(
'\'{0}\' is not a key{1}value pair'.format(item, delimiter)
)
new_val[lvalue] = rvalue
return new_val
# Functions below must match names of API arguments
# Functions below must match names of docker-py arguments
def auto_remove(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def binds(val, **kwargs): # pylint: disable=unused-argument
@ -284,7 +152,7 @@ def binds(val, **kwargs): # pylint: disable=unused-argument
if not isinstance(val, dict):
if not isinstance(val, list):
try:
val = _split(val)
val = helpers.split(val)
except AttributeError:
raise SaltInvocationError(
'\'{0}\' is not a dictionary or list of bind '
@ -294,7 +162,7 @@ def binds(val, **kwargs): # pylint: disable=unused-argument
def blkio_weight(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def blkio_weight_device(val, **kwargs): # pylint: disable=unused-argument
@ -302,7 +170,7 @@ def blkio_weight_device(val, **kwargs): # pylint: disable=unused-argument
CLI input is a list of PATH:WEIGHT pairs, but the API expects a list of
dictionaries in the format [{'Path': path, 'Weight': weight}]
'''
val = _map_vals(val, 'Path', 'Weight')
val = helpers.map_vals(val, 'Path', 'Weight')
for idx in range(len(val)):
try:
val[idx]['Weight'] = int(val[idx]['Weight'])
@ -315,205 +183,179 @@ def blkio_weight_device(val, **kwargs): # pylint: disable=unused-argument
def cap_add(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def cap_drop(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def command(val, **kwargs): # pylint: disable=unused-argument
return _translate_command(val)
return helpers.translate_command(val)
def cpuset_cpus(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def cpuset_mems(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def cpu_group(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def cpu_period(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def cpu_shares(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def detach(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def device_read_bps(val, **kwargs): # pylint: disable=unused-argument
return _translate_device_rates(val, numeric_rate=False)
return helpers.translate_device_rates(val, numeric_rate=False)
def device_read_iops(val, **kwargs): # pylint: disable=unused-argument
return _translate_device_rates(val, numeric_rate=True)
return helpers.translate_device_rates(val, numeric_rate=True)
def device_write_bps(val, **kwargs): # pylint: disable=unused-argument
return _translate_device_rates(val, numeric_rate=False)
return helpers.translate_device_rates(val, numeric_rate=False)
def device_write_iops(val, **kwargs): # pylint: disable=unused-argument
return _translate_device_rates(val, numeric_rate=True)
return helpers.translate_device_rates(val, numeric_rate=True)
def devices(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def dns_opt(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def dns_search(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def dns(val, **kwargs):
val = _translate_stringlist(val)
val = helpers.translate_stringlist(val)
if kwargs.get('validate_ip_addrs', True):
for item in val:
_validate_ip(item)
helpers.validate_ip(item)
return val
def domainname(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def entrypoint(val, **kwargs): # pylint: disable=unused-argument
return _translate_command(val)
return helpers.translate_command(val)
def environment(val, **kwargs): # pylint: disable=unused-argument
return _translate_key_val(val, delimiter='=')
return helpers.translate_key_val(val, delimiter='=')
def extra_hosts(val, **kwargs):
val = _translate_key_val(val, delimiter=':')
val = helpers.translate_key_val(val, delimiter=':')
if kwargs.get('validate_ip_addrs', True):
for key in val:
_validate_ip(val[key])
helpers.validate_ip(val[key])
return val
def group_add(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def host_config(val, **kwargs): # pylint: disable=unused-argument
return _translate_dict(val)
return helpers.translate_dict(val)
def hostname(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def ipc_mode(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def isolation(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def labels(val, **kwargs): # pylint: disable=unused-argument
'''
Can either be a list of label names, or a list of name=value pairs. The API
can accept either a list of label names or a dictionary mapping names to
values, so the value we translate will be different depending on the input.
'''
if not isinstance(val, dict):
val = _translate_stringlist(val)
try:
has_mappings = all('=' in x for x in val)
except TypeError:
has_mappings = False
if has_mappings:
# The try/except above where has_mappings was defined has
# already confirmed that all elements are strings, and that
# all contain an equal sign. So we do not need to enclose
# the split in another try/except.
val = dict([_split(x, '=', 1) for x in val])
else:
# Stringify any non-string values
for idx in range(len(val)):
if '=' in val[idx]:
raise SaltInvocationError(
'Mix of labels with and without values'
)
return val
return val
return helpers.translate_labels(val)
def links(val, **kwargs): # pylint: disable=unused-argument
return _translate_key_val(val, delimiter=':')
return helpers.translate_key_val(val, delimiter=':')
def log_driver(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def log_opt(val, **kwargs): # pylint: disable=unused-argument
return _translate_key_val(val, delimiter='=')
return helpers.translate_key_val(val, delimiter='=')
def lxc_conf(val, **kwargs): # pylint: disable=unused-argument
return _translate_key_val(val, delimiter='=')
return helpers.translate_key_val(val, delimiter='=')
def mac_address(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def mem_limit(val, **kwargs): # pylint: disable=unused-argument
return _translate_bytes(val)
return helpers.translate_bytes(val)
def mem_swappiness(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def memswap_limit(val, **kwargs): # pylint: disable=unused-argument
return _translate_bytes(val)
return helpers.translate_bytes(val)
def name(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def network_disabled(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def network_mode(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def oom_kill_disable(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def oom_score_adj(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def pid_mode(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def pids_limit(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def port_bindings(val, **kwargs):
@ -531,9 +373,9 @@ def port_bindings(val, **kwargs):
if not isinstance(val, dict):
if not isinstance(val, list):
try:
val = _split(val)
val = helpers.split(val)
except AttributeError:
val = _split(str(val))
val = helpers.split(str(val))
for idx in range(len(val)):
if not isinstance(val[idx], six.string_types):
@ -544,7 +386,7 @@ def port_bindings(val, **kwargs):
bindings = {}
for binding in val:
bind_parts = _split(binding, ':')
bind_parts = helpers.split(binding, ':')
num_bind_parts = len(bind_parts)
if num_bind_parts == 1:
# Single port or port range being passed through (no
@ -556,7 +398,7 @@ def port_bindings(val, **kwargs):
)
container_port, _, proto = container_port.partition('/')
try:
start, end = _get_port_range(container_port)
start, end = helpers.get_port_range(container_port)
except ValueError as exc:
# Using __str__() to avoid deprecation warning for using
# the message attribute of the ValueError.
@ -578,8 +420,10 @@ def port_bindings(val, **kwargs):
)
container_port, _, proto = bind_parts[1].partition('/')
try:
cport_start, cport_end = _get_port_range(container_port)
hport_start, hport_end = _get_port_range(bind_parts[0])
cport_start, cport_end = \
helpers.get_port_range(container_port)
hport_start, hport_end = \
helpers.get_port_range(bind_parts[0])
except ValueError as exc:
# Using __str__() to avoid deprecation warning for
# using the message attribute of the ValueError.
@ -600,10 +444,11 @@ def port_bindings(val, **kwargs):
elif num_bind_parts == 3:
host_ip, host_port = bind_parts[0:2]
if validate_ip_addrs:
_validate_ip(host_ip)
helpers.validate_ip(host_ip)
container_port, _, proto = bind_parts[2].partition('/')
try:
cport_start, cport_end = _get_port_range(container_port)
cport_start, cport_end = \
helpers.get_port_range(container_port)
except ValueError as exc:
# Using __str__() to avoid deprecation warning for
# using the message attribute of the ValueError.
@ -613,7 +458,8 @@ def port_bindings(val, **kwargs):
hport_list = [None] * len(cport_list)
else:
try:
hport_start, hport_end = _get_port_range(host_port)
hport_start, hport_end = \
helpers.get_port_range(host_port)
except ValueError as exc:
# Using __str__() to avoid deprecation warning for
# using the message attribute of the ValueError.
@ -678,7 +524,7 @@ def ports(val, **kwargs): # pylint: disable=unused-argument
'''
if not isinstance(val, list):
try:
val = _split(val)
val = helpers.split(val)
except AttributeError:
if isinstance(val, six.integer_types):
val = [val]
@ -698,12 +544,13 @@ def ports(val, **kwargs): # pylint: disable=unused-argument
'\'{0}\' is not a valid port definition'.format(item)
)
try:
range_start, range_end = _get_port_range(item)
range_start, range_end = \
helpers.get_port_range(item)
except ValueError as exc:
# Using __str__() to avoid deprecation warning for using
# the "message" attribute of the ValueError.
raise SaltInvocationError(exc.__str__())
new_ports.update([_get_port_def(x, proto)
new_ports.update([helpers.get_port_def(x, proto)
for x in range(range_start, range_end + 1)])
ordered_new_ports = [
port if proto == 'tcp' else (port, proto) for (port, proto) in sorted(
@ -715,15 +562,15 @@ def ports(val, **kwargs): # pylint: disable=unused-argument
def privileged(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def publish_all_ports(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def read_only(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def restart_policy(val, **kwargs): # pylint: disable=unused-argument
@ -733,8 +580,12 @@ def restart_policy(val, **kwargs): # pylint: disable=unused-argument
to make sure the mapped result uses '0' for the count if this optional
value was omitted.
'''
val = _map_vals(val, 'Name', 'MaximumRetryCount', fill='0')
# _map_vals() converts the input into a list of dicts, but the API
val = helpers.map_vals(
val,
'Name',
'MaximumRetryCount',
fill='0')
# map_vals() converts the input into a list of dicts, but the API
# wants just a dict, so extract the value from the single-element
# list. If there was more than one element in the list, then
# invalid input was passed (i.e. a comma-separated list, when what
@ -754,48 +605,49 @@ def restart_policy(val, **kwargs): # pylint: disable=unused-argument
def security_opt(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def shm_size(val, **kwargs): # pylint: disable=unused-argument
return _translate_bytes(val)
return helpers.translate_bytes(val)
def stdin_open(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def stop_signal(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def stop_timeout(val, **kwargs): # pylint: disable=unused-argument
return _translate_int(val)
return helpers.translate_int(val)
def storage_opt(val, **kwargs): # pylint: disable=unused-argument
return _translate_key_val(val, delimiter='=')
return helpers.translate_key_val(val, delimiter='=')
def sysctls(val, **kwargs): # pylint: disable=unused-argument
return _translate_key_val(val, delimiter='=')
return helpers.translate_key_val(val, delimiter='=')
def tmpfs(val, **kwargs): # pylint: disable=unused-argument
return _translate_dict(val)
return helpers.translate_dict(val)
def tty(val, **kwargs): # pylint: disable=unused-argument
return _translate_bool(val)
return helpers.translate_bool(val)
def ulimits(val, **kwargs): # pylint: disable=unused-argument
val = _translate_stringlist(val)
val = helpers.translate_stringlist(val)
for idx in range(len(val)):
if not isinstance(val[idx], dict):
try:
ulimit_name, limits = _split(val[idx], '=', 1)
comps = _split(limits, ':', 1)
ulimit_name, limits = \
helpers.split(val[idx], '=', 1)
comps = helpers.split(limits, ':', 1)
except (AttributeError, ValueError):
raise SaltInvocationError(
'Ulimit definition \'{0}\' is not in the format '
@ -839,18 +691,18 @@ def user(val, **kwargs): # pylint: disable=unused-argument
def userns_mode(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def volume_driver(val, **kwargs): # pylint: disable=unused-argument
return _translate_str(val)
return helpers.translate_str(val)
def volumes(val, **kwargs): # pylint: disable=unused-argument
'''
Should be a list of absolute paths
'''
val = _translate_stringlist(val)
val = helpers.translate_stringlist(val)
for item in val:
if not os.path.isabs(item):
raise SaltInvocationError(
@ -860,7 +712,7 @@ def volumes(val, **kwargs): # pylint: disable=unused-argument
def volumes_from(val, **kwargs): # pylint: disable=unused-argument
return _translate_stringlist(val)
return helpers.translate_stringlist(val)
def working_dir(val, **kwargs): # pylint: disable=unused-argument

View file

@ -0,0 +1,308 @@
# -*- coding: utf-8 -*-
'''
Functions to translate input in the docker CLI format to the format desired by
by the API.
'''
# Import Python libs
from __future__ import absolute_import
import os
# Import Salt libs
import salt.utils.data
import salt.utils.network
from salt.exceptions import SaltInvocationError
# Import 3rd-party libs
from salt.ext import six
from salt.ext.six.moves import range, zip # pylint: disable=import-error,redefined-builtin
NOTSET = object()
def split(item, sep=',', maxsplit=-1):
return [x.strip() for x in item.split(sep, maxsplit)]
def get_port_def(port_num, proto='tcp'):
'''
Given a port number and protocol, returns the port definition expected by
docker-py. For TCP ports this is simply an integer, for UDP ports this is
(port_num, 'udp').
port_num can also be a string in the format 'port_num/udp'. If so, the
"proto" argument will be ignored. The reason we need to be able to pass in
the protocol separately is because this function is sometimes invoked on
data derived from a port range (e.g. '2222-2223/udp'). In these cases the
protocol has already been stripped off and the port range resolved into the
start and end of the range, and get_port_def() is invoked once for each
port number in that range. So, rather than munge udp ports back into
strings before passing them to this function, the function will see if it
has a string and use the protocol from it if present.
This function does not catch the TypeError or ValueError which would be
raised if the port number is non-numeric. This function either needs to be
run on known good input, or should be run within a try/except that catches
these two exceptions.
'''
try:
port_num, _, port_num_proto = port_num.partition('/')
except AttributeError:
pass
else:
if port_num_proto:
proto = port_num_proto
try:
if proto.lower() == 'udp':
return int(port_num), 'udp'
except AttributeError:
pass
return int(port_num)
def get_port_range(port_def):
'''
Given a port number or range, return a start and end to that range. Port
ranges are defined as a string containing two numbers separated by a dash
(e.g. '4505-4506').
A ValueError will be raised if bad input is provided.
'''
if isinstance(port_def, six.integer_types):
# Single integer, start/end of range is the same
return port_def, port_def
try:
comps = [int(x) for x in split(port_def, '-')]
if len(comps) == 1:
range_start = range_end = comps[0]
else:
range_start, range_end = comps
if range_start > range_end:
raise ValueError('start > end')
except (TypeError, ValueError) as exc:
if exc.__str__() == 'start > end':
msg = (
'Start of port range ({0}) cannot be greater than end of '
'port range ({1})'.format(range_start, range_end)
)
else:
msg = '\'{0}\' is non-numeric or an invalid port range'.format(
port_def
)
raise ValueError(msg)
else:
return range_start, range_end
def map_vals(val, *names, **extra_opts):
'''
Many arguments come in as a list of VAL1:VAL2 pairs, but map to a list
of dicts in the format {NAME1: VAL1, NAME2: VAL2}. This function
provides common code to handle these instances.
'''
fill = extra_opts.pop('fill', NOTSET)
expected_num_elements = len(names)
val = translate_stringlist(val)
for idx, item in enumerate(val):
if not isinstance(item, dict):
elements = [x.strip() for x in item.split(':')]
num_elements = len(elements)
if num_elements < expected_num_elements:
if fill is NOTSET:
raise SaltInvocationError(
'\'{0}\' contains {1} value(s) (expected {2})'.format(
item, num_elements, expected_num_elements
)
)
elements.extend([fill] * (expected_num_elements - num_elements))
elif num_elements > expected_num_elements:
raise SaltInvocationError(
'\'{0}\' contains {1} value(s) (expected {2})'.format(
item,
num_elements,
expected_num_elements if fill is NOTSET
else 'up to {0}'.format(expected_num_elements)
)
)
val[idx] = dict(zip(names, elements))
return val
def validate_ip(val):
try:
if not salt.utils.network.is_ip(val):
raise SaltInvocationError(
'\'{0}\' is not a valid IP address'.format(val)
)
except RuntimeError:
pass
def validate_subnet(val):
try:
if not salt.utils.network.is_subnet(val):
raise SaltInvocationError(
'\'{0}\' is not a valid subnet'.format(val)
)
except RuntimeError:
pass
def translate_str(val):
return str(val) if not isinstance(val, six.string_types) else val
def translate_int(val):
if not isinstance(val, six.integer_types):
try:
val = int(val)
except (TypeError, ValueError):
raise SaltInvocationError('\'{0}\' is not an integer'.format(val))
return val
def translate_bool(val):
return bool(val) if not isinstance(val, bool) else val
def translate_dict(val):
'''
Not really translating, just raising an exception if it's not a dict
'''
if not isinstance(val, dict):
raise SaltInvocationError('\'{0}\' is not a dictionary'.format(val))
return val
def translate_command(val):
'''
Input should either be a single string, or a list of strings. This is used
for the two args that deal with commands ("command" and "entrypoint").
'''
if isinstance(val, six.string_types):
return val
elif isinstance(val, list):
for idx in range(len(val)):
if not isinstance(val[idx], six.string_types):
val[idx] = str(val[idx])
else:
# Make sure we have a string
val = str(val)
return val
def translate_bytes(val):
'''
These values can be expressed as an integer number of bytes, or a string
expression (i.e. 100mb, 1gb, etc.).
'''
try:
val = int(val)
except (TypeError, ValueError):
if not isinstance(val, six.string_types):
val = str(val)
return val
def translate_stringlist(val):
'''
On the CLI, these are passed as multiple instances of a given CLI option.
In Salt, we accept these as a comma-delimited list but the API expects a
Python list. This function accepts input and returns it back as a Python
list of strings. If the input is a string which is a comma-separated list
of items, split that string and return it.
'''
if not isinstance(val, list):
try:
val = split(val)
except AttributeError:
val = split(str(val))
for idx in range(len(val)):
if not isinstance(val[idx], six.string_types):
val[idx] = str(val[idx])
return val
def translate_device_rates(val, numeric_rate=True):
'''
CLI input is a list of PATH:RATE pairs, but the API expects a list of
dictionaries in the format [{'Path': path, 'Rate': rate}]
'''
val = map_vals(val, 'Path', 'Rate')
for idx in range(len(val)):
try:
is_abs = os.path.isabs(val[idx]['Path'])
except AttributeError:
is_abs = False
if not is_abs:
raise SaltInvocationError(
'Path \'{Path}\' is not absolute'.format(**val[idx])
)
# Attempt to convert to an integer. Will fail if rate was specified as
# a shorthand (e.g. 1mb), this is OK as we will check to make sure the
# value is an integer below if that is what is required.
try:
val[idx]['Rate'] = int(val[idx]['Rate'])
except (TypeError, ValueError):
pass
if numeric_rate:
try:
val[idx]['Rate'] = int(val[idx]['Rate'])
except ValueError:
raise SaltInvocationError(
'Rate \'{Rate}\' for path \'{Path}\' is '
'non-numeric'.format(**val[idx])
)
return val
def translate_key_val(val, delimiter='='):
'''
CLI input is a list of key/val pairs, but the API expects a dictionary in
the format {key: val}
'''
if isinstance(val, dict):
return val
val = translate_stringlist(val)
new_val = {}
for item in val:
try:
lvalue, rvalue = split(item, delimiter, 1)
except (AttributeError, TypeError, ValueError):
raise SaltInvocationError(
'\'{0}\' is not a key{1}value pair'.format(item, delimiter)
)
new_val[lvalue] = rvalue
return new_val
def translate_labels(val):
'''
Can either be a list of label names, or a list of name=value pairs. The API
can accept either a list of label names or a dictionary mapping names to
values, so the value we translate will be different depending on the input.
'''
if not isinstance(val, dict):
if not isinstance(val, list):
val = split(val)
new_val = {}
for item in val:
if isinstance(item, dict):
if len(item) != 1:
raise SaltInvocationError('Invalid label(s)')
key = next(iter(item))
val = item[key]
else:
try:
key, val = split(item, '=', 1)
except ValueError:
key = item
val = ''
if not isinstance(key, six.string_types):
key = str(key)
if not isinstance(val, six.string_types):
val = str(val)
new_val[key] = val
val = new_val
return val

View file

@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
'''
Functions to translate input for network creation
'''
# Import Python libs
from __future__ import absolute_import
# Import Salt libs
from salt.exceptions import SaltInvocationError
# Import 3rd-party libs
from salt.ext import six
# Import helpers
from . import helpers
ALIASES = {
'driver_opt': 'options',
'driver_opts': 'options',
'ipv6': 'enable_ipv6',
}
IPAM_ALIASES = {
'ip_range': 'iprange',
'aux_address': 'aux_addresses',
}
# ALIASES is a superset of IPAM_ALIASES
ALIASES.update(IPAM_ALIASES)
ALIASES_REVMAP = dict([(y, x) for x, y in six.iteritems(ALIASES)])
DEFAULTS = {'check_duplicate': True}
def _post_processing(kwargs, skip_translate, invalid): # pylint: disable=unused-argument
'''
Additional network-specific post-translation processing
'''
# If any defaults were not expicitly passed, add them
for item in DEFAULTS:
if item not in kwargs:
kwargs[item] = DEFAULTS[item]
# Functions below must match names of docker-py arguments
def driver(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_str(val)
def options(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_key_val(val, delimiter='=')
def ipam(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_dict(val)
def check_duplicate(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_bool(val)
def internal(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_bool(val)
def labels(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_labels(val)
def enable_ipv6(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_bool(val)
def attachable(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_bool(val)
def ingress(val, **kwargs): # pylint: disable=unused-argument
return helpers.translate_bool(val)
# IPAM args
def ipam_driver(val, **kwargs): # pylint: disable=unused-argument
return driver(val, **kwargs)
def ipam_opts(val, **kwargs): # pylint: disable=unused-argument
return options(val, **kwargs)
def ipam_pools(val, **kwargs): # pylint: disable=unused-argument
if not hasattr(val, '__iter__') \
or not all(isinstance(x, dict) for x in val):
# Can't do a simple dictlist check because each dict may have more than
# one element.
raise SaltInvocationError('ipam_pools must be a list of dictionaries')
skip_translate = kwargs.get('skip_translate', ())
if not (skip_translate is True or 'ipam_pools' in skip_translate):
_globals = globals()
for ipam_dict in val:
for key in list(ipam_dict):
if skip_translate is not True and key in skip_translate:
continue
if key in IPAM_ALIASES:
# Make sure we resolve aliases, since this wouldn't have
# been done within the individual IPAM dicts
ipam_dict[IPAM_ALIASES[key]] = ipam_dict.pop(key)
key = IPAM_ALIASES[key]
if key in _globals:
ipam_dict[key] = _globals[key](ipam_dict[key])
return val
def subnet(val, **kwargs): # pylint: disable=unused-argument
validate_ip_addrs = kwargs.get('validate_ip_addrs', True)
val = helpers.translate_str(val)
if validate_ip_addrs:
helpers.validate_subnet(val)
return val
def iprange(val, **kwargs): # pylint: disable=unused-argument
validate_ip_addrs = kwargs.get('validate_ip_addrs', True)
val = helpers.translate_str(val)
if validate_ip_addrs:
helpers.validate_subnet(val)
return val
def gateway(val, **kwargs): # pylint: disable=unused-argument
validate_ip_addrs = kwargs.get('validate_ip_addrs', True)
val = helpers.translate_str(val)
if validate_ip_addrs:
helpers.validate_ip(val)
return val
def aux_addresses(val, **kwargs): # pylint: disable=unused-argument
validate_ip_addrs = kwargs.get('validate_ip_addrs', True)
val = helpers.translate_key_val(val, delimiter='=')
if validate_ip_addrs:
for address in six.itervalues(val):
helpers.validate_ip(address)
return val

View file

@ -263,6 +263,33 @@ def is_ipv6(ip):
return False
def is_subnet(cidr):
'''
Returns a bool telling if the passed string is an IPv4 or IPv6 subnet
'''
return is_ipv4_subnet(cidr) or is_ipv6_subnet(cidr)
def is_ipv4_subnet(cidr):
'''
Returns a bool telling if the passed string is an IPv4 subnet
'''
try:
return '/' in cidr and bool(ipaddress.IPv4Network(cidr))
except Exception:
return False
def is_ipv6_subnet(cidr):
'''
Returns a bool telling if the passed string is an IPv6 subnet
'''
try:
return '/' in cidr and bool(ipaddress.IPv6Network(cidr))
except Exception:
return False
@jinja_filter('is_ip')
def is_ip_filter(ip, options=None):
'''

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,500 @@
# -*- coding: utf-8 -*-
'''
Integration tests for the docker_network states
'''
# Import Python Libs
from __future__ import absolute_import
import errno
import functools
import logging
import os
import subprocess
import tempfile
# Import Salt Testing Libs
from tests.support.unit import skipIf
from tests.support.case import ModuleCase
from tests.support.docker import with_network, random_name
from tests.support.paths import FILES, TMP
from tests.support.helpers import destructiveTest
from tests.support.mixins import SaltReturnAssertsMixin
# Import Salt Libs
import salt.utils.files
import salt.utils.network
import salt.utils.path
from salt.exceptions import CommandExecutionError
log = logging.getLogger(__name__)
IMAGE_NAME = random_name(prefix='salt_busybox_')
IPV6_ENABLED = bool(salt.utils.network.ip_addrs6(include_loopback=True))
def network_name(func):
'''
Generate a randomized name for a network and clean it up afterward
'''
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
name = random_name(prefix='salt_net_')
try:
return func(self, name, *args, **kwargs)
finally:
self.run_function(
'docker.disconnect_all_containers_from_network', [name])
try:
self.run_function('docker.remove_network', [name])
except CommandExecutionError as exc:
if 'No such network' not in exc.__str__():
raise
return wrapper
def container_name(func):
'''
Generate a randomized name for a container and clean it up afterward
'''
def build_image():
# Create temp dir
image_build_rootdir = tempfile.mkdtemp(dir=TMP)
script_path = \
os.path.join(FILES, 'file/base/mkimage-busybox-static')
cmd = [script_path, image_build_rootdir, IMAGE_NAME]
log.debug('Running \'%s\' to build busybox image', ' '.join(cmd))
process = subprocess.Popen(
cmd,
close_fds=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
output = process.communicate()[0]
log.debug('Output from mkimge-busybox-static:\n%s', output)
if process.returncode != 0:
raise Exception('Failed to build image')
try:
salt.utils.files.rm_rf(image_build_rootdir)
except OSError as exc:
if exc.errno != errno.ENOENT:
raise
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
try:
self.run_function('docker.inspect_image', [IMAGE_NAME])
except CommandExecutionError:
pass
else:
build_image()
name = random_name(prefix='salt_test_')
self.run_function(
'docker.create',
name=name,
image=IMAGE_NAME,
command='sleep 600',
start=True)
try:
return func(self, name, *args, **kwargs)
finally:
try:
self.run_function('docker.rm', [name], force=True)
except CommandExecutionError as exc:
if 'No such container' not in exc.__str__():
raise
return wrapper
@destructiveTest
@skipIf(not salt.utils.path.which('dockerd'), 'Docker not installed')
class DockerNetworkTestCase(ModuleCase, SaltReturnAssertsMixin):
'''
Test docker_network states
'''
@classmethod
def tearDownClass(cls):
'''
Remove test image if present. Note that this will run a docker rmi even
if no test which required the image was run.
'''
cmd = ['docker', 'rmi', '--force', IMAGE_NAME]
log.debug('Running \'%s\' to destroy busybox image', ' '.join(cmd))
process = subprocess.Popen(
cmd,
close_fds=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
output = process.communicate()[0]
log.debug('Output from %s:\n%s', ' '.join(cmd), output)
if process.returncode != 0 and 'No such image' not in output:
raise Exception('Failed to destroy image')
def run_state(self, function, **kwargs):
ret = super(DockerNetworkTestCase, self).run_state(function, **kwargs)
log.debug('ret = %s', ret)
return ret
@with_network(create=False)
def test_absent(self, net):
self.assertSaltTrueReturn(
self.run_state('docker_network.present', name=net.name))
ret = self.run_state('docker_network.absent', name=net.name)
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
self.assertEqual(ret['changes'], {'removed': True})
self.assertEqual(
ret['comment'],
'Removed network \'{0}\''.format(net.name)
)
@container_name
@with_network(create=False)
def test_absent_with_disconnected_container(self, net, container_name):
self.assertSaltTrueReturn(
self.run_state('docker_network.present',
name=net.name,
containers=[container_name])
)
ret = self.run_state('docker_network.absent', name=net.name)
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
self.assertEqual(
ret['changes'],
{
'removed': True,
'disconnected': [container_name],
}
)
self.assertEqual(
ret['comment'],
'Removed network \'{0}\''.format(net.name)
)
@with_network(create=False)
def test_absent_when_not_present(self, net):
ret = self.run_state('docker_network.absent', name=net.name)
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
self.assertEqual(ret['changes'], {})
self.assertEqual(
ret['comment'],
'Network \'{0}\' already absent'.format(net.name)
)
@with_network(create=False)
def test_present(self, net):
ret = self.run_state('docker_network.present', name=net.name)
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
# Make sure the state return is what we expect
self.assertEqual(ret['changes'], {'created': True})
self.assertEqual(
ret['comment'],
'Network \'{0}\' created'.format(net.name)
)
# Now check to see that the network actually exists. If it doesn't,
# this next function call will raise an exception.
self.run_function('docker.inspect_network', [net.name])
@container_name
@with_network(create=False)
def test_present_with_containers(self, net, container_name):
ret = self.run_state(
'docker_network.present',
name=net.name,
containers=[container_name])
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
self.assertEqual(
ret['changes'],
{
'created': True,
'connected': [container_name],
}
)
self.assertEqual(
ret['comment'],
'Network \'{0}\' created'.format(net.name)
)
# Now check to see that the network actually exists. If it doesn't,
# this next function call will raise an exception.
self.run_function('docker.inspect_network', [net.name])
def _test_present_reconnect(self, net, container_name, reconnect=True):
ret = self.run_state(
'docker_network.present',
name=net.name,
driver='bridge')
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
self.assertEqual(ret['changes'], {'created': True})
self.assertEqual(
ret['comment'],
'Network \'{0}\' created'.format(net.name)
)
# Connect the container
self.run_function(
'docker.connect_container_to_network',
[container_name, net.name]
)
# Change the driver to force the network to be replaced
ret = self.run_state(
'docker_network.present',
name=net.name,
driver='macvlan',
reconnect=reconnect)
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
self.assertEqual(
ret['changes'],
{
'recreated': True,
'reconnected' if reconnect else 'disconnected': [container_name],
net.name: {
'Driver': {
'old': 'bridge',
'new': 'macvlan',
}
}
}
)
self.assertEqual(
ret['comment'],
'Network \'{0}\' was replaced with updated config'.format(net.name)
)
@container_name
@with_network(create=False)
def test_present_with_reconnect(self, net, container_name):
'''
Test reconnecting with containers not passed to state
'''
self._test_present_reconnect(net, container_name, reconnect=True)
@container_name
@with_network(create=False)
def test_present_with_no_reconnect(self, net, container_name):
'''
Test reconnecting with containers not passed to state
'''
self._test_present_reconnect(net, container_name, reconnect=False)
@with_network()
def test_present_internal(self, net):
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=net.name,
internal=True,
)
)
net_info = self.run_function('docker.inspect_network', [net.name])
self.assertIs(net_info['Internal'], True)
@with_network()
def test_present_labels(self, net):
# Test a mix of different ways of specifying labels
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=net.name,
labels=[
'foo',
'bar=baz',
{'hello': 'world'},
],
)
)
net_info = self.run_function('docker.inspect_network', [net.name])
self.assertEqual(
net_info['Labels'],
{'foo': '',
'bar': 'baz',
'hello': 'world'},
)
@with_network(subnet='fe3f:2180:26:1::/123')
@with_network(subnet='10.247.197.96/27')
@skipIf(not IPV6_ENABLED, 'IPv6 not enabled')
def test_present_enable_ipv6(self, net1, net2):
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=net1.name,
enable_ipv6=True,
ipam_pools=[
{'subnet': net1.subnet},
{'subnet': net2.subnet},
],
)
)
net_info = self.run_function('docker.inspect_network', [net1.name])
self.assertIs(net_info['EnableIPv6'], True)
@with_network()
def test_present_attachable(self, net):
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=net.name,
attachable=True,
)
)
net_info = self.run_function('docker.inspect_network', [net.name])
self.assertIs(net_info['Attachable'], True)
@skipIf(True, 'Skip until we can set up docker swarm testing')
@with_network()
def test_present_scope(self, net):
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=net.name,
scope='global',
)
)
net_info = self.run_function('docker.inspect_network', [net.name])
self.assertIs(net_info['Scope'], 'global')
@skipIf(True, 'Skip until we can set up docker swarm testing')
@with_network()
def test_present_ingress(self, net):
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=net.name,
ingress=True,
)
)
net_info = self.run_function('docker.inspect_network', [net.name])
self.assertIs(net_info['Ingress'], True)
@with_network(subnet='10.247.197.128/27')
@with_network(subnet='10.247.197.96/27')
def test_present_with_custom_ipv4(self, net1, net2):
# First run will test passing the IPAM arguments individually
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=net1.name,
subnet=net1.subnet,
gateway=net1.gateway,
)
)
# Second run will pass them in the ipam_pools argument
ret = self.run_state(
'docker_network.present',
name=net1.name, # We want to keep the same network name
ipam_pools=[
{'subnet': net2.subnet,
'gateway': net2.gateway},
],
)
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
# Docker requires there to be IPv4, even when only an IPv6 subnet was
# provided. So, there will be both an IPv4 and IPv6 pool in the
# configuration.
expected = {
'recreated': True,
net1.name: {
'IPAM': {
'Config': {
'old': [
{'Subnet': net1.subnet,
'Gateway': net1.gateway},
],
'new': [
{'Subnet': net2.subnet,
'Gateway': net2.gateway},
],
}
}
}
}
self.assertEqual(ret['changes'], expected)
self.assertEqual(
ret['comment'],
'Network \'{0}\' was replaced with updated config'.format(
net1.name
)
)
@with_network(subnet='fe3f:2180:26:1::20/123')
@with_network(subnet='fe3f:2180:26:1::/123')
@with_network(subnet='10.247.197.96/27')
@skipIf(not IPV6_ENABLED, 'IPv6 not enabled')
def test_present_with_custom_ipv6(self, ipv4_net, ipv6_net1, ipv6_net2):
self.assertSaltTrueReturn(
self.run_state(
'docker_network.present',
name=ipv4_net.name,
enable_ipv6=True,
ipam_pools=[
{'subnet': ipv4_net.subnet,
'gateway': ipv4_net.gateway},
{'subnet': ipv6_net1.subnet,
'gateway': ipv6_net1.gateway}
],
)
)
ret = self.run_state(
'docker_network.present',
name=ipv4_net.name, # We want to keep the same network name
enable_ipv6=True,
ipam_pools=[
{'subnet': ipv4_net.subnet,
'gateway': ipv4_net.gateway},
{'subnet': ipv6_net2.subnet,
'gateway': ipv6_net2.gateway}
],
)
self.assertSaltTrueReturn(ret)
ret = ret[next(iter(ret))]
# Docker requires there to be IPv4, even when only an IPv6 subnet was
# provided. So, there will be both an IPv4 and IPv6 pool in the
# configuration.
expected = {
'recreated': True,
ipv4_net.name: {
'IPAM': {
'Config': {
'old': [
{'Subnet': ipv4_net.subnet,
'Gateway': ipv4_net.gateway},
{'Subnet': ipv6_net1.subnet,
'Gateway': ipv6_net1.gateway}
],
'new': [
{'Subnet': ipv4_net.subnet,
'Gateway': ipv4_net.gateway},
{'Subnet': ipv6_net2.subnet,
'Gateway': ipv6_net2.gateway}
],
}
}
}
}
self.assertEqual(ret['changes'], expected)
self.assertEqual(
ret['comment'],
'Network \'{0}\' was replaced with updated config'.format(
ipv4_net.name
)
)

109
tests/support/docker.py Normal file
View file

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
'''
Common code used in Docker integration tests
'''
# Import Python libs
from __future__ import absolute_import
import functools
import random
import string
# Import Salt libs
from salt.exceptions import CommandExecutionError
# Import 3rd-party libs
from salt._compat import ipaddress
from salt.ext.six.moves import range # pylint: disable=import-error,redefined-builtin
def random_name(prefix=''):
ret = prefix
for _ in range(8):
ret += random.choice(string.ascii_lowercase)
return ret
class Network(object):
def __init__(self, name, **kwargs):
self.kwargs = kwargs
self.name = name
try:
self.net = ipaddress.ip_network(self.kwargs['subnet'])
self._rand_indexes = random.sample(
range(2, self.net.num_addresses - 1),
self.net.num_addresses - 3)
self.ip_arg = 'ipv{0}_address'.format(self.net.version)
except KeyError:
# No explicit subnet passed
self.net = self.ip_arg = None
def __getitem__(self, index):
try:
return self.net[self._rand_indexes[index]].compressed
except (TypeError, AttributeError):
raise ValueError(
'Indexing not supported for networks without a custom subnet')
def arg_map(self, arg_name):
return {'ipv4_address': 'IPv4Address',
'ipv6_address': 'IPv6Address',
'links': 'Links',
'aliases': 'Aliases'}[arg_name]
@property
def subnet(self):
try:
return self.net.compressed
except AttributeError:
return None
@property
def gateway(self):
try:
return self.kwargs['gateway']
except KeyError:
try:
return self.net[1].compressed
except (AttributeError, IndexError):
return None
class with_network(object):
'''
Generate a network for the test. Information about the network will be
passed to the wrapped function.
'''
def __init__(self, **kwargs):
self.create = kwargs.pop('create', False)
self.network = Network(random_name(prefix='salt_net_'), **kwargs)
if self.network.net is not None:
if 'enable_ipv6' not in kwargs:
kwargs['enable_ipv6'] = self.network.net.version == 6
self.kwargs = kwargs
def __call__(self, func):
self.func = func
return functools.wraps(func)(
lambda testcase, *args, **kwargs: self.wrap(testcase, *args, **kwargs) # pylint: disable=W0108
)
def wrap(self, testcase, *args, **kwargs):
if self.create:
testcase.run_function(
'docker.create_network',
[self.network.name],
**self.kwargs)
try:
return self.func(testcase, self.network, *args, **kwargs)
finally:
try:
testcase.run_function(
'docker.disconnect_all_containers_from_network',
[self.network.name])
except CommandExecutionError as exc:
if '404' not in exc.__str__():
raise
else:
testcase.run_function(
'docker.remove_network',
[self.network.name])

View file

@ -130,7 +130,7 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
('kill', ()),
('pause', ()),
('signal_', ('KILL',)),
('start', ()),
('start_', ()),
('stop', ()),
('unpause', ()),
('_run', ('command',)),
@ -164,8 +164,12 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
host_config = {}
client = Mock()
client.api_version = '1.21'
client.networks = Mock(return_value=[
{'Name': 'foo',
'Id': '01234',
'Containers': {}}
])
get_client_mock = MagicMock(return_value=client)
with patch.dict(docker_mod.__dict__,
{'__salt__': __salt__}):
with patch.object(docker_mod, '_get_client', get_client_mock):
@ -207,7 +211,6 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
'Subnet': '192.168.0.0/24'
}],
'Driver': 'default',
'Options': {}
},
check_duplicate=True)
@ -276,7 +279,7 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
with patch.object(docker_mod, '_get_client', get_client_mock):
docker_mod.connect_container_to_network('container', 'foo')
client.connect_container_to_network.assert_called_once_with(
'container', 'foo', None)
'container', 'foo')
@skipIf(docker_version < (1, 5, 0),
'docker module must be installed to run this test or is too old. >=1.5.0')
@ -546,16 +549,13 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
})
ret = None
with patch.object(docker_mod, 'start', docker_start_mock):
with patch.object(docker_mod, 'create', docker_create_mock):
with patch.object(docker_mod, 'stop', docker_stop_mock):
with patch.object(docker_mod, 'commit', docker_commit_mock):
with patch.object(docker_mod, 'sls', docker_sls_mock):
with patch.object(docker_mod, 'rm_', docker_rm_mock):
ret = docker_mod.sls_build(
'foo',
mods='foo',
)
with patch.object(docker_mod, 'start_', docker_start_mock), \
patch.object(docker_mod, 'create', docker_create_mock), \
patch.object(docker_mod, 'stop', docker_stop_mock), \
patch.object(docker_mod, 'commit', docker_commit_mock), \
patch.object(docker_mod, 'sls', docker_sls_mock), \
patch.object(docker_mod, 'rm_', docker_rm_mock):
ret = docker_mod.sls_build('foo', mods='foo')
docker_create_mock.assert_called_once_with(
cmd='sleep infinity',
image='opensuse/python', interactive=True, tty=True)
@ -563,7 +563,7 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
docker_sls_mock.assert_called_once_with('ID', 'foo', 'base')
docker_stop_mock.assert_called_once_with('ID')
docker_rm_mock.assert_called_once_with('ID')
docker_commit_mock.assert_called_once_with('ID', 'foo')
docker_commit_mock.assert_called_once_with('ID', 'foo', tag='latest')
self.assertEqual(
{'Id': 'ID2', 'Image': 'foo', 'Time_Elapsed': 42}, ret)
@ -604,16 +604,12 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
})
ret = None
with patch.object(docker_mod, 'start', docker_start_mock):
with patch.object(docker_mod, 'create', docker_create_mock):
with patch.object(docker_mod, 'stop', docker_stop_mock):
with patch.object(docker_mod, 'rm_', docker_rm_mock):
with patch.object(docker_mod, 'sls', docker_sls_mock):
ret = docker_mod.sls_build(
'foo',
mods='foo',
dryrun=True
)
with patch.object(docker_mod, 'start_', docker_start_mock), \
patch.object(docker_mod, 'create', docker_create_mock), \
patch.object(docker_mod, 'stop', docker_stop_mock), \
patch.object(docker_mod, 'rm_', docker_rm_mock), \
patch.object(docker_mod, 'sls', docker_sls_mock):
ret = docker_mod.sls_build('foo', mods='foo', dryrun=True)
docker_create_mock.assert_called_once_with(
cmd='sleep infinity',
image='opensuse/python', interactive=True, tty=True)
@ -669,19 +665,15 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
context = {'docker.exec_driver': 'docker-exec'}
salt_dunder = {'config.option': docker_config_mock}
with patch.object(docker_mod, 'run_all', docker_run_all_mock):
with patch.object(docker_mod, 'copy_to', docker_copy_to_mock):
with patch.object(docker_mod, '_get_client', get_client_mock):
with patch.dict(docker_mod.__opts__, {'cachedir': '/tmp'}):
with patch.dict(docker_mod.__salt__, salt_dunder):
with patch.dict(docker_mod.__context__, context):
# call twice to verify tmp path later
for i in range(2):
ret = docker_mod.call(
'ID',
'test.arg',
1, 2,
arg1='val1')
with patch.object(docker_mod, 'run_all', docker_run_all_mock), \
patch.object(docker_mod, 'copy_to', docker_copy_to_mock), \
patch.object(docker_mod, '_get_client', get_client_mock), \
patch.dict(docker_mod.__opts__, {'cachedir': '/tmp'}), \
patch.dict(docker_mod.__salt__, salt_dunder), \
patch.dict(docker_mod.__context__, context):
# call twice to verify tmp path later
for i in range(2):
ret = docker_mod.call('ID', 'test.arg', 1, 2, arg1='val1')
# Check that the directory is different each time
# [ call(name, [args]), ...
@ -759,21 +751,28 @@ class DockerTestCase(TestCase, LoaderModuleMockMixin):
def test_resolve_tag(self):
'''
Test the resolve_tag function
Test the resolve_tag function. It runs docker.insect_image on the image
name passed and then looks for the RepoTags key in the result
'''
with_prefix = 'docker.io/foo:latest'
no_prefix = 'bar:latest'
with patch.object(docker_mod,
'list_tags',
MagicMock(return_value=[with_prefix])):
self.assertEqual(docker_mod.resolve_tag('foo'), with_prefix)
self.assertEqual(docker_mod.resolve_tag('foo:latest'), with_prefix)
self.assertEqual(docker_mod.resolve_tag(with_prefix), with_prefix)
self.assertEqual(docker_mod.resolve_tag('foo:bar'), False)
id_ = 'sha256:6ad733544a6317992a6fac4eb19fe1df577d4dec7529efec28a5bd0edad0fd30'
tags = ['foo:latest', 'foo:bar']
mock_tagged = MagicMock(return_value={'Id': id_, 'RepoTags': tags})
mock_untagged = MagicMock(return_value={'Id': id_, 'RepoTags': []})
mock_unexpected = MagicMock(return_value={'Id': id_})
mock_not_found = MagicMock(side_effect=CommandExecutionError())
with patch.object(docker_mod,
'list_tags',
MagicMock(return_value=[no_prefix])):
self.assertEqual(docker_mod.resolve_tag('bar'), no_prefix)
self.assertEqual(docker_mod.resolve_tag(no_prefix), no_prefix)
self.assertEqual(docker_mod.resolve_tag('bar:baz'), False)
with patch.object(docker_mod, 'inspect_image', mock_tagged):
self.assertEqual(docker_mod.resolve_tag('foo'), tags[0])
self.assertEqual(docker_mod.resolve_tag('foo', all=True), tags)
with patch.object(docker_mod, 'inspect_image', mock_untagged):
self.assertEqual(docker_mod.resolve_tag('foo'), id_)
self.assertEqual(docker_mod.resolve_tag('foo', all=True), [id_])
with patch.object(docker_mod, 'inspect_image', mock_unexpected):
self.assertEqual(docker_mod.resolve_tag('foo'), id_)
self.assertEqual(docker_mod.resolve_tag('foo', all=True), [id_])
with patch.object(docker_mod, 'inspect_image', mock_not_found):
self.assertIs(docker_mod.resolve_tag('foo'), False)
self.assertIs(docker_mod.resolve_tag('foo', all=True), False)

View file

@ -1,216 +0,0 @@
# -*- coding: utf-8 -*-
'''
Unit tests for the docker state
'''
# Import Python Libs
from __future__ import absolute_import
# Import Salt Testing Libs
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.unit import skipIf, TestCase
from tests.support.mock import (
Mock,
NO_MOCK,
NO_MOCK_REASON,
patch
)
# Import Salt Libs
import salt.modules.dockermod as docker_mod
import salt.states.docker_network as docker_state
@skipIf(NO_MOCK, NO_MOCK_REASON)
class DockerNetworkTestCase(TestCase, LoaderModuleMockMixin):
'''
Test docker_network states
'''
def setup_loader_modules(self):
return {
docker_mod: {
'__context__': {'docker.docker_version': ''}
},
docker_state: {
'__opts__': {'test': False}
}
}
def test_present(self):
'''
Test docker_network.present
'''
docker_create_network = Mock(return_value='created')
docker_connect_container_to_network = Mock(return_value='connected')
docker_inspect_container = Mock(return_value={'Id': 'abcd', 'Name': 'container_bar'})
# Get docker.networks to return a network with a name which is a superset of the name of
# the network which is to be created, despite this network existing we should still expect
# that the new network will be created.
# Regression test for #41982.
docker_networks = Mock(return_value=[{
'Name': 'network_foobar',
'Containers': {'container': {}}
}])
__salt__ = {'docker.create_network': docker_create_network,
'docker.inspect_container': docker_inspect_container,
'docker.connect_container_to_network': docker_connect_container_to_network,
'docker.networks': docker_networks,
}
with patch.dict(docker_state.__dict__,
{'__salt__': __salt__}):
ret = docker_state.present(
'network_foo',
containers=['container'],
gateway='192.168.0.1',
ip_range='192.168.0.128/25',
subnet='192.168.0.0/24'
)
docker_create_network.assert_called_with('network_foo',
driver=None,
driver_opts=None,
gateway='192.168.0.1',
ip_range='192.168.0.128/25',
subnet='192.168.0.0/24')
docker_connect_container_to_network.assert_called_with('abcd',
'network_foo')
self.assertEqual(ret, {'name': 'network_foo',
'comment': '',
'changes': {'connected': ['container_bar'],
'created': 'created'},
'result': True})
def test_present_with_change(self):
'''
Test docker_network.present when the specified network has properties differing from the already present network
'''
network_details = {
'Id': 'abcd',
'Name': 'network_foo',
'Driver': 'macvlan',
'Containers': {
'abcd': {}
},
'Options': {
'parent': 'eth0'
},
'IPAM': {
'Config': [
{
'Subnet': '192.168.0.0/24',
'Gateway': '192.168.0.1'
}
]
}
}
docker_networks = Mock(return_value=[network_details])
network_details['Containers'] = {'abcd': {'Id': 'abcd', 'Name': 'container_bar'}}
docker_inspect_network = Mock(return_value=network_details)
docker_inspect_container = Mock(return_value={'Id': 'abcd', 'Name': 'container_bar'})
docker_disconnect_container_from_network = Mock(return_value='disconnected')
docker_remove_network = Mock(return_value='removed')
docker_create_network = Mock(return_value='created')
docker_connect_container_to_network = Mock(return_value='connected')
__salt__ = {'docker.networks': docker_networks,
'docker.inspect_network': docker_inspect_network,
'docker.inspect_container': docker_inspect_container,
'docker.disconnect_container_from_network': docker_disconnect_container_from_network,
'docker.remove_network': docker_remove_network,
'docker.create_network': docker_create_network,
'docker.connect_container_to_network': docker_connect_container_to_network,
}
with patch.dict(docker_state.__dict__,
{'__salt__': __salt__}):
ret = docker_state.present(
'network_foo',
driver='macvlan',
gateway='192.168.1.1',
subnet='192.168.1.0/24',
driver_opts={'parent': 'eth1'},
containers=['abcd']
)
docker_disconnect_container_from_network.assert_called_with('abcd', 'network_foo')
docker_remove_network.assert_called_with('network_foo')
docker_create_network.assert_called_with('network_foo',
driver='macvlan',
driver_opts={'parent': 'eth1'},
gateway='192.168.1.1',
ip_range=None,
subnet='192.168.1.0/24')
docker_connect_container_to_network.assert_called_with('abcd', 'network_foo')
self.assertEqual(ret, {'name': 'network_foo',
'comment': 'Network \'network_foo\' was replaced with updated config',
'changes': {
'updated': {'network_foo': {
'old': {
'driver_opts': {'parent': 'eth0'},
'gateway': '192.168.0.1',
'subnet': '192.168.0.0/24'
},
'new': {
'driver_opts': {'parent': 'eth1'},
'gateway': '192.168.1.1',
'subnet': '192.168.1.0/24'
}
}},
'reconnected': ['container_bar']
},
'result': True})
def test_absent(self):
'''
Test docker_network.absent
'''
docker_remove_network = Mock(return_value='removed')
docker_disconnect_container_from_network = Mock(return_value='disconnected')
docker_networks = Mock(return_value=[{
'Name': 'network_foo',
'Containers': {'container': {}}
}])
__salt__ = {
'docker.remove_network': docker_remove_network,
'docker.disconnect_container_from_network': docker_disconnect_container_from_network,
'docker.networks': docker_networks,
}
with patch.dict(docker_state.__dict__,
{'__salt__': __salt__}):
ret = docker_state.absent('network_foo')
docker_disconnect_container_from_network.assert_called_with('container',
'network_foo')
docker_remove_network.assert_called_with('network_foo')
self.assertEqual(ret, {'name': 'network_foo',
'comment': '',
'changes': {'disconnected': 'disconnected',
'removed': 'removed'},
'result': True})
def test_absent_with_matching_network(self):
'''
Test docker_network.absent when the specified network does not exist,
but another network with a name which is a superset of the specified
name does exist. In this case we expect there to be no attempt to remove
any network.
Regression test for #41982.
'''
docker_remove_network = Mock(return_value='removed')
docker_disconnect_container_from_network = Mock(return_value='disconnected')
docker_networks = Mock(return_value=[{
'Name': 'network_foobar',
'Containers': {'container': {}}
}])
__salt__ = {
'docker.remove_network': docker_remove_network,
'docker.disconnect_container_from_network': docker_disconnect_container_from_network,
'docker.networks': docker_networks,
}
with patch.dict(docker_state.__dict__,
{'__salt__': __salt__}):
ret = docker_state.absent('network_foo')
docker_disconnect_container_from_network.assert_not_called()
docker_remove_network.assert_not_called()
self.assertEqual(ret, {'name': 'network_foo',
'comment': 'Network \'network_foo\' already absent',
'changes': {},
'result': True})

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Import Python libs
from __future__ import absolute_import
import logging
import socket
# Import Salt Testing libs
@ -11,6 +12,8 @@ from tests.support.mock import NO_MOCK, NO_MOCK_REASON, patch, MagicMock
# Import salt libs
import salt.utils.network as network
log = logging.getLogger(__name__)
LINUX = '''\
eth0 Link encap:Ethernet HWaddr e0:3f:49:85:6a:af
inet addr:10.10.10.56 Bcast:10.10.10.255 Mask:255.255.252.0
@ -96,6 +99,11 @@ USER COMMAND PID FD PROTO LOCAL ADDRESS FOREIGN ADDRESS
salt-master python2.781106 35 tcp4 127.0.0.1:61115 127.0.0.1:4506
'''
IPV4_SUBNETS = {True: ('10.10.0.0/24',),
False: ('10.10.0.0', '10.10.0.0/33', 'FOO', 9, '0.9.800.1000/24')}
IPV6_SUBNETS = {True: ('::1/128',),
False: ('::1', '::1/129', 'FOO', 9, 'aj01::feac/64')}
@skipIf(NO_MOCK, NO_MOCK_REASON)
class NetworkTestCase(TestCase):
@ -160,6 +168,31 @@ class NetworkTestCase(TestCase):
self.assertFalse(network.is_ipv6('10.0.1.2'))
self.assertFalse(network.is_ipv6('2001.0db8.85a3.0000.0000.8a2e.0370.7334'))
def test_is_subnet(self):
for subnet_data in (IPV4_SUBNETS, IPV6_SUBNETS):
for item in subnet_data[True]:
log.debug('Testing that %s is a valid subnet', item)
self.assertTrue(network.is_subnet(item))
for item in subnet_data[False]:
log.debug('Testing that %s is not a valid subnet', item)
self.assertFalse(network.is_subnet(item))
def test_is_ipv4_subnet(self):
for item in IPV4_SUBNETS[True]:
log.debug('Testing that %s is a valid subnet', item)
self.assertTrue(network.is_ipv4_subnet(item))
for item in IPV4_SUBNETS[False]:
log.debug('Testing that %s is not a valid subnet', item)
self.assertFalse(network.is_ipv4_subnet(item))
def test_is_ipv6_subnet(self):
for item in IPV6_SUBNETS[True]:
log.debug('Testing that %s is a valid subnet', item)
self.assertTrue(network.is_ipv6_subnet(item))
for item in IPV6_SUBNETS[False]:
log.debug('Testing that %s is not a valid subnet', item)
self.assertFalse(network.is_ipv6_subnet(item))
def test_cidr_to_ipv4_netmask(self):
self.assertEqual(network.cidr_to_ipv4_netmask(24), '255.255.255.0')
self.assertEqual(network.cidr_to_ipv4_netmask(21), '255.255.248.0')