Skip to content

Commit afb6003

Browse files
committed
Adds ~ support and ro mode support for volumes, along with some additional validation. Signed-off-by: Daniel Nephin <dnephin@gmail.com> Signed-off-by: Yuval Kohavi <yuval.kohavi@gmail.com>
1 parent 60686d4 commit afb6003

File tree

3 files changed

+100
-37
lines changed

3 files changed

+100
-37
lines changed

docs/yml.md

+6-2
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/service.py

+40-34
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 = {}
224-
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 = {}
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 [])
231224

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,
@@ -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,15 +424,30 @@ 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):

tests/unit/service_test.py

+54-1
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):
@@ -82,3 +92,46 @@ def test_split_domainname_weird(self):
8292
opts = service._get_container_create_options({})
8393
self.assertEqual(opts['hostname'], 'name.sub', 'hostname')
8494
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
95+
96+
97+
class ServiceVolumesTest(unittest.TestCase):
98+
99+
def test_parse_volume_spec_only_one_path(self):
100+
spec = parse_volume_spec('/the/volume')
101+
self.assertEqual(spec, (None, '/the/volume', 'rw'))
102+
103+
def test_parse_volume_spec_internal_and_external(self):
104+
spec = parse_volume_spec('external:interval')
105+
self.assertEqual(spec, ('external', 'interval', 'rw'))
106+
107+
def test_parse_volume_spec_with_mode(self):
108+
spec = parse_volume_spec('external:interval:ro')
109+
self.assertEqual(spec, ('external', 'interval', 'ro'))
110+
111+
def test_parse_volume_spec_too_many_parts(self):
112+
with self.assertRaises(ConfigError):
113+
parse_volume_spec('one:two:three:four')
114+
115+
def test_parse_volume_bad_mode(self):
116+
with self.assertRaises(ConfigError):
117+
parse_volume_spec('one:two:notrw')
118+
119+
def test_build_volume_binding(self):
120+
binding = build_volume_binding(parse_volume_spec('/outside:/inside'))
121+
self.assertEqual(
122+
binding,
123+
('/outside', dict(bind='/inside', ro=False)))
124+
125+
@mock.patch.dict(os.environ)
126+
def test_build_volume_binding_with_environ(self):
127+
os.environ['VOLUME_PATH'] = '/opt'
128+
binding = build_volume_binding(parse_volume_spec('${VOLUME_PATH}:/opt'))
129+
self.assertEqual(binding, ('/opt', dict(bind='/opt', ro=False)))
130+
131+
@mock.patch.dict(os.environ)
132+
def test_building_volume_binding_with_home(self):
133+
os.environ['HOME'] = '/home/user'
134+
binding = build_volume_binding(parse_volume_spec('~:/home/user'))
135+
self.assertEqual(
136+
binding,
137+
('/home/user', dict(bind='/home/user', ro=False)))

0 commit comments

Comments
 (0)