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