1#!/usr/local/bin/python3.8
2#
3# Copyright 2016 Red Hat | Ansible
4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5
6from __future__ import absolute_import, division, print_function
7__metaclass__ = type
8
9
10DOCUMENTATION = '''
11
12module: docker_compose
13
14short_description: Manage multi-container Docker applications with Docker Compose.
15
16
17author: "Chris Houseknecht (@chouseknecht)"
18
19description:
20  - Uses Docker Compose to start, shutdown and scale services. B(This module requires docker-compose < 2.0.0.)
21  - Configuration can be read from a C(docker-compose.yml) or C(docker-compose.yaml) file or inline using the I(definition) option.
22  - See the examples for more details.
23  - Supports check mode.
24  - This module was called C(docker_service) before Ansible 2.8. The usage did not change.
25
26options:
27  project_src:
28    description:
29      - Path to a directory containing a C(docker-compose.yml) or C(docker-compose.yaml) file.
30      - Mutually exclusive with I(definition).
31      - Required when no I(definition) is provided.
32    type: path
33  project_name:
34    description:
35      - Provide a project name. If not provided, the project name is taken from the basename of I(project_src).
36      - Required when I(definition) is provided.
37    type: str
38  env_file:
39    description:
40      - By default environment files are loaded from a C(.env) file located directly under the I(project_src) directory.
41      - I(env_file) can be used to specify the path of a custom environment file instead.
42      - The path is relative to the I(project_src) directory.
43      - Requires C(docker-compose) version 1.25.0 or greater.
44      - "Note: C(docker-compose) versions C(<=1.28) load the env file from the current working directory of the
45          C(docker-compose) command rather than I(project_src)."
46    type: path
47    version_added: 1.9.0
48  files:
49    description:
50      - List of Compose file names relative to I(project_src). Overrides C(docker-compose.yml) or C(docker-compose.yaml).
51      - Files are loaded and merged in the order given.
52    type: list
53    elements: path
54  profiles:
55    description:
56      - List of profiles to enable when starting services.
57      - Equivalent to C(docker-compose --profile).
58      - Requires C(docker-compose) version 1.28.0 or greater.
59    type: list
60    elements: str
61    version_added: 1.8.0
62  state:
63    description:
64      - Desired state of the project.
65      - Specifying C(present) is the same as running C(docker-compose up) resp. C(docker-compose stop) (with I(stopped)) resp. C(docker-compose restart)
66        (with I(restarted)).
67      - Specifying C(absent) is the same as running C(docker-compose down).
68    type: str
69    default: present
70    choices:
71      - absent
72      - present
73  services:
74    description:
75      - When I(state) is C(present) run C(docker-compose up) resp. C(docker-compose stop) (with I(stopped)) resp. C(docker-compose restart) (with I(restarted))
76        on a subset of services.
77      - If empty, which is the default, the operation will be performed on all services defined in the Compose file (or inline I(definition)).
78    type: list
79    elements: str
80  scale:
81    description:
82      - When I(state) is C(present) scale services. Provide a dictionary of key/value pairs where the key
83        is the name of the service and the value is an integer count for the number of containers.
84    type: dict
85  dependencies:
86    description:
87      - When I(state) is C(present) specify whether or not to include linked services.
88    type: bool
89    default: yes
90  definition:
91    description:
92      - Compose file describing one or more services, networks and volumes.
93      - Mutually exclusive with I(project_src) and I(files).
94    type: dict
95  hostname_check:
96    description:
97      - Whether or not to check the Docker daemon's hostname against the name provided in the client certificate.
98    type: bool
99    default: no
100  recreate:
101    description:
102      - By default containers will be recreated when their configuration differs from the service definition.
103      - Setting to C(never) ignores configuration differences and leaves existing containers unchanged.
104      - Setting to C(always) forces recreation of all existing containers.
105    type: str
106    default: smart
107    choices:
108      - always
109      - never
110      - smart
111  build:
112    description:
113      - Use with I(state) C(present) to always build images prior to starting the application.
114      - Same as running C(docker-compose build) with the pull option.
115      - Images will only be rebuilt if Docker detects a change in the Dockerfile or build directory contents.
116      - Use the I(nocache) option to ignore the image cache when performing the build.
117      - If an existing image is replaced, services using the image will be recreated unless I(recreate) is C(never).
118    type: bool
119    default: no
120  pull:
121    description:
122      - Use with I(state) C(present) to always pull images prior to starting the application.
123      - Same as running C(docker-compose pull).
124      - When a new image is pulled, services using the image will be recreated unless I(recreate) is C(never).
125    type: bool
126    default: no
127  nocache:
128    description:
129      - Use with the I(build) option to ignore the cache during the image build process.
130    type: bool
131    default: no
132  remove_images:
133    description:
134      - Use with I(state) C(absent) to remove all images or only local images.
135    type: str
136    choices:
137        - 'all'
138        - 'local'
139  remove_volumes:
140    description:
141      - Use with I(state) C(absent) to remove data volumes.
142    type: bool
143    default: no
144  stopped:
145    description:
146      - Use with I(state) C(present) to stop all containers defined in the Compose file.
147      - If I(services) is defined, only the containers listed there will be stopped.
148      - Requires C(docker-compose) version 1.17.0 or greater for full support. For older versions, the services will
149        first be started and then stopped when the service is supposed to be created as stopped.
150    type: bool
151    default: no
152  restarted:
153    description:
154      - Use with I(state) C(present) to restart all containers defined in the Compose file.
155      - If I(services) is defined, only the containers listed there will be restarted.
156    type: bool
157    default: no
158  remove_orphans:
159    description:
160      - Remove containers for services not defined in the Compose file.
161    type: bool
162    default: no
163  timeout:
164    description:
165      - Timeout in seconds for container shutdown when attached or when containers are already running.
166    type: int
167    default: 10
168  use_ssh_client:
169    description:
170      - Currently ignored for this module, but might suddenly be supported later on.
171
172extends_documentation_fragment:
173  - community.docker.docker
174  - community.docker.docker.docker_py_1_documentation
175
176
177requirements:
178  - "L(Docker SDK for Python,https://docker-py.readthedocs.io/en/stable/) >= 1.8.0 (use L(docker-py,https://pypi.org/project/docker-py/) for Python 2.6)"
179  - "docker-compose >= 1.7.0, < 2.0.0"
180  - "Docker API >= 1.20"
181  - "PyYAML >= 3.11"
182'''
183
184EXAMPLES = '''
185# Examples use the django example at https://docs.docker.com/compose/django. Follow it to create the
186# flask directory
187
188- name: Run using a project directory
189  hosts: localhost
190  gather_facts: no
191  tasks:
192    - name: Tear down existing services
193      community.docker.docker_compose:
194        project_src: flask
195        state: absent
196
197    - name: Create and start services
198      community.docker.docker_compose:
199        project_src: flask
200      register: output
201
202    - ansible.builtin.debug:
203        var: output
204
205    - name: Run `docker-compose up` again
206      community.docker.docker_compose:
207        project_src: flask
208        build: no
209      register: output
210
211    - ansible.builtin.debug:
212        var: output
213
214    - ansible.builtin.assert:
215        that: "not output.changed "
216
217    - name: Stop all services
218      community.docker.docker_compose:
219        project_src: flask
220        build: no
221        stopped: yes
222      register: output
223
224    - ansible.builtin.debug:
225        var: output
226
227    - ansible.builtin.assert:
228        that:
229          - "not web.flask_web_1.state.running"
230          - "not db.flask_db_1.state.running"
231
232    - name: Restart services
233      community.docker.docker_compose:
234        project_src: flask
235        build: no
236        restarted: yes
237      register: output
238
239    - ansible.builtin.debug:
240        var: output
241
242    - ansible.builtin.assert:
243        that:
244          - "web.flask_web_1.state.running"
245          - "db.flask_db_1.state.running"
246
247- name: Scale the web service to 2
248  hosts: localhost
249  gather_facts: no
250  tasks:
251    - community.docker.docker_compose:
252        project_src: flask
253        scale:
254          web: 2
255      register: output
256
257    - ansible.builtin.debug:
258        var: output
259
260- name: Run with inline v2 compose
261  hosts: localhost
262  gather_facts: no
263  tasks:
264    - community.docker.docker_compose:
265        project_src: flask
266        state: absent
267
268    - community.docker.docker_compose:
269        project_name: flask
270        definition:
271          version: '2'
272          services:
273            db:
274              image: postgres
275            web:
276              build: "{{ playbook_dir }}/flask"
277              command: "python manage.py runserver 0.0.0.0:8000"
278              volumes:
279                - "{{ playbook_dir }}/flask:/code"
280              ports:
281                - "8000:8000"
282              depends_on:
283                - db
284      register: output
285
286    - ansible.builtin.debug:
287        var: output
288
289    - ansible.builtin.assert:
290        that:
291          - "web.flask_web_1.state.running"
292          - "db.flask_db_1.state.running"
293
294- name: Run with inline v1 compose
295  hosts: localhost
296  gather_facts: no
297  tasks:
298    - community.docker.docker_compose:
299        project_src: flask
300        state: absent
301
302    - community.docker.docker_compose:
303        project_name: flask
304        definition:
305            db:
306              image: postgres
307            web:
308              build: "{{ playbook_dir }}/flask"
309              command: "python manage.py runserver 0.0.0.0:8000"
310              volumes:
311                - "{{ playbook_dir }}/flask:/code"
312              ports:
313                - "8000:8000"
314              links:
315                - db
316      register: output
317
318    - ansible.builtin.debug:
319        var: output
320
321    - ansible.builtin.assert:
322        that:
323          - "web.flask_web_1.state.running"
324          - "db.flask_db_1.state.running"
325'''
326
327RETURN = '''
328services:
329  description:
330  - A dictionary mapping the service's name to a dictionary of containers.
331  returned: success
332  type: complex
333  contains:
334      container_name:
335          description: Name of the container. Format is C(project_service_#).
336          returned: success
337          type: complex
338          contains:
339              cmd:
340                  description: One or more commands to be executed in the container.
341                  returned: success
342                  type: list
343                  elements: str
344                  example: ["postgres"]
345              image:
346                  description: Name of the image from which the container was built.
347                  returned: success
348                  type: str
349                  example: postgres
350              labels:
351                  description: Meta data assigned to the container.
352                  returned: success
353                  type: dict
354                  example: {...}
355              networks:
356                  description: Contains a dictionary for each network to which the container is a member.
357                  returned: success
358                  type: list
359                  elements: dict
360                  contains:
361                      IPAddress:
362                          description: The IP address assigned to the container.
363                          returned: success
364                          type: str
365                          example: 172.17.0.2
366                      IPPrefixLen:
367                          description: Number of bits used by the subnet.
368                          returned: success
369                          type: int
370                          example: 16
371                      aliases:
372                          description: Aliases assigned to the container by the network.
373                          returned: success
374                          type: list
375                          elements: str
376                          example: ['db']
377                      globalIPv6:
378                          description: IPv6 address assigned to the container.
379                          returned: success
380                          type: str
381                          example: ''
382                      globalIPv6PrefixLen:
383                          description: IPv6 subnet length.
384                          returned: success
385                          type: int
386                          example: 0
387                      links:
388                          description: List of container names to which this container is linked.
389                          returned: success
390                          type: list
391                          elements: str
392                          example: null
393                      macAddress:
394                          description: Mac Address assigned to the virtual NIC.
395                          returned: success
396                          type: str
397                          example: "02:42:ac:11:00:02"
398              state:
399                  description: Information regarding the current disposition of the container.
400                  returned: success
401                  type: dict
402                  contains:
403                      running:
404                          description: Whether or not the container is up with a running process.
405                          returned: success
406                          type: bool
407                          example: true
408                      status:
409                          description: Description of the running state.
410                          returned: success
411                          type: str
412                          example: running
413
414actions:
415  description: Provides the actions to be taken on each service as determined by compose.
416  returned: when in check mode or I(debug) is C(yes)
417  type: complex
418  contains:
419      service_name:
420          description: Name of the service.
421          returned: always
422          type: complex
423          contains:
424              pulled_image:
425                  description: Provides image details when a new image is pulled for the service.
426                  returned: on image pull
427                  type: complex
428                  contains:
429                      name:
430                          description: name of the image
431                          returned: always
432                          type: str
433                      id:
434                          description: image hash
435                          returned: always
436                          type: str
437              built_image:
438                  description: Provides image details when a new image is built for the service.
439                  returned: on image build
440                  type: complex
441                  contains:
442                      name:
443                          description: name of the image
444                          returned: always
445                          type: str
446                      id:
447                          description: image hash
448                          returned: always
449                          type: str
450
451              action:
452                  description: A descriptive name of the action to be performed on the service's containers.
453                  returned: always
454                  type: list
455                  elements: str
456                  contains:
457                      id:
458                          description: the container's long ID
459                          returned: always
460                          type: str
461                      name:
462                          description: the container's name
463                          returned: always
464                          type: str
465                      short_id:
466                          description: the container's short ID
467                          returned: always
468                          type: str
469'''
470
471import os
472import re
473import sys
474import tempfile
475import traceback
476from contextlib import contextmanager
477from distutils.version import LooseVersion
478
479try:
480    import yaml
481    HAS_YAML = True
482    HAS_YAML_EXC = None
483except ImportError as dummy:
484    HAS_YAML = False
485    HAS_YAML_EXC = traceback.format_exc()
486
487try:
488    from docker.errors import DockerException
489except ImportError:
490    # missing Docker SDK for Python handled in ansible.module_utils.docker.common
491    pass
492
493try:
494    from compose import __version__ as compose_version
495    from compose.cli.command import project_from_options
496    from compose.service import NoSuchImageError
497    from compose.cli.main import convergence_strategy_from_opts, build_action_from_opts, image_type_from_opt
498    from compose.const import DEFAULT_TIMEOUT, LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF
499    HAS_COMPOSE = True
500    HAS_COMPOSE_EXC = None
501    MINIMUM_COMPOSE_VERSION = '1.7.0'
502except ImportError as dummy:
503    HAS_COMPOSE = False
504    HAS_COMPOSE_EXC = traceback.format_exc()
505    DEFAULT_TIMEOUT = 10
506
507from ansible.module_utils.common.text.converters import to_native
508
509from ansible_collections.community.docker.plugins.module_utils.common import (
510    AnsibleDockerClient,
511    DockerBaseClass,
512    RequestException,
513)
514
515
516AUTH_PARAM_MAPPING = {
517    u'docker_host': u'--host',
518    u'tls': u'--tls',
519    u'cacert_path': u'--tlscacert',
520    u'cert_path': u'--tlscert',
521    u'key_path': u'--tlskey',
522    u'tls_verify': u'--tlsverify'
523}
524
525
526@contextmanager
527def stdout_redirector(path_name):
528    old_stdout = sys.stdout
529    fd = open(path_name, 'w')
530    sys.stdout = fd
531    try:
532        yield
533    finally:
534        sys.stdout = old_stdout
535
536
537@contextmanager
538def stderr_redirector(path_name):
539    old_fh = sys.stderr
540    fd = open(path_name, 'w')
541    sys.stderr = fd
542    try:
543        yield
544    finally:
545        sys.stderr = old_fh
546
547
548def make_redirection_tempfiles():
549    dummy, out_redir_name = tempfile.mkstemp(prefix="ansible")
550    dummy, err_redir_name = tempfile.mkstemp(prefix="ansible")
551    return (out_redir_name, err_redir_name)
552
553
554def cleanup_redirection_tempfiles(out_name, err_name):
555    for i in [out_name, err_name]:
556        os.remove(i)
557
558
559def get_redirected_output(path_name):
560    output = []
561    with open(path_name, 'r') as fd:
562        for line in fd:
563            # strip terminal format/color chars
564            new_line = re.sub(r'\x1b\[.+m', '', line)
565            output.append(new_line)
566    os.remove(path_name)
567    return output
568
569
570def attempt_extract_errors(exc_str, stdout, stderr):
571    errors = [l.strip() for l in stderr if l.strip().startswith('ERROR:')]
572    errors.extend([l.strip() for l in stdout if l.strip().startswith('ERROR:')])
573
574    warnings = [l.strip() for l in stderr if l.strip().startswith('WARNING:')]
575    warnings.extend([l.strip() for l in stdout if l.strip().startswith('WARNING:')])
576
577    # assume either the exception body (if present) or the last warning was the 'most'
578    # fatal.
579
580    if exc_str.strip():
581        msg = exc_str.strip()
582    elif errors:
583        msg = errors[-1].encode('utf-8')
584    else:
585        msg = 'unknown cause'
586
587    return {
588        'warnings': [w.encode('utf-8') for w in warnings],
589        'errors': [e.encode('utf-8') for e in errors],
590        'msg': msg,
591        'module_stderr': ''.join(stderr),
592        'module_stdout': ''.join(stdout)
593    }
594
595
596def get_failure_info(exc, out_name, err_name=None, msg_format='%s'):
597    if err_name is None:
598        stderr = []
599    else:
600        stderr = get_redirected_output(err_name)
601    stdout = get_redirected_output(out_name)
602
603    reason = attempt_extract_errors(str(exc), stdout, stderr)
604    reason['msg'] = msg_format % reason['msg']
605    return reason
606
607
608class ContainerManager(DockerBaseClass):
609
610    def __init__(self, client):
611
612        super(ContainerManager, self).__init__()
613
614        self.client = client
615        self.project_src = None
616        self.files = None
617        self.project_name = None
618        self.state = None
619        self.definition = None
620        self.hostname_check = None
621        self.timeout = None
622        self.remove_images = None
623        self.remove_orphans = None
624        self.remove_volumes = None
625        self.stopped = None
626        self.restarted = None
627        self.recreate = None
628        self.build = None
629        self.dependencies = None
630        self.services = None
631        self.scale = None
632        self.debug = None
633        self.pull = None
634        self.nocache = None
635
636        for key, value in client.module.params.items():
637            setattr(self, key, value)
638
639        self.check_mode = client.check_mode
640
641        if not self.debug:
642            self.debug = client.module._debug
643
644        self.options = dict()
645        self.options.update(self._get_auth_options())
646        self.options[u'--skip-hostname-check'] = (not self.hostname_check)
647
648        if self.project_name:
649            self.options[u'--project-name'] = self.project_name
650
651        if self.env_file:
652            self.options[u'--env-file'] = self.env_file
653
654        if self.files:
655            self.options[u'--file'] = self.files
656
657        if self.profiles:
658            self.options[u'--profile'] = self.profiles
659
660        if not HAS_COMPOSE:
661            self.client.fail("Unable to load docker-compose. Try `pip install docker-compose`. Error: %s" %
662                             to_native(HAS_COMPOSE_EXC))
663
664        if LooseVersion(compose_version) < LooseVersion(MINIMUM_COMPOSE_VERSION):
665            self.client.fail("Found docker-compose version %s. Minimum required version is %s. "
666                             "Upgrade docker-compose to a min version of %s." %
667                             (compose_version, MINIMUM_COMPOSE_VERSION, MINIMUM_COMPOSE_VERSION))
668
669        if self.restarted and self.stopped:
670            self.client.fail("Cannot use restarted and stopped at the same time.")
671
672        self.log("options: ")
673        self.log(self.options, pretty_print=True)
674
675        if self.definition:
676            if not HAS_YAML:
677                self.client.fail("Unable to load yaml. Try `pip install PyYAML`. Error: %s" % to_native(HAS_YAML_EXC))
678
679            if not self.project_name:
680                self.client.fail("Parameter error - project_name required when providing definition.")
681
682            self.project_src = tempfile.mkdtemp(prefix="ansible")
683            compose_file = os.path.join(self.project_src, "docker-compose.yml")
684            try:
685                self.log('writing: ')
686                self.log(yaml.dump(self.definition, default_flow_style=False))
687                with open(compose_file, 'w') as f:
688                    f.write(yaml.dump(self.definition, default_flow_style=False))
689            except Exception as exc:
690                self.client.fail("Error writing to %s - %s" % (compose_file, to_native(exc)))
691        else:
692            if not self.project_src:
693                self.client.fail("Parameter error - project_src required.")
694
695        try:
696            self.log("project_src: %s" % self.project_src)
697            self.project = project_from_options(self.project_src, self.options)
698        except Exception as exc:
699            self.client.fail("Configuration error - %s" % to_native(exc))
700
701    def exec_module(self):
702        result = dict()
703
704        if self.state == 'present':
705            result = self.cmd_up()
706        elif self.state == 'absent':
707            result = self.cmd_down()
708
709        if self.definition:
710            compose_file = os.path.join(self.project_src, "docker-compose.yml")
711            self.log("removing %s" % compose_file)
712            os.remove(compose_file)
713            self.log("removing %s" % self.project_src)
714            os.rmdir(self.project_src)
715
716        if not self.check_mode and not self.debug and result.get('actions'):
717            result.pop('actions')
718
719        return result
720
721    def _get_auth_options(self):
722        options = dict()
723        for key, value in self.client.auth_params.items():
724            if value is not None:
725                option = AUTH_PARAM_MAPPING.get(key)
726                if option:
727                    options[option] = value
728        return options
729
730    def cmd_up(self):
731
732        start_deps = self.dependencies
733        service_names = self.services
734        detached = True
735        result = dict(changed=False, actions=[], services=dict())
736
737        up_options = {
738            u'--no-recreate': False,
739            u'--build': False,
740            u'--no-build': False,
741            u'--no-deps': False,
742            u'--force-recreate': False,
743        }
744
745        if self.recreate == 'never':
746            up_options[u'--no-recreate'] = True
747        elif self.recreate == 'always':
748            up_options[u'--force-recreate'] = True
749
750        if self.remove_orphans:
751            up_options[u'--remove-orphans'] = True
752
753        converge = convergence_strategy_from_opts(up_options)
754        self.log("convergence strategy: %s" % converge)
755
756        if self.pull:
757            pull_output = self.cmd_pull()
758            result['changed'] |= pull_output['changed']
759            result['actions'] += pull_output['actions']
760
761        if self.build:
762            build_output = self.cmd_build()
763            result['changed'] |= build_output['changed']
764            result['actions'] += build_output['actions']
765
766        if self.remove_orphans:
767            containers = self.client.containers(
768                filters={
769                    'label': [
770                        '{0}={1}'.format(LABEL_PROJECT, self.project.name),
771                        '{0}={1}'.format(LABEL_ONE_OFF, "False")
772                    ],
773                }
774            )
775
776            orphans = []
777            for container in containers:
778                service_name = container.get('Labels', {}).get(LABEL_SERVICE)
779                if service_name not in self.project.service_names:
780                    orphans.append(service_name)
781
782            if orphans:
783                result['changed'] = True
784
785        for service in self.project.services:
786            if not service_names or service.name in service_names:
787                plan = service.convergence_plan(strategy=converge)
788                if plan.action == 'start' and self.stopped:
789                    # In case the only action is starting, and the user requested
790                    # that the service should be stopped, ignore this service.
791                    continue
792                if not self._service_profile_enabled(service):
793                    continue
794                if plan.action != 'noop':
795                    result['changed'] = True
796                    result_action = dict(service=service.name)
797                    result_action[plan.action] = []
798                    for container in plan.containers:
799                        result_action[plan.action].append(dict(
800                            id=container.id,
801                            name=container.name,
802                            short_id=container.short_id,
803                        ))
804                    result['actions'].append(result_action)
805
806        if not self.check_mode and result['changed']:
807            out_redir_name, err_redir_name = make_redirection_tempfiles()
808            try:
809                with stdout_redirector(out_redir_name):
810                    with stderr_redirector(err_redir_name):
811                        do_build = build_action_from_opts(up_options)
812                        self.log('Setting do_build to %s' % do_build)
813                        up_kwargs = {
814                            'service_names': service_names,
815                            'start_deps': start_deps,
816                            'strategy': converge,
817                            'do_build': do_build,
818                            'detached': detached,
819                            'remove_orphans': self.remove_orphans,
820                            'timeout': self.timeout,
821                        }
822
823                        if LooseVersion(compose_version) >= LooseVersion('1.17.0'):
824                            up_kwargs['start'] = not self.stopped
825                        elif self.stopped:
826                            self.client.module.warn(
827                                "The 'stopped' option requires docker-compose version >= 1.17.0. " +
828                                "This task was run with docker-compose version %s." % compose_version
829                            )
830
831                        self.project.up(**up_kwargs)
832            except Exception as exc:
833                fail_reason = get_failure_info(exc, out_redir_name, err_redir_name,
834                                               msg_format="Error starting project %s")
835                self.client.fail(**fail_reason)
836            else:
837                cleanup_redirection_tempfiles(out_redir_name, err_redir_name)
838
839        if self.stopped:
840            stop_output = self.cmd_stop(service_names)
841            result['changed'] |= stop_output['changed']
842            result['actions'] += stop_output['actions']
843
844        if self.restarted:
845            restart_output = self.cmd_restart(service_names)
846            result['changed'] |= restart_output['changed']
847            result['actions'] += restart_output['actions']
848
849        if self.scale:
850            scale_output = self.cmd_scale()
851            result['changed'] |= scale_output['changed']
852            result['actions'] += scale_output['actions']
853
854        for service in self.project.services:
855            service_facts = dict()
856            result['services'][service.name] = service_facts
857            for container in service.containers(stopped=True):
858                inspection = container.inspect()
859                # pare down the inspection data to the most useful bits
860                facts = dict(
861                    cmd=[],
862                    labels=dict(),
863                    image=None,
864                    state=dict(
865                        running=None,
866                        status=None
867                    ),
868                    networks=dict()
869                )
870                if inspection['Config'].get('Cmd', None) is not None:
871                    facts['cmd'] = inspection['Config']['Cmd']
872                if inspection['Config'].get('Labels', None) is not None:
873                    facts['labels'] = inspection['Config']['Labels']
874                if inspection['Config'].get('Image', None) is not None:
875                    facts['image'] = inspection['Config']['Image']
876                if inspection['State'].get('Running', None) is not None:
877                    facts['state']['running'] = inspection['State']['Running']
878                if inspection['State'].get('Status', None) is not None:
879                    facts['state']['status'] = inspection['State']['Status']
880
881                if inspection.get('NetworkSettings') and inspection['NetworkSettings'].get('Networks'):
882                    networks = inspection['NetworkSettings']['Networks']
883                    for key in networks:
884                        facts['networks'][key] = dict(
885                            aliases=[],
886                            globalIPv6=None,
887                            globalIPv6PrefixLen=0,
888                            IPAddress=None,
889                            IPPrefixLen=0,
890                            links=None,
891                            macAddress=None,
892                        )
893                        if networks[key].get('Aliases', None) is not None:
894                            facts['networks'][key]['aliases'] = networks[key]['Aliases']
895                        if networks[key].get('GlobalIPv6Address', None) is not None:
896                            facts['networks'][key]['globalIPv6'] = networks[key]['GlobalIPv6Address']
897                        if networks[key].get('GlobalIPv6PrefixLen', None) is not None:
898                            facts['networks'][key]['globalIPv6PrefixLen'] = networks[key]['GlobalIPv6PrefixLen']
899                        if networks[key].get('IPAddress', None) is not None:
900                            facts['networks'][key]['IPAddress'] = networks[key]['IPAddress']
901                        if networks[key].get('IPPrefixLen', None) is not None:
902                            facts['networks'][key]['IPPrefixLen'] = networks[key]['IPPrefixLen']
903                        if networks[key].get('Links', None) is not None:
904                            facts['networks'][key]['links'] = networks[key]['Links']
905                        if networks[key].get('MacAddress', None) is not None:
906                            facts['networks'][key]['macAddress'] = networks[key]['MacAddress']
907
908                service_facts[container.name] = facts
909
910        return result
911
912    def cmd_pull(self):
913        result = dict(
914            changed=False,
915            actions=[],
916        )
917
918        if not self.check_mode:
919            for service in self.project.get_services(self.services, include_deps=False):
920                if 'image' not in service.options:
921                    continue
922
923                self.log('Pulling image for service %s' % service.name)
924                # store the existing image ID
925                old_image_id = ''
926                try:
927                    image = service.image()
928                    if image and image.get('Id'):
929                        old_image_id = image['Id']
930                except NoSuchImageError:
931                    pass
932                except Exception as exc:
933                    self.client.fail("Error: service image lookup failed - %s" % to_native(exc))
934
935                out_redir_name, err_redir_name = make_redirection_tempfiles()
936                # pull the image
937                try:
938                    with stdout_redirector(out_redir_name):
939                        with stderr_redirector(err_redir_name):
940                            service.pull(ignore_pull_failures=False)
941                except Exception as exc:
942                    fail_reason = get_failure_info(exc, out_redir_name, err_redir_name,
943                                                   msg_format="Error: pull failed with %s")
944                    self.client.fail(**fail_reason)
945                else:
946                    cleanup_redirection_tempfiles(out_redir_name, err_redir_name)
947
948                # store the new image ID
949                new_image_id = ''
950                try:
951                    image = service.image()
952                    if image and image.get('Id'):
953                        new_image_id = image['Id']
954                except NoSuchImageError as exc:
955                    self.client.fail("Error: service image lookup failed after pull - %s" % to_native(exc))
956
957                if new_image_id != old_image_id:
958                    # if a new image was pulled
959                    result['changed'] = True
960                    result['actions'].append(dict(
961                        service=service.name,
962                        pulled_image=dict(
963                            name=service.image_name,
964                            id=new_image_id
965                        )
966                    ))
967        return result
968
969    def cmd_build(self):
970        result = dict(
971            changed=False,
972            actions=[]
973        )
974        if not self.check_mode:
975            for service in self.project.get_services(self.services, include_deps=False):
976                if service.can_be_built():
977                    self.log('Building image for service %s' % service.name)
978                    # store the existing image ID
979                    old_image_id = ''
980                    try:
981                        image = service.image()
982                        if image and image.get('Id'):
983                            old_image_id = image['Id']
984                    except NoSuchImageError:
985                        pass
986                    except Exception as exc:
987                        self.client.fail("Error: service image lookup failed - %s" % to_native(exc))
988
989                    out_redir_name, err_redir_name = make_redirection_tempfiles()
990                    # build the image
991                    try:
992                        with stdout_redirector(out_redir_name):
993                            with stderr_redirector(err_redir_name):
994                                new_image_id = service.build(pull=self.pull, no_cache=self.nocache)
995                    except Exception as exc:
996                        fail_reason = get_failure_info(exc, out_redir_name, err_redir_name,
997                                                       msg_format="Error: build failed with %s")
998                        self.client.fail(**fail_reason)
999                    else:
1000                        cleanup_redirection_tempfiles(out_redir_name, err_redir_name)
1001
1002                    if new_image_id not in old_image_id:
1003                        # if a new image was built
1004                        result['changed'] = True
1005                        result['actions'].append(dict(
1006                            service=service.name,
1007                            built_image=dict(
1008                                name=service.image_name,
1009                                id=new_image_id
1010                            )
1011                        ))
1012        return result
1013
1014    def _service_profile_enabled(self, service):
1015        """Returns `True` if the service has no profiles defined or has a profile which is among
1016           the profiles passed to the `docker compose up` command. Otherwise returns `False`.
1017        """
1018        if LooseVersion(compose_version) < LooseVersion('1.28.0'):
1019            return True
1020        return service.enabled_for_profiles(self.profiles or [])
1021
1022    def cmd_down(self):
1023        result = dict(
1024            changed=False,
1025            actions=[]
1026        )
1027        for service in self.project.services:
1028            containers = service.containers(stopped=True)
1029            if len(containers):
1030                result['changed'] = True
1031            result['actions'].append(dict(
1032                service=service.name,
1033                deleted=[container.name for container in containers]
1034            ))
1035        if not self.check_mode and result['changed']:
1036            image_type = image_type_from_opt('--rmi', self.remove_images)
1037            out_redir_name, err_redir_name = make_redirection_tempfiles()
1038            try:
1039                with stdout_redirector(out_redir_name):
1040                    with stderr_redirector(err_redir_name):
1041                        self.project.down(image_type, self.remove_volumes, self.remove_orphans)
1042            except Exception as exc:
1043                fail_reason = get_failure_info(exc, out_redir_name, err_redir_name,
1044                                               msg_format="Error stopping project - %s")
1045                self.client.fail(**fail_reason)
1046            else:
1047                cleanup_redirection_tempfiles(out_redir_name, err_redir_name)
1048        return result
1049
1050    def cmd_stop(self, service_names):
1051        result = dict(
1052            changed=False,
1053            actions=[]
1054        )
1055        for service in self.project.services:
1056            if not service_names or service.name in service_names:
1057                service_res = dict(
1058                    service=service.name,
1059                    stop=[]
1060                )
1061                for container in service.containers(stopped=False):
1062                    result['changed'] = True
1063                    service_res['stop'].append(dict(
1064                        id=container.id,
1065                        name=container.name,
1066                        short_id=container.short_id
1067                    ))
1068                result['actions'].append(service_res)
1069        if not self.check_mode and result['changed']:
1070            out_redir_name, err_redir_name = make_redirection_tempfiles()
1071            try:
1072                with stdout_redirector(out_redir_name):
1073                    with stderr_redirector(err_redir_name):
1074                        self.project.stop(service_names=service_names, timeout=self.timeout)
1075            except Exception as exc:
1076                fail_reason = get_failure_info(exc, out_redir_name, err_redir_name,
1077                                               msg_format="Error stopping project %s")
1078                self.client.fail(**fail_reason)
1079            else:
1080                cleanup_redirection_tempfiles(out_redir_name, err_redir_name)
1081        return result
1082
1083    def cmd_restart(self, service_names):
1084        result = dict(
1085            changed=False,
1086            actions=[]
1087        )
1088
1089        for service in self.project.services:
1090            if not service_names or service.name in service_names:
1091                service_res = dict(
1092                    service=service.name,
1093                    restart=[]
1094                )
1095                for container in service.containers(stopped=True):
1096                    result['changed'] = True
1097                    service_res['restart'].append(dict(
1098                        id=container.id,
1099                        name=container.name,
1100                        short_id=container.short_id
1101                    ))
1102                result['actions'].append(service_res)
1103
1104        if not self.check_mode and result['changed']:
1105            out_redir_name, err_redir_name = make_redirection_tempfiles()
1106            try:
1107                with stdout_redirector(out_redir_name):
1108                    with stderr_redirector(err_redir_name):
1109                        self.project.restart(service_names=service_names, timeout=self.timeout)
1110            except Exception as exc:
1111                fail_reason = get_failure_info(exc, out_redir_name, err_redir_name,
1112                                               msg_format="Error restarting project %s")
1113                self.client.fail(**fail_reason)
1114            else:
1115                cleanup_redirection_tempfiles(out_redir_name, err_redir_name)
1116        return result
1117
1118    def cmd_scale(self):
1119        result = dict(
1120            changed=False,
1121            actions=[]
1122        )
1123        for service in self.project.services:
1124            if service.name in self.scale:
1125                service_res = dict(
1126                    service=service.name,
1127                    scale=0
1128                )
1129                containers = service.containers(stopped=True)
1130                scale = self.parse_scale(service.name)
1131                if len(containers) != scale:
1132                    result['changed'] = True
1133                    service_res['scale'] = scale - len(containers)
1134                    if not self.check_mode:
1135                        out_redir_name, err_redir_name = make_redirection_tempfiles()
1136                        try:
1137                            with stdout_redirector(out_redir_name):
1138                                with stderr_redirector(err_redir_name):
1139                                    service.scale(scale)
1140                        except Exception as exc:
1141                            fail_reason = get_failure_info(exc, out_redir_name, err_redir_name,
1142                                                           msg_format="Error scaling {0} - %s".format(service.name))
1143                            self.client.fail(**fail_reason)
1144                        else:
1145                            cleanup_redirection_tempfiles(out_redir_name, err_redir_name)
1146                    result['actions'].append(service_res)
1147        return result
1148
1149    def parse_scale(self, service_name):
1150        try:
1151            return int(self.scale[service_name])
1152        except ValueError:
1153            self.client.fail("Error scaling %s - expected int, got %s",
1154                             service_name, to_native(type(self.scale[service_name])))
1155
1156
1157def main():
1158    argument_spec = dict(
1159        project_src=dict(type='path'),
1160        project_name=dict(type='str',),
1161        env_file=dict(type='path'),
1162        files=dict(type='list', elements='path'),
1163        profiles=dict(type='list', elements='str'),
1164        state=dict(type='str', default='present', choices=['absent', 'present']),
1165        definition=dict(type='dict'),
1166        hostname_check=dict(type='bool', default=False),
1167        recreate=dict(type='str', default='smart', choices=['always', 'never', 'smart']),
1168        build=dict(type='bool', default=False),
1169        remove_images=dict(type='str', choices=['all', 'local']),
1170        remove_volumes=dict(type='bool', default=False),
1171        remove_orphans=dict(type='bool', default=False),
1172        stopped=dict(type='bool', default=False),
1173        restarted=dict(type='bool', default=False),
1174        scale=dict(type='dict'),
1175        services=dict(type='list', elements='str'),
1176        dependencies=dict(type='bool', default=True),
1177        pull=dict(type='bool', default=False),
1178        nocache=dict(type='bool', default=False),
1179        debug=dict(type='bool', default=False),
1180        timeout=dict(type='int', default=DEFAULT_TIMEOUT)
1181    )
1182
1183    mutually_exclusive = [
1184        ('definition', 'project_src'),
1185        ('definition', 'files')
1186    ]
1187
1188    client = AnsibleDockerClient(
1189        argument_spec=argument_spec,
1190        mutually_exclusive=mutually_exclusive,
1191        supports_check_mode=True,
1192        min_docker_api_version='1.20',
1193    )
1194
1195    try:
1196        result = ContainerManager(client).exec_module()
1197        client.module.exit_json(**result)
1198    except DockerException as e:
1199        client.fail('An unexpected docker error occurred: {0}'.format(to_native(e)), exception=traceback.format_exc())
1200    except RequestException as e:
1201        client.fail(
1202            'An unexpected requests error occurred when docker-py tried to talk to the docker daemon: {0}'.format(to_native(e)),
1203            exception=traceback.format_exc())
1204
1205
1206if __name__ == '__main__':
1207    main()
1208