Merge lp:~johnsca/charm-helpers/docker into lp:charm-helpers
- docker
- Merge into devel
Status: | Needs review |
---|---|
Proposed branch: | lp:~johnsca/charm-helpers/docker |
Merge into: | lp:charm-helpers |
Diff against target: |
627 lines (+552/-1) 7 files modified
charmhelpers/contrib/docker/__init__.py (+287/-0) charmhelpers/core/host.py (+5/-0) charmhelpers/core/services/base.py (+14/-1) docs/api/charmhelpers.contrib.docker.rst (+8/-0) docs/api/charmhelpers.contrib.rst (+1/-0) docs/examples/docker.rst (+119/-0) tests/contrib/docker/test_docker.py (+118/-0) |
To merge this branch: | bzr merge lp:~johnsca/charm-helpers/docker |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
amir sanjar (community) | Approve | ||
Benjamin Saller (community) | Approve | ||
charmers | Pending | ||
Charm Helper Maintainers | Pending | ||
Review via email: mp+231226@code.launchpad.net |
Commit message
Description of the change
Added helpers for managing Docker containers in a charm, using the services framework. Includes documentation.
Kapil Thangavelu (hazmat) wrote : | # |
Cory Johns (johnsca) wrote : | # |
> i'd also appreciate an option for docker install latest ala
> https:/
Their instructions for installing the latest version on Ubuntu say to use their apt repo: https:/
You think there's likely to be drift between their apt repo version and the latest binary to warrant installing the binaries manually?
Kapil Thangavelu (hazmat) wrote : | # |
fair enough.. i was considering cross platform and the way i always install
it (static bin dl), in addition to the oncoming pkg binary name change
(though it will be backwards compatible). but what your doing is good given
the XP is theoretical at this point.
On Tue, Aug 19, 2014 at 10:05 AM, Cory Johns <email address hidden>
wrote:
> > i'd also appreciate an option for docker install latest ala
> > https:/
>
> Their instructions for installing the latest version on Ubuntu say to use
> their apt repo: https:/
>
> You think there's likely to be drift between their apt repo version and
> the latest binary to warrant installing the binaries manually?
> --
> https:/
> Your team Charm Helper Maintainers is requested to review the proposed
> merge of lp:~johnsca/charm-helpers/docker into lp:charm-helpers.
>
Cory Johns (johnsca) wrote : | # |
> fair enough.. i was considering cross platform and the way i always install
> it (static bin dl), in addition to the oncoming pkg binary name change
> (though it will be backwards compatible). but what your doing is good given
> the XP is theoretical at this point.
And since this is a helper, it will be easy enough to refactor it later as long as the API doesn't change (which is just a no-arg function call).
Benjamin Saller (bcsaller) wrote : | # |
LGTM, thanks, Good Work.
amir sanjar (asanjar) wrote : | # |
+1
As much as personally I don't think this should matter anymore, but charmers have frown at using
path as follow:
if os.path.
os.unlink(
recommend to change to:
docker_path = os.path.
Unmerged revisions
- 195. By Cory Johns
-
Added Docker helpers and documentation
Preview Diff
1 | === added directory 'charmhelpers/contrib/docker' |
2 | === added file 'charmhelpers/contrib/docker/__init__.py' |
3 | --- charmhelpers/contrib/docker/__init__.py 1970-01-01 00:00:00 +0000 |
4 | +++ charmhelpers/contrib/docker/__init__.py 2014-08-18 16:28:55 +0000 |
5 | @@ -0,0 +1,287 @@ |
6 | + |
7 | +import os |
8 | +import subprocess |
9 | + |
10 | +from charmhelpers import fetch |
11 | +from charmhelpers.core import host |
12 | +from charmhelpers.core import hookenv |
13 | +from charmhelpers.core.services.base import ManagerCallback |
14 | +from charmhelpers.core.services.helpers import RelationContext |
15 | + |
16 | + |
17 | +def install_docker(): |
18 | + """ |
19 | + Install the Docker tools. |
20 | + """ |
21 | + fetch.apt_install(['docker.io']) |
22 | + if os.path.exists('/usr/local/bin/docker'): |
23 | + os.unlink('/usr/local/bin/docker') |
24 | + os.symlink('/usr/bin/docker.io', '/usr/local/bin/docker') |
25 | + with open('/etc/bash_completion.d/docker.io', 'a') as fp: |
26 | + fp.write('\ncomplete -F _docker docker') |
27 | + |
28 | + |
29 | +def install_docker_unstable(): |
30 | + """ |
31 | + Install the Docker tools from the unstable (but potentially more |
32 | + up-to-date) repository. |
33 | + """ |
34 | + fetch.add_source('deb https://get.docker.io/ubuntu docker main', |
35 | + key='36A1D7869245C8950F966E92D8576A8BA88D21E9') |
36 | + fetch.apt_update(fatal=True) |
37 | + fetch.apt_install(['lxc-docker']) |
38 | + |
39 | + |
40 | +def docker_pull(container_name, registry=None, username=None, password=None): |
41 | + """ |
42 | + Fetch a container from the Docker registry. |
43 | + |
44 | + If :param:`registry` is given, the container will be pulled from that |
45 | + registry instead of the default. If the registry requires authentication, |
46 | + provide the :param:`username` and :param:`password` arguments. |
47 | + """ |
48 | + if registry is not None: |
49 | + container_name = '/'.join([registry, container_name]) |
50 | + if any(username, password): |
51 | + subprocess.check_call([ |
52 | + 'docker', 'login', '-u', username, '-p', password]) |
53 | + subprocess.check_call(['docker', 'pull', container_name]) |
54 | + |
55 | + |
56 | +class DockerCallback(ManagerCallback): |
57 | + """ |
58 | + ServiceManager callback to manage starting up a Docker container. |
59 | + |
60 | + Can be referenced as ``docker_start`` or ``docker_stop``, and performs |
61 | + the appropriate action. Requires one or more of |
62 | + :class:`DockerPortMappings`, :class:`DockerVolumes`, |
63 | + :class:`DockerContainerArgs`, and :class:`DockerRelation` to be |
64 | + included in the ``required_data`` section of the services definition. |
65 | + |
66 | + Example:: |
67 | + |
68 | + manager = services.ServiceManager([{ |
69 | + 'service': 'dockerfile/rethinkdb', |
70 | + 'required_data': [ |
71 | + DockerPortMappings({ |
72 | + 80: 8080, |
73 | + 28015: 28015, |
74 | + 29015: 29015, |
75 | + }), |
76 | + DockerVolumes(mapped_volumes={'data': '/rethinkdb'}), |
77 | + DockerContainerArgs( |
78 | + '--bind', 'all', |
79 | + '--canonical-address', hookenv.unit_get('public-address'), |
80 | + '--canonical-address', hookenv.unit_get('private-address'), |
81 | + '--machine-name', socket.gethostname().replace('-', '_'), |
82 | + ), |
83 | + ], |
84 | + 'start': docker_start, |
85 | + 'stop': docker_stop, |
86 | + }]) |
87 | + """ |
88 | + def __call__(self, manager, service_name, event_name): |
89 | + container_id_file = os.path.join(hookenv.charm_dir(), 'CONTAINER_ID') |
90 | + if os.path.exists(container_id_file): |
91 | + container_id = host.read_file(container_id_file) |
92 | + subprocess.check_call(['docker', 'stop', container_id]) |
93 | + os.remove(container_id_file) |
94 | + if event_name == 'start': |
95 | + subprocess.check_call( |
96 | + ['docker', 'run', '-d', '--cidfile', container_id_file] + |
97 | + self.get_volume_args(manager, service_name) + |
98 | + self.get_port_args(manager, service_name) + |
99 | + [service_name] + |
100 | + self.get_container_args(manager, service_name)) |
101 | + |
102 | + def _get_args(self, manager, service_name, arg_type): |
103 | + args = [] |
104 | + service = manager.get_service(service_name) |
105 | + for provider in service['required_data']: |
106 | + if isinstance(provider, arg_type): |
107 | + args.extend(provider.build_args()) |
108 | + return args |
109 | + |
110 | + def get_port_args(self, manager, service_name): |
111 | + return self._get_args(manager, service_name, DockerPortMappings) |
112 | + |
113 | + def get_container_args(self, manager, service_name): |
114 | + return self._get_args(manager, service_name, DockerContainerArgs) |
115 | + |
116 | + def get_volume_args(self, manager, service_name): |
117 | + return self._get_args(manager, service_name, DockerVolumes) |
118 | + |
119 | + |
120 | +class DockerPortMappings(dict): |
121 | + """ |
122 | + Context class for use in ``required_data`` representing a mapping of |
123 | + ports from the host to the container. |
124 | + |
125 | + Example:: |
126 | + |
127 | + manager = services.ServiceManager([{ |
128 | + 'required_data': [ |
129 | + DockerPortMappings({ |
130 | + 80: 8080, |
131 | + 28015: 28015, |
132 | + 29015: 29015, |
133 | + }), |
134 | + ], |
135 | + }]) |
136 | + |
137 | + """ |
138 | + def build_args(self): |
139 | + ports = [] |
140 | + for src, dst in self.iteritems(): |
141 | + ports.extend(['-p', '{}:{}'.format(src, dst)]) |
142 | + return ports |
143 | + |
144 | + |
145 | +class DockerVolumes(object): |
146 | + """ |
147 | + Context class for use in ``required_data`` representing Docker volumes. |
148 | + |
149 | + Example:: |
150 | + |
151 | + manager = services.ServiceManager([{ |
152 | + 'required_data': [ |
153 | + DockerVolumes(mapped_volumes={'data': '/rethinkdb'}), |
154 | + ], |
155 | + 'start': docker_start, |
156 | + 'stop': docker_stop, |
157 | + }]) |
158 | + """ |
159 | + def __init__(self, volumes=None, named_volumes=None, mapped_volumes=None): |
160 | + """ |
161 | + :param volumes: List of mutable data volumes to create. |
162 | + :type volumes: list |
163 | + :param named_volumes: Mapping of names to mutable data volumes to create. |
164 | + :type volumes: dict |
165 | + :param mapped_volumes: Mapping of host paths to container paths. |
166 | + :type volumes: dict |
167 | + """ |
168 | + assert any([volumes, named_volumes, mapped_volumes]),\ |
169 | + 'Must provide at least one of: volumes, named_volumes, mapped_volumes' |
170 | + self.volumes = volumes or [] |
171 | + self.named_volumes = named_volumes or {} |
172 | + self.mapped_volumes = mapped_volumes or {} |
173 | + |
174 | + def build_args(self): |
175 | + args = [] |
176 | + for volume in self.volumes: |
177 | + args.extend(['-v', volume]) |
178 | + for name, volume in self.named_volumes.iteritems(): |
179 | + args.extend(['-v', volume, '--name', name]) |
180 | + for host_path, volume in self.mapped_volumes.iteritems(): |
181 | + if not os.path.isabs(host_path): |
182 | + host_path = os.path.join(hookenv.charm_dir(), host_path) |
183 | + host.mkdir(host_path) |
184 | + args.extend(['-v', ':'.join([host_path, volume])]) |
185 | + return args |
186 | + |
187 | + |
188 | +class DockerContainerArgs(object): |
189 | + """ |
190 | + Context class for use in ``required_data`` representing arguments to be |
191 | + passed to the Docker container. |
192 | + |
193 | + Example:: |
194 | + |
195 | + manager = services.ServiceManager([{ |
196 | + 'required_data': [ |
197 | + DockerContainerArgs( |
198 | + '--bind', 'all', |
199 | + '--canonical-address', hookenv.unit_get('public-address'), |
200 | + '--canonical-address', hookenv.unit_get('private-address'), |
201 | + '--machine-name', socket.gethostname().replace('-', '_'), |
202 | + ), |
203 | + ], |
204 | + 'start': docker_start, |
205 | + 'stop': docker_stop, |
206 | + }]) |
207 | + """ |
208 | + def __init__(self, *args, **kwargs): |
209 | + """ |
210 | + Can accept either a list of arg strings, or a kwarg mapping of arg |
211 | + names to values. If kwargs are given, the names are prepended |
212 | + with '--' and have any underscores converted to dashes. |
213 | + |
214 | + For example, `DockerContainerArgs(my_opt='foo')` becomes `['--my-opt', 'foo']`. |
215 | + |
216 | + If you need to run a specific command other than the container default, it |
217 | + should be the first argument. |
218 | + """ |
219 | + self.args = list(args) |
220 | + for key, value in kwargs.iteritems(): |
221 | + self.args.extend(['--'+key.replace('_', '-'), value]) |
222 | + |
223 | + def build_args(self): |
224 | + return self.args |
225 | + |
226 | + |
227 | +class DockerRelation(RelationContext, DockerContainerArgs): |
228 | + """ |
229 | + :class:`~charmhelpers.core.services.helpers.RelationContext` subclass |
230 | + for use as a base class for contexts representing a relation to another |
231 | + Docker container, which could be another service, or a peer within the |
232 | + same service. |
233 | + |
234 | + Example:: |
235 | + |
236 | + class DatabaseContainerRelation(DockerRelation): |
237 | + name = 'db' |
238 | + interface = 'mysql-docker' |
239 | + required_keys = ['db-host', 'db-name'] |
240 | + |
241 | + manager = services.ServiceManager([{ |
242 | + 'required_data': [DatabaseContainerRelation()], |
243 | + 'start': docker_start, |
244 | + 'stop': docker_stop, |
245 | + }]) |
246 | + """ |
247 | + name = None |
248 | + interface = None |
249 | + required_keys = [] |
250 | + |
251 | + def map(self, relation_settings): |
252 | + """ |
253 | + Translate the relation settings from a single unit into a list of |
254 | + arguments to be passed to the Docker container. This may be called |
255 | + multiple times, once for each unit, and the resulting arguments will |
256 | + all be passed to the container. |
257 | + |
258 | + The default implementation simply appends ``--`` to the relation setting |
259 | + name, so that ``{'private-address': '10.0.0.1'}`` is transformed |
260 | + to ``['--private-address', '10.0.0.1']``. |
261 | + |
262 | + For example, the following subclass would translate the ``private-address`` |
263 | + value from each peer to a ``--join`` argument for each connected peer:: |
264 | + |
265 | + class ClusterPeers(DockerRelation): |
266 | + name = 'cluster' |
267 | + interface = 'cluster' |
268 | + required_keys = ['private-address'] |
269 | + port = 29015 |
270 | + |
271 | + def map(self, relation_settings): |
272 | + return [ |
273 | + '--join', '{}:{}'.format( |
274 | + relation_settings['private-address'], |
275 | + self.port |
276 | + ) |
277 | + ] |
278 | + """ |
279 | + args = [] |
280 | + for key, value in relation_settings.iteritems(): |
281 | + args.extend(['--{}'.format(key), str(value)]) |
282 | + return args |
283 | + |
284 | + def build_args(self): |
285 | + args = [] |
286 | + for unit in self.get(self.name, []): |
287 | + args.extend(self.map(unit)) |
288 | + return args |
289 | + |
290 | + |
291 | +# Convenience aliases for Docker |
292 | +docker_start = docker_stop = DockerCallback() |
293 | |
294 | === modified file 'charmhelpers/core/host.py' |
295 | --- charmhelpers/core/host.py 2014-08-05 20:52:59 +0000 |
296 | +++ charmhelpers/core/host.py 2014-08-18 16:28:55 +0000 |
297 | @@ -157,6 +157,11 @@ |
298 | target.write(content) |
299 | |
300 | |
301 | +def read_file(path): |
302 | + with open(path) as fp: |
303 | + return fp.read() |
304 | + |
305 | + |
306 | def fstab_remove(mp): |
307 | """Remove the given mountpoint entry from /etc/fstab |
308 | """ |
309 | |
310 | === modified file 'charmhelpers/core/services/base.py' |
311 | --- charmhelpers/core/services/base.py 2014-08-05 21:28:01 +0000 |
312 | +++ charmhelpers/core/services/base.py 2014-08-18 16:28:55 +0000 |
313 | @@ -31,6 +31,7 @@ |
314 | { |
315 | "service": <service name>, |
316 | "required_data": <list of required data contexts>, |
317 | + "provided_data": <list of provided data contexts>, |
318 | "data_ready": <one or more callbacks>, |
319 | "data_lost": <one or more callbacks>, |
320 | "start": <one or more callbacks>, |
321 | @@ -44,6 +45,10 @@ |
322 | of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more |
323 | information. |
324 | |
325 | + The 'provided_data' list should contain relation data providers, most likely |
326 | + a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`, |
327 | + that will indicate a set of data to set on a given relation. |
328 | + |
329 | The 'data_ready' value should be either a single callback, or a list of |
330 | callbacks, to be called when all items in 'required_data' pass `is_ready()`. |
331 | Each callback will be called with the service name as the only parameter. |
332 | @@ -123,12 +128,20 @@ |
333 | self.reconfigure_services() |
334 | |
335 | def provide_data(self): |
336 | + """ |
337 | + Set the relation data for each provider in the ``provided_data`` list. |
338 | + |
339 | + A provider must have a `name` attribute, which indicates which relation |
340 | + to set data on, and a `provide_data()` method, which returns a dict of |
341 | + data to set. |
342 | + """ |
343 | hook_name = hookenv.hook_name() |
344 | for service in self.services.values(): |
345 | for provider in service.get('provided_data', []): |
346 | if re.match(r'{}-relation-(joined|changed)'.format(provider.name), hook_name): |
347 | data = provider.provide_data() |
348 | - if provider._is_ready(data): |
349 | + _ready = provider._is_ready(data) if hasattr(provider, '_is_ready') else data |
350 | + if _ready: |
351 | hookenv.relation_set(None, data) |
352 | |
353 | def reconfigure_services(self, *service_names): |
354 | |
355 | === added file 'docs/api/charmhelpers.contrib.docker.rst' |
356 | --- docs/api/charmhelpers.contrib.docker.rst 1970-01-01 00:00:00 +0000 |
357 | +++ docs/api/charmhelpers.contrib.docker.rst 2014-08-18 16:28:55 +0000 |
358 | @@ -0,0 +1,8 @@ |
359 | +charmhelpers.contrib.docker package |
360 | +=================================== |
361 | + |
362 | +.. automodule:: charmhelpers.contrib.docker |
363 | + :members: |
364 | + :undoc-members: |
365 | + :show-inheritance: |
366 | + |
367 | |
368 | === modified file 'docs/api/charmhelpers.contrib.rst' |
369 | --- docs/api/charmhelpers.contrib.rst 2014-08-05 21:28:01 +0000 |
370 | +++ docs/api/charmhelpers.contrib.rst 2014-08-18 16:28:55 +0000 |
371 | @@ -6,6 +6,7 @@ |
372 | charmhelpers.contrib.ansible |
373 | charmhelpers.contrib.charmhelpers |
374 | charmhelpers.contrib.charmsupport |
375 | + charmhelpers.contrib.docker |
376 | charmhelpers.contrib.hahelpers |
377 | charmhelpers.contrib.network |
378 | charmhelpers.contrib.openstack |
379 | |
380 | === added file 'docs/examples/docker.rst' |
381 | --- docs/examples/docker.rst 1970-01-01 00:00:00 +0000 |
382 | +++ docs/examples/docker.rst 2014-08-18 16:28:55 +0000 |
383 | @@ -0,0 +1,119 @@ |
384 | +Managing Docker with the Services Framework |
385 | +=========================================== |
386 | + |
387 | +Docker containers can be easily managed with |
388 | +the :mod:`services framework <charmhelpers.core.services.base>` and the |
389 | +helpers provided in :mod:`charmhelpers.contrib.docker`. Using these |
390 | +helpers and classes will manage pulling the container, blocking on |
391 | +neccessary data (such as from required config values, or other services), |
392 | +starting the container with the appropriate arguments, and restarting the |
393 | +container whenever any relevant configuration data has changed. And the |
394 | +resulting charm will be very declarative and thus should be easy to follow. |
395 | + |
396 | + |
397 | +Installing Docker and a Container |
398 | +--------------------------------- |
399 | + |
400 | +Your install hook will probably just consist of the following lines:: |
401 | + |
402 | + # hooks/install |
403 | + |
404 | + from charmhelpers.contrib.docker import install_docker, docker_pull |
405 | + |
406 | + install_docker() |
407 | + docker_pull('my_container') |
408 | + |
409 | +The :func:`~charmhelpers.contrib.docker.docker_pull` helper also has |
410 | +support for pulling from a custom registry, with authentication. |
411 | + |
412 | + |
413 | +Defining Your Docker Container Configuration |
414 | +-------------------------------------------- |
415 | + |
416 | +The common configuration for a Docker container includes port mappings, |
417 | +volumes and / or volume mappings, and arguments for the container itself |
418 | +which may be built from various sources such as config options, unit data, |
419 | +or relations to other services. By using the various classes and |
420 | +the :class:`docker_start() <charmhelpers.contrib.docker.DockerCallback>` |
421 | +and :class:`docker_stop() <charmhelpers.contrib.docker.DockerCallback>` |
422 | +helpers from :mod:`charmhelpers.contrib.docker` with the |
423 | +:class:`~charmhelpers.core.services.base.ServiceManager`, you can declare |
424 | +this configuration in a single location, and then just call |
425 | +:meth:`manager.manage() <charmhelpers.core.services.base.ServiceManager.manage>` |
426 | +to handle your hooks. |
427 | + |
428 | +An example, from the `RethinkDB Docker Charm <https://github.com/bcsaller/juju-docker/>`_, |
429 | +which uses most of the options available, is below:: |
430 | + |
431 | + # hooks/hooks.py |
432 | + # (symlinked to from start, stop, config-changed, intracluster-relation-joined, etc) |
433 | + |
434 | + import socket |
435 | + from charmhelpers.core import hookenv |
436 | + from charmhelpers.core import services |
437 | + from charmhelpers.contrib import docker |
438 | + |
439 | + class ClusterPeers(docker.DockerRelation): |
440 | + name = 'intracluster' |
441 | + interface = 'rethinkdb-cluster' |
442 | + port = 29015 |
443 | + |
444 | + def map(self, relation_settings): |
445 | + return [ |
446 | + '--join', '{}:{}'.format( |
447 | + relation_settings['private-address'], |
448 | + self.port |
449 | + ) |
450 | + ] |
451 | + |
452 | + |
453 | + class WebsiteRelation(services.helpers.RelationContext): |
454 | + name = 'website' |
455 | + interface = 'http' |
456 | + |
457 | + def provide_data(self): |
458 | + return {'hostname': hookenv.unit_private_ip(), 'port': 80} |
459 | + |
460 | + |
461 | + config = hookenv.config() |
462 | + manager = services.ServiceManager([{ |
463 | + 'service': 'dockerfile/rethinkdb', |
464 | + 'ports': [80, 28015, 29015], |
465 | + 'provided_data': [WebsiteRelation()], |
466 | + 'required_data': [ |
467 | + config if config.get('storage-path') else {}, |
468 | + DockerPortMappings({ |
469 | + 80: 8080, |
470 | + 28015: 28015, |
471 | + 29015: 29015, |
472 | + }), |
473 | + DockerVolumes(mapped_volumes={config.get('storage-path'): '/rethinkdb'}), |
474 | + DockerContainerArgs( |
475 | + bind='all', |
476 | + canonical_address=hookenv.unit_get('public-address'), |
477 | + canonical_address=hookenv.unit_get('private-address'), |
478 | + machine_name=socket.gethostname().replace('-', '_'), |
479 | + ), |
480 | + ClusterPeers(), |
481 | + ], |
482 | + 'start': docker_start, |
483 | + 'stop': docker_stop, |
484 | + }]) |
485 | + manager.manage() |
486 | + |
487 | +In this example, once the ``storage-path`` config option has been set, |
488 | +the container will be started with something like the following command-line:: |
489 | + |
490 | + docker run -d --cidfile CONTAINER_ID -v <storage-path>:/rethinkdb -p 80:8080 \ |
491 | + -p 28015:28015 -p 29015:29015 dockerfile/rethinkdb --bind all \ |
492 | + --canoncal-address <public-address> --canonical-address <private-address> \ |
493 | + --machine-name <machine-name> --join <peer-addr>:<peer-port> |
494 | + |
495 | +Additionally, whenever a new peer is added via ``juju add-relation``, the container |
496 | +will be automatically restarted with the additional ``--join`` argument. |
497 | + |
498 | + |
499 | +Additional Steps |
500 | +---------------- |
501 | + |
502 | +Actually, that's it. =) |
503 | |
504 | === added directory 'tests/contrib/docker' |
505 | === added file 'tests/contrib/docker/__init__.py' |
506 | === added file 'tests/contrib/docker/test_docker.py' |
507 | --- tests/contrib/docker/test_docker.py 1970-01-01 00:00:00 +0000 |
508 | +++ tests/contrib/docker/test_docker.py 2014-08-18 16:28:55 +0000 |
509 | @@ -0,0 +1,118 @@ |
510 | +import mock |
511 | +import unittest |
512 | + |
513 | +from charmhelpers.contrib import docker |
514 | + |
515 | + |
516 | +class TestDockerHelpers(unittest.TestCase): |
517 | + def test_port_mappings(self): |
518 | + mappings = docker.DockerPortMappings({ |
519 | + '80': '8080', |
520 | + '5000': '5000', |
521 | + }) |
522 | + self.assertIn(mappings.build_args(), [ |
523 | + ['-p', '80:8080', '-p', '5000:5000'], |
524 | + ['-p', '5000:5000', '-p', '80:8080'], |
525 | + ]) |
526 | + |
527 | + def test_container_args(self): |
528 | + args = docker.DockerContainerArgs( |
529 | + 'command', |
530 | + 'arg', |
531 | + named_arg='value', |
532 | + ) |
533 | + self.assertEqual(args.build_args(), ['command', 'arg', '--named-arg', 'value']) |
534 | + |
535 | + @mock.patch('charmhelpers.core.host.mkdir') |
536 | + @mock.patch('charmhelpers.core.hookenv.charm_dir') |
537 | + def test_volumes(self, charm_dir, mkdir): |
538 | + charm_dir.return_value = 'charm_dir' |
539 | + vols = docker.DockerVolumes( |
540 | + volumes=['vol1'], |
541 | + named_volumes={'vol2_name': 'vol2'}, |
542 | + mapped_volumes={'host1': 'vol3'}, |
543 | + ) |
544 | + self.assertEqual(vols.build_args(), [ |
545 | + '-v', 'vol1', |
546 | + '-v', 'vol2', '--name', 'vol2_name', |
547 | + '-v', 'charm_dir/host1:vol3']) |
548 | + mkdir.assert_called_once_with('charm_dir/host1') |
549 | + mkdir.reset_mock() |
550 | + vols = docker.DockerVolumes(mapped_volumes={'/host1': 'vol4'}) |
551 | + self.assertEqual(vols.build_args(), ['-v', '/host1:vol4']) |
552 | + mkdir.assert_called_once_with('/host1') |
553 | + |
554 | + @mock.patch('charmhelpers.core.host.mkdir') |
555 | + @mock.patch('os.path.exists') |
556 | + @mock.patch('os.remove') |
557 | + @mock.patch('subprocess.check_call') |
558 | + @mock.patch('charmhelpers.core.hookenv.charm_dir') |
559 | + @mock.patch('charmhelpers.core.host.read_file') |
560 | + def test_docker_callback(self, read_file, charm_dir, check_call, exists, remove, mkdir): |
561 | + charm_dir.return_value = 'charm_dir' |
562 | + read_file.return_value = 'cid' |
563 | + exists.return_value = True |
564 | + manager = mock.Mock() |
565 | + manager.get_service.return_value = { |
566 | + 'service': 'dockeruser/container', |
567 | + 'required_data': [ |
568 | + docker.DockerPortMappings({'80': '8080'}), |
569 | + docker.DockerVolumes(mapped_volumes={'data': '/path'}), |
570 | + docker.DockerContainerArgs(bind='all'), |
571 | + ], |
572 | + } |
573 | + callback = docker.DockerCallback() |
574 | + callback(manager, 'dockeruser/container', 'start') |
575 | + exists.assert_called_once_with('charm_dir/CONTAINER_ID') |
576 | + read_file.assert_called_once_with('charm_dir/CONTAINER_ID') |
577 | + check_call.assert_has_call(['docker', 'stop', 'cid']) |
578 | + remove.assert_called_once_with('charm_dir/CONTAINER_ID') |
579 | + check_call.assert_has_call([ |
580 | + 'docker', 'run', '-d', '-cidfile', 'charm_dir/CONTAINER_ID', |
581 | + '-v', 'charm_dir/data', '/path', |
582 | + '-p', '80:8080', |
583 | + 'dockeruser/container', |
584 | + '--bind', 'all', |
585 | + ]) |
586 | + |
587 | + @mock.patch('charmhelpers.core.services.helpers.RelationContext.get_data') |
588 | + def test_docker_relation(self, get_data): |
589 | + relation = docker.DockerRelation() |
590 | + relation.name = 'name' |
591 | + relation.required_keys = ['key1'] |
592 | + relation.update({ |
593 | + 'name': [ |
594 | + {'key1': 'val1'}, |
595 | + {'key2': 'val2'}, |
596 | + ], |
597 | + }) |
598 | + self.assertEqual(set(relation.build_args()), set([ |
599 | + '--key1', 'val1', '--key2', 'val2', |
600 | + ])) |
601 | + |
602 | + @mock.patch('charmhelpers.contrib.docker.open', create=True) |
603 | + @mock.patch('os.symlink') |
604 | + @mock.patch('os.unlink') |
605 | + @mock.patch('os.path.exists') |
606 | + @mock.patch('charmhelpers.fetch.apt_install') |
607 | + def test_install_docker(self, apt_install, exists, unlink, symlink, mopen): |
608 | + exists.return_value = True |
609 | + fp = mopen.return_value.__enter__.return_value |
610 | + docker.install_docker() |
611 | + assert apt_install.called |
612 | + assert exists.called |
613 | + assert unlink.called |
614 | + assert symlink.called |
615 | + assert fp.write.called |
616 | + |
617 | + @mock.patch('charmhelpers.contrib.docker.fetch') |
618 | + def test_install_docker_unstable(self, fetch): |
619 | + docker.install_docker_unstable() |
620 | + assert fetch.add_source.called |
621 | + assert fetch.apt_update.called |
622 | + assert fetch.apt_install.called |
623 | + |
624 | + @mock.patch('subprocess.check_call') |
625 | + def test_docker_pull(self, check_call): |
626 | + docker.docker_pull('foo') |
627 | + check_call.assert_called_once_with(['docker', 'pull', 'foo']) |
i'd also appreciate an option for docker install latest ala /docs.docker. com/installatio n/binaries/
https:/
On Mon, Aug 18, 2014 at 12:29 PM, Cory Johns <email address hidden>
wrote:
> Cory Johns has proposed merging lp:~johnsca/charm-helpers/docker into /code.launchpad .net/~johnsca/ charm-helpers/ docker/ +merge/ 231226 /code.launchpad .net/~johnsca/ charm-helpers/ docker/ +merge/ 231226 contrib/ docker' contrib/ docker/ __init_ _.py' contrib/ docker/ __init_ _.py 1970-01-01 00:00:00 +0000 contrib/ docker/ __init_ _.py 2014-08-18 16:28:55 +0000 core.services. base import ManagerCallback core.services. helpers import RelationContext install( ['docker. io']) exists( '/usr/local/ bin/docker' ): '/usr/local/ bin/docker' ) '/usr/bin/ docker. io', '/usr/local/ bin/docker' ) etc/bash_ completion. d/docker. io', 'a') as fp: '\ncomplete -F _docker docker') docker_ unstable( ): source( 'deb https:/ /get.docker. io/ubuntu docker main', 45C8950F966E92D 8576A8BA88D21E9 ') update( fatal=True) install( ['lxc-docker' ]) pull(container_ name, registry=None, username=None, check_call( [ check_call( ['docker' , 'pull', container_name]) ManagerCallback ): `DockerPortMapp ings`, :class: `DockerVolumes` ,
> lp:charm-helpers.
>
> Requested reviews:
> Charm Helper Maintainers (charm-helpers)
>
> For more details, see:
> https:/
>
> Added helpers for managing Docker containers in a charm, using the
> services framework. Includes documentation.
> --
> https:/
> Your team Charm Helper Maintainers is requested to review the proposed
> merge of lp:~johnsca/charm-helpers/docker into lp:charm-helpers.
>
> === added directory 'charmhelpers/
> === added file 'charmhelpers/
> --- charmhelpers/
> +++ charmhelpers/
> @@ -0,0 +1,287 @@
> +
> +import os
> +import subprocess
> +
> +from charmhelpers import fetch
> +from charmhelpers.core import host
> +from charmhelpers.core import hookenv
> +from charmhelpers.
> +from charmhelpers.
> +
> +
> +def install_docker():
> + """
> + Install the Docker tools.
> + """
> + fetch.apt_
> + if os.path.
> + os.unlink(
> + os.symlink(
> + with open('/
> + fp.write(
> +
> +
> +def install_
> + """
> + Install the Docker tools from the unstable (but potentially more
> + up-to-date) repository.
> + """
> + fetch.add_
> + key='36A1D78692
> + fetch.apt_
> + fetch.apt_
> +
> +
> +def docker_
> password=None):
> + """
> + Fetch a container from the Docker registry.
> +
> + If :param:`registry` is given, the container will be pulled from that
> + registry instead of the default. If the registry requires
> authentication,
> + provide the :param:`username` and :param:`password` arguments.
> + """
> + if registry is not None:
> + container_name = '/'.join([registry, container_name])
> + if any(username, password):
> + subprocess.
> + 'docker', 'login', '-u', username, '-p', password])
> + subprocess.
> +
> +
> +class DockerCallback(
> + """
> + ServiceManager callback to manage starting up a Docker container.
> +
> + Can be referenced as ``docker_start`` or ``docker_stop``, and performs
> + the appropriate action. Requires one or more of
> + :class:
> + :c...