Skip to content

Commit 7ad91f3

Browse files
committed
Merge pull request #442 from dnephin/fix_create_container_volumes
Additional validation for container volumes and ports.
2 parents 6dab8c1 + 24044fa commit 7ad91f3

4 files changed

Lines changed: 130 additions & 55 deletions

File tree

docs/yml.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,18 @@ expose:
7474

7575
### volumes
7676

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

79-
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.
80+
Note for fig on OSX: Mapping local volumes is currently unsupported on
81+
boot2docker. We recommend you use [docker-osx](https://github.com/noplay/docker-osx)
82+
if want to map local volumes on OSX.
8083

8184
```
8285
volumes:
8386
- /var/lib/mysql
8487
- cache/:/tmp/cache
88+
- ~/configs:/etc/configs/:ro
8589
```
8690

8791
### volumes_from

fig/cli/formatter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def get_tty_width():
99
if len(tty_size) != 2:
1010
return 80
1111
_, width = tty_size
12-
return width
12+
return int(width)
1313

1414

1515
class Formatter(object):

fig/service.py

Lines changed: 55 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import unicode_literals
22
from __future__ import absolute_import
3+
from collections import namedtuple
34
from .packages.docker.errors import APIError
45
import logging
56
import re
@@ -39,6 +40,9 @@ class ConfigError(ValueError):
3940
pass
4041

4142

43+
VolumeSpec = namedtuple('VolumeSpec', 'external internal mode')
44+
45+
4246
class Service(object):
4347
def __init__(self, name, client=None, project='default', links=None, volumes_from=None, **options):
4448
if not re.match('^%s+$' % VALID_NAME_CHARS, name):
@@ -214,37 +218,22 @@ def start_container_if_stopped(self, container, **options):
214218
return self.start_container(container, **options)
215219

216220
def start_container(self, container=None, intermediate_container=None, **override_options):
217-
if container is None:
218-
container = self.create_container(**override_options)
219-
220-
options = self.options.copy()
221-
options.update(override_options)
222-
223-
port_bindings = {}
221+
container = container or self.create_container(**override_options)
222+
options = dict(self.options, **override_options)
223+
ports = dict(split_port(port) for port in options.get('ports') or [])
224224

225-
if options.get('ports', None) is not None:
226-
for port in options['ports']:
227-
internal_port, external_port = split_port(port)
228-
port_bindings[internal_port] = external_port
229-
230-
volume_bindings = {}
231-
232-
if options.get('volumes', None) is not None:
233-
for volume in options['volumes']:
234-
if ':' in volume:
235-
external_dir, internal_dir = volume.split(':')
236-
volume_bindings[os.path.abspath(external_dir)] = {
237-
'bind': internal_dir,
238-
'ro': False,
239-
}
225+
volume_bindings = dict(
226+
build_volume_binding(parse_volume_spec(volume))
227+
for volume in options.get('volumes') or []
228+
if ':' in volume)
240229

241230
privileged = options.get('privileged', False)
242231
net = options.get('net', 'bridge')
243232
dns = options.get('dns', None)
244233

245234
container.start(
246-
links=self._get_links(link_to_self=override_options.get('one_off', False)),
247-
port_bindings=port_bindings,
235+
links=self._get_links(link_to_self=options.get('one_off', False)),
236+
port_bindings=ports,
248237
binds=volume_bindings,
249238
volumes_from=self._get_volumes_from(intermediate_container),
250239
privileged=privileged,
@@ -256,7 +245,7 @@ def start_container(self, container=None, intermediate_container=None, **overrid
256245
def start_or_create_containers(self):
257246
containers = self.containers(stopped=True)
258247

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

340329
if 'volumes' in container_options:
341-
container_options['volumes'] = dict((split_volume(v)[1], {}) for v in container_options['volumes'])
330+
container_options['volumes'] = dict(
331+
(parse_volume_spec(v).internal, {})
332+
for v in container_options['volumes'])
342333

343334
if 'environment' in container_options:
344335
if isinstance(container_options['environment'], list):
@@ -433,32 +424,47 @@ def get_container_name(container):
433424
return name[1:]
434425

435426

436-
def split_volume(v):
437-
"""
438-
If v is of the format EXTERNAL:INTERNAL, returns (EXTERNAL, INTERNAL).
439-
If v is of the format INTERNAL, returns (None, INTERNAL).
440-
"""
441-
if ':' in v:
442-
return v.split(':', 1)
443-
else:
444-
return (None, v)
427+
def parse_volume_spec(volume_config):
428+
parts = volume_config.split(':')
429+
if len(parts) > 3:
430+
raise ConfigError("Volume %s has incorrect format, should be "
431+
"external:internal[:mode]" % volume_config)
432+
433+
if len(parts) == 1:
434+
return VolumeSpec(None, parts[0], 'rw')
435+
436+
if len(parts) == 2:
437+
parts.append('rw')
438+
439+
external, internal, mode = parts
440+
if mode not in ('rw', 'ro'):
441+
raise ConfigError("Volume %s has invalid mode (%s), should be "
442+
"one of: rw, ro." % (volume_config, mode))
443+
444+
return VolumeSpec(external, internal, mode)
445+
446+
447+
def build_volume_binding(volume_spec):
448+
internal = {'bind': volume_spec.internal, 'ro': volume_spec.mode == 'ro'}
449+
external = os.path.expanduser(volume_spec.external)
450+
return os.path.abspath(os.path.expandvars(external)), internal
445451

446452

447453
def split_port(port):
448-
port = str(port)
449-
external_ip = None
450-
if ':' in port:
451-
external_port, internal_port = port.rsplit(':', 1)
452-
if ':' in external_port:
453-
external_ip, external_port = external_port.split(':', 1)
454-
else:
455-
external_port, internal_port = (None, port)
456-
if external_ip:
457-
if external_port:
458-
external_port = (external_ip, external_port)
459-
else:
460-
external_port = (external_ip,)
461-
return internal_port, external_port
454+
parts = str(port).split(':')
455+
if not 1 <= len(parts) <= 3:
456+
raise ConfigError('Invalid port "%s", should be '
457+
'[[remote_ip:]remote_port:]port[/protocol]' % port)
458+
459+
if len(parts) == 1:
460+
internal_port, = parts
461+
return internal_port, None
462+
if len(parts) == 2:
463+
external_port, internal_port = parts
464+
return internal_port, external_port
465+
466+
external_ip, external_port, internal_port = parts
467+
return internal_port, (external_ip, external_port or None)
462468

463469

464470
def split_env(env):

tests/unit/service_test.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
from __future__ import unicode_literals
22
from __future__ import absolute_import
3+
import os
4+
35
from .. import unittest
6+
import mock
7+
48
from fig import Service
5-
from fig.service import ConfigError, split_port
9+
from fig.service import (
10+
ConfigError,
11+
split_port,
12+
parse_volume_spec,
13+
build_volume_binding,
14+
)
15+
616

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

31-
def test_split_port(self):
41+
def test_split_port_with_host_ip(self):
3242
internal_port, external_port = split_port("127.0.0.1:1000:2000")
3343
self.assertEqual(internal_port, "2000")
3444
self.assertEqual(external_port, ("127.0.0.1", "1000"))
3545

46+
def test_split_port_with_protocol(self):
3647
internal_port, external_port = split_port("127.0.0.1:1000:2000/udp")
3748
self.assertEqual(internal_port, "2000/udp")
3849
self.assertEqual(external_port, ("127.0.0.1", "1000"))
3950

51+
def test_split_port_with_host_ip_no_port(self):
4052
internal_port, external_port = split_port("127.0.0.1::2000")
4153
self.assertEqual(internal_port, "2000")
42-
self.assertEqual(external_port, ("127.0.0.1",))
54+
self.assertEqual(external_port, ("127.0.0.1", None))
4355

56+
def test_split_port_with_host_port(self):
4457
internal_port, external_port = split_port("1000:2000")
4558
self.assertEqual(internal_port, "2000")
4659
self.assertEqual(external_port, "1000")
4760

61+
def test_split_port_no_host_port(self):
62+
internal_port, external_port = split_port("2000")
63+
self.assertEqual(internal_port, "2000")
64+
self.assertEqual(external_port, None)
65+
66+
def test_split_port_invalid(self):
67+
with self.assertRaises(ConfigError):
68+
split_port("0.0.0.0:1000:2000:tcp")
69+
4870
def test_split_domainname_none(self):
4971
service = Service('foo',
5072
hostname = 'name',
@@ -82,3 +104,46 @@ def test_split_domainname_weird(self):
82104
opts = service._get_container_create_options({})
83105
self.assertEqual(opts['hostname'], 'name.sub', 'hostname')
84106
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
107+
108+
109+
class ServiceVolumesTest(unittest.TestCase):
110+
111+
def test_parse_volume_spec_only_one_path(self):
112+
spec = parse_volume_spec('/the/volume')
113+
self.assertEqual(spec, (None, '/the/volume', 'rw'))
114+
115+
def test_parse_volume_spec_internal_and_external(self):
116+
spec = parse_volume_spec('external:interval')
117+
self.assertEqual(spec, ('external', 'interval', 'rw'))
118+
119+
def test_parse_volume_spec_with_mode(self):
120+
spec = parse_volume_spec('external:interval:ro')
121+
self.assertEqual(spec, ('external', 'interval', 'ro'))
122+
123+
def test_parse_volume_spec_too_many_parts(self):
124+
with self.assertRaises(ConfigError):
125+
parse_volume_spec('one:two:three:four')
126+
127+
def test_parse_volume_bad_mode(self):
128+
with self.assertRaises(ConfigError):
129+
parse_volume_spec('one:two:notrw')
130+
131+
def test_build_volume_binding(self):
132+
binding = build_volume_binding(parse_volume_spec('/outside:/inside'))
133+
self.assertEqual(
134+
binding,
135+
('/outside', dict(bind='/inside', ro=False)))
136+
137+
@mock.patch.dict(os.environ)
138+
def test_build_volume_binding_with_environ(self):
139+
os.environ['VOLUME_PATH'] = '/opt'
140+
binding = build_volume_binding(parse_volume_spec('${VOLUME_PATH}:/opt'))
141+
self.assertEqual(binding, ('/opt', dict(bind='/opt', ro=False)))
142+
143+
@mock.patch.dict(os.environ)
144+
def test_building_volume_binding_with_home(self):
145+
os.environ['HOME'] = '/home/user'
146+
binding = build_volume_binding(parse_volume_spec('~:/home/user'))
147+
self.assertEqual(
148+
binding,
149+
('/home/user', dict(bind='/home/user', ro=False)))

0 commit comments

Comments
 (0)