Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Additional validation for container volumes and ports. #442

Merged
merged 3 commits into from
Sep 4, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/yml.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,18 @@ expose:

### volumes

Mount paths as volumes, optionally specifying a path on the host machine (`HOST:CONTAINER`).
Mount paths as volumes, optionally specifying a path on the host machine
(`HOST:CONTAINER`), or an access mode (`HOST:CONTAINER:ro`).

Note: Mapping local volumes is currently unsupported on boot2docker. We recommend you use [docker-osx](https://github.com/noplay/docker-osx) if want to map local volumes.
Note for fig on OSX: Mapping local volumes is currently unsupported on
boot2docker. We recommend you use [docker-osx](https://github.com/noplay/docker-osx)
if want to map local volumes on OSX.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've seen multiple people confused about this so I tried to clarify that this is only an issue on OSX, not linux.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1


```
volumes:
- /var/lib/mysql
- cache/:/tmp/cache
- ~/configs:/etc/configs/:ro
```

### volumes_from
Expand Down
2 changes: 1 addition & 1 deletion fig/cli/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def get_tty_width():
if len(tty_size) != 2:
return 80
_, width = tty_size
return width
return int(width)


class Formatter(object):
Expand Down
104 changes: 55 additions & 49 deletions fig/service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from collections import namedtuple
from .packages.docker.errors import APIError
import logging
import re
Expand Down Expand Up @@ -39,6 +40,9 @@ class ConfigError(ValueError):
pass


VolumeSpec = namedtuple('VolumeSpec', 'external internal mode')


class Service(object):
def __init__(self, name, client=None, project='default', links=None, volumes_from=None, **options):
if not re.match('^%s+$' % VALID_NAME_CHARS, name):
Expand Down Expand Up @@ -214,37 +218,22 @@ def start_container_if_stopped(self, container, **options):
return self.start_container(container, **options)

def start_container(self, container=None, intermediate_container=None, **override_options):
if container is None:
container = self.create_container(**override_options)

options = self.options.copy()
options.update(override_options)

port_bindings = {}
container = container or self.create_container(**override_options)
options = dict(self.options, **override_options)
ports = dict(split_port(port) for port in options.get('ports') or [])

if options.get('ports', None) is not None:
for port in options['ports']:
internal_port, external_port = split_port(port)
port_bindings[internal_port] = external_port

volume_bindings = {}

if options.get('volumes', None) is not None:
for volume in options['volumes']:
if ':' in volume:
external_dir, internal_dir = volume.split(':')
volume_bindings[os.path.abspath(external_dir)] = {
'bind': internal_dir,
'ro': False,
}
volume_bindings = dict(
build_volume_binding(parse_volume_spec(volume))
for volume in options.get('volumes') or []
if ':' in volume)

privileged = options.get('privileged', False)
net = options.get('net', 'bridge')
dns = options.get('dns', None)

container.start(
links=self._get_links(link_to_self=override_options.get('one_off', False)),
port_bindings=port_bindings,
links=self._get_links(link_to_self=options.get('one_off', False)),
port_bindings=ports,
binds=volume_bindings,
volumes_from=self._get_volumes_from(intermediate_container),
privileged=privileged,
Expand All @@ -256,7 +245,7 @@ def start_container(self, container=None, intermediate_container=None, **overrid
def start_or_create_containers(self):
containers = self.containers(stopped=True)

if len(containers) == 0:
if not containers:
log.info("Creating %s..." % self.next_container_name())
new_container = self.create_container()
return [self.start_container(new_container)]
Expand Down Expand Up @@ -338,7 +327,9 @@ def _get_container_create_options(self, override_options, one_off=False):
container_options['ports'] = ports

if 'volumes' in container_options:
container_options['volumes'] = dict((split_volume(v)[1], {}) for v in container_options['volumes'])
container_options['volumes'] = dict(
(parse_volume_spec(v).internal, {})
for v in container_options['volumes'])

if 'environment' in container_options:
if isinstance(container_options['environment'], list):
Expand Down Expand Up @@ -433,32 +424,47 @@ def get_container_name(container):
return name[1:]


def split_volume(v):
"""
If v is of the format EXTERNAL:INTERNAL, returns (EXTERNAL, INTERNAL).
If v is of the format INTERNAL, returns (None, INTERNAL).
"""
if ':' in v:
return v.split(':', 1)
else:
return (None, v)
def parse_volume_spec(volume_config):
parts = volume_config.split(':')
if len(parts) > 3:
raise ConfigError("Volume %s has incorrect format, should be "
"external:internal[:mode]" % volume_config)

if len(parts) == 1:
return VolumeSpec(None, parts[0], 'rw')

if len(parts) == 2:
parts.append('rw')

external, internal, mode = parts
if mode not in ('rw', 'ro'):
raise ConfigError("Volume %s has invalid mode (%s), should be "
"one of: rw, ro." % (volume_config, mode))

return VolumeSpec(external, internal, mode)


def build_volume_binding(volume_spec):
internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'}
external = os.path.expanduser(volume_spec.external)
return os.path.abspath(os.path.expandvars(external)), internal


def split_port(port):
port = str(port)
external_ip = None
if ':' in port:
external_port, internal_port = port.rsplit(':', 1)
if ':' in external_port:
external_ip, external_port = external_port.split(':', 1)
else:
external_port, internal_port = (None, port)
if external_ip:
if external_port:
external_port = (external_ip, external_port)
else:
external_port = (external_ip,)
return internal_port, external_port
parts = str(port).split(':')
if not 1 <= len(parts) <= 3:
raise ConfigError('Invalid port "%s", should be '
'[[remote_ip:]remote_port:]port[/protocol]' % port)

if len(parts) == 1:
internal_port, = parts
return internal_port, None
if len(parts) == 2:
external_port, internal_port = parts
return internal_port, external_port

external_ip, external_port, internal_port = parts
return internal_port, (external_ip, external_port or None)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This list-fiddling stuff is really confusing. Could we bring back the local variables, so I can see what's being returned in each case?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I'm happy to make some changes here. I think in order to validate the string properly, we still need to make it a list, but additional local variables I can add.


def split_env(env):
Expand Down
71 changes: 68 additions & 3 deletions tests/unit/service_test.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
from __future__ import unicode_literals
from __future__ import absolute_import
import os

from .. import unittest
import mock

from fig import Service
from fig.service import ConfigError, split_port
from fig.service import (
ConfigError,
split_port,
parse_volume_spec,
build_volume_binding,
)


class ServiceTest(unittest.TestCase):
def test_name_validations(self):
Expand All @@ -28,23 +38,35 @@ def test_config_validation(self):
self.assertRaises(ConfigError, lambda: Service(name='foo', port=['8000']))
Service(name='foo', ports=['8000'])

def test_split_port(self):
def test_split_port_with_host_ip(self):
internal_port, external_port = split_port("127.0.0.1:1000:2000")
self.assertEqual(internal_port, "2000")
self.assertEqual(external_port, ("127.0.0.1", "1000"))

def test_split_port_with_protocol(self):
internal_port, external_port = split_port("127.0.0.1:1000:2000/udp")
self.assertEqual(internal_port, "2000/udp")
self.assertEqual(external_port, ("127.0.0.1", "1000"))

def test_split_port_with_host_ip_no_port(self):
internal_port, external_port = split_port("127.0.0.1::2000")
self.assertEqual(internal_port, "2000")
self.assertEqual(external_port, ("127.0.0.1",))
self.assertEqual(external_port, ("127.0.0.1", None))

def test_split_port_with_host_port(self):
internal_port, external_port = split_port("1000:2000")
self.assertEqual(internal_port, "2000")
self.assertEqual(external_port, "1000")

def test_split_port_no_host_port(self):
internal_port, external_port = split_port("2000")
self.assertEqual(internal_port, "2000")
self.assertEqual(external_port, None)

def test_split_port_invalid(self):
with self.assertRaises(ConfigError):
split_port("0.0.0.0:1000:2000:tcp")

def test_split_domainname_none(self):
service = Service('foo',
hostname = 'name',
Expand Down Expand Up @@ -82,3 +104,46 @@ def test_split_domainname_weird(self):
opts = service._get_container_create_options({})
self.assertEqual(opts['hostname'], 'name.sub', 'hostname')
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')


class ServiceVolumesTest(unittest.TestCase):

def test_parse_volume_spec_only_one_path(self):
spec = parse_volume_spec('/the/volume')
self.assertEqual(spec, (None, '/the/volume', 'rw'))

def test_parse_volume_spec_internal_and_external(self):
spec = parse_volume_spec('external:interval')
self.assertEqual(spec, ('external', 'interval', 'rw'))

def test_parse_volume_spec_with_mode(self):
spec = parse_volume_spec('external:interval:ro')
self.assertEqual(spec, ('external', 'interval', 'ro'))

def test_parse_volume_spec_too_many_parts(self):
with self.assertRaises(ConfigError):
parse_volume_spec('one:two:three:four')

def test_parse_volume_bad_mode(self):
with self.assertRaises(ConfigError):
parse_volume_spec('one:two:notrw')

def test_build_volume_binding(self):
binding = build_volume_binding(parse_volume_spec('/outside:/inside'))
self.assertEqual(
binding,
('/outside', dict(bind='/inside', ro=False)))

@mock.patch.dict(os.environ)
def test_build_volume_binding_with_environ(self):
os.environ['VOLUME_PATH'] = '/opt'
binding = build_volume_binding(parse_volume_spec('${VOLUME_PATH}:/opt'))
self.assertEqual(binding, ('/opt', dict(bind='/opt', ro=False)))

@mock.patch.dict(os.environ)
def test_building_volume_binding_with_home(self):
os.environ['HOME'] = '/home/user'
binding = build_volume_binding(parse_volume_spec('~:/home/user'))
self.assertEqual(
binding,
('/home/user', dict(bind='/home/user', ro=False)))