diff --git a/README.md b/README.md index c7ad2fc42..7e8c72786 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ If you use the Ansible package and do not update collections independently, use - community.docker.docker_volume: manage Docker volumes - community.docker.docker_volume_info: retrieve information on Docker volumes * Docker Compose: - - community.docker.docker_compose: manage Docker Compose files + - community.docker.docker_compose: manage Docker Compose files (legacy Docker Compose v1) + - community.docker.docker_compose_v2: manage Docker Compose files (Docker compose CLI plugin) * Docker Swarm: - community.docker.docker_config: manage configurations - community.docker.docker_node: manage Docker Swarm nodes diff --git a/meta/runtime.yml b/meta/runtime.yml index ba8534315..78f4e30a1 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -7,6 +7,7 @@ requires_ansible: '>=2.11.0' action_groups: docker: - docker_compose + - docker_compose_v2 - docker_config - docker_container - docker_container_copy_into diff --git a/plugins/modules/docker_compose.py b/plugins/modules/docker_compose.py index 76a9e219e..f8edbee4b 100644 --- a/plugins/modules/docker_compose.py +++ b/plugins/modules/docker_compose.py @@ -12,12 +12,13 @@ module: docker_compose -short_description: Manage multi-container Docker applications with Docker Compose. +short_description: Manage multi-container Docker applications with Docker Compose V1 author: "Chris Houseknecht (@chouseknecht)" description: - Uses Docker Compose to start, shutdown and scale services. B(This module requires docker-compose < 2.0.0.) + Use the M(community.docker.docker_compose_v2) module for using the modern Docker compose CLI plugin. - Configuration can be read from a C(docker-compose.yml) or C(docker-compose.yaml) file or inline using the O(definition) option. - See the examples for more details. - Supports check mode. @@ -188,6 +189,9 @@ - "docker-compose >= 1.7.0, < 2.0.0" - "Docker API >= 1.25" - "PyYAML >= 3.11" + +seealso: + - module: community.docker.docker_compose_v2 ''' EXAMPLES = ''' diff --git a/plugins/modules/docker_compose_v2.py b/plugins/modules/docker_compose_v2.py new file mode 100644 index 000000000..b4e94d81f --- /dev/null +++ b/plugins/modules/docker_compose_v2.py @@ -0,0 +1,743 @@ +#!/usr/bin/python +# +# Copyright (c) 2023, Felix Fontein +# Copyright (c) 2023, Léo El Amri (@lel-amri) +# Copyright 2016 Red Hat | Ansible +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' + +module: docker_compose_v2 + +short_description: Manage multi-container Docker applications with Docker Compose CLI plugin + +version_added: 3.6.0 + +description: + - Uses Docker Compose to start or shutdown services. + +extends_documentation_fragment: + - community.docker.docker.cli_documentation + - community.docker.attributes + - community.docker.attributes.actiongroup_docker + +attributes: + check_mode: + support: full + diff_mode: + support: none + +options: + project_src: + description: + - Path to a directory containing a C(docker-compose.yml) or C(docker-compose.yaml) file. + type: path + required: true + project_name: + description: + - Provide a project name. If not provided, the project name is taken from the basename of O(project_src). + type: str + env_files: + description: + - By default environment files are loaded from a C(.env) file located directly under the O(project_src) directory. + - O(env_files) can be used to specify the path of one or multiple custom environment files instead. + - The path is relative to the O(project_src) directory. + type: list + elements: path + profiles: + description: + - List of profiles to enable when starting services. + - Equivalent to C(docker compose --profile). + type: list + elements: str + state: + description: + - Desired state of the project. + - V(present) is equivalent to running C(docker compose up). + - V(stopped) is equivalent to running C(docker compose stop). + - V(absent) is equivalent to running C(docker compose down). + - V(restarted) is equivalent to running C(docker compose restart). + type: str + default: present + choices: + - absent + - stopped + - restarted + - present + dependencies: + description: + - When O(state) is V(present) or V(restarted), specify whether or not to include linked services. + type: bool + default: true + recreate: + description: + - By default containers will be recreated when their configuration differs from the service definition. + - Setting to V(never) ignores configuration differences and leaves existing containers unchanged. + - Setting to V(always) forces recreation of all existing containers. + type: str + default: auto + choices: + - always + - never + - auto + remove_images: + description: + - Use with O(state=absent) to remove all images or only local images. + type: str + choices: + - 'all' + - 'local' + remove_volumes: + description: + - Use with O(state=absent) to remove data volumes. + type: bool + default: false + remove_orphans: + description: + - Remove containers for services not defined in the Compose file. + type: bool + default: false + timeout: + description: + - Timeout in seconds for container shutdown when attached or when containers are already running. + type: int + +requirements: + - "Docker CLI with Docker compose plugin 2.18.0 or later" + +author: + - Felix Fontein (@felixfontein) + +seealso: + - module: community.docker.docker_compose +''' + +EXAMPLES = ''' +# Examples use the django example at https://docs.docker.com/compose/django. Follow it to create the +# flask directory + +- name: Run using a project directory + hosts: localhost + gather_facts: false + tasks: + - name: Tear down existing services + community.docker.docker_compose_v2: + project_src: flask + state: absent + + - name: Create and start services + community.docker.docker_compose_v2: + project_src: flask + register: output + + - name: Show results + ansible.builtin.debug: + var: output + + - name: Run `docker-compose up` again + community.docker.docker_compose_v2: + project_src: flask + register: output + + - name: Show results + ansible.builtin.debug: + var: output + + - ansible.builtin.assert: + that: not output.changed + + - name: Stop all services + community.docker.docker_compose_v2: + project_src: flask + state: stopped + register: output + + - name: Show results + ansible.builtin.debug: + var: output + + - name: Verify that web and db services are not running + ansible.builtin.assert: + that: + - "not output.services.web.flask_web_1.state.running" + - "not output.services.db.flask_db_1.state.running" + + - name: Restart services + community.docker.docker_compose_v2: + project_src: flask + state: restarted + register: output + + - name: Show results + ansible.builtin.debug: + var: output + + - name: Verify that web and db services are running + ansible.builtin.assert: + that: + - "output.services.web.flask_web_1.state.running" + - "output.services.db.flask_db_1.state.running" +''' + +RETURN = ''' +containers: + description: + - A list of containers associated to the service. + returned: success + type: list + elements: dict + contains: + Command: + description: + - The container's command. + type: raw + CreatedAt: + description: + - The timestamp when the container was created. + type: str + sample: "2024-01-02 12:20:41 +0100 CET" + ExitCode: + description: + - The container's exit code. + type: int + Health: + description: + - The container's health check. + type: raw + ID: + description: + - The container's ID. + type: str + sample: "44a7d607219a60b7db0a4817fb3205dce46e91df2cb4b78a6100b6e27b0d3135" + Image: + description: + - The container's image. + type: str + Labels: + description: + - Labels for this container. + type: dict + LocalVolumes: + description: + - The local volumes count. + type: str + Mounts: + description: + - Mounts. + type: str + Name: + description: + - The container's primary name. + type: str + Names: + description: + - List of names of the container. + type: list + elements: str + Networks: + description: + - List of networks attached to this container. + type: list + elements: str + Ports: + description: + - List of port assignments as a string. + type: str + Publishers: + description: + - List of port assigments. + type: list + elements: dict + contains: + URL: + description: + - Interface the port is bound to. + type: str + TargetPort: + description: + - The container's port the published port maps to. + type: int + PublishedPort: + description: + - The port that is published. + type: int + Protocol: + description: + - The protocol. + type: str + choices: + - tcp + - udp + RunningFor: + description: + - Amount of time the container runs. + type: str + Service: + description: + - The name of the service. + type: str + Size: + description: + - The container's size. + type: str + sample: "0B" + State: + description: + - The container's state. + type: str + sample: running + Status: + description: + - The container's status. + type: str + sample: Up About a minute +images: + description: + - A list of images associated to the service. + returned: success + type: list + elements: dict + contains: + ID: + description: + - The image's ID. + type: str + sample: sha256:c8bccc0af9571ec0d006a43acb5a8d08c4ce42b6cc7194dd6eb167976f501ef1 + ContainerName: + description: + - Name of the conainer this image is used by. + type: str + Repository: + description: + - The repository where this image belongs to. + type: str + Tag: + description: + - The tag of the image. + type: str + Size: + description: + - The image's size in bytes. + type: int +actions: + description: + - A list of actions that have been applied. + returned: success + type: list + elements: dict + contains: + what: + description: + - What kind of resource was changed. + type: str + sample: container + choices: + - network + - image + - volume + - container + id: + description: + - The ID of the resource that was changed. + type: str + sample: container + status: + description: + - The status change that happened. + type: str + sample: Created + choices: + - Started + - Exited + - Restarted + - Created + - Stopped + - Killed + - Removed + - Recreated +''' + +import os +import re +import traceback +from collections import namedtuple + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.docker.plugins.module_utils.common_cli import ( + AnsibleModuleDockerClient, + DockerException, +) + +from ansible_collections.community.docker.plugins.module_utils.util import DockerBaseClass +from ansible_collections.community.docker.plugins.module_utils.version import LooseVersion + + +DOCKER_COMPOSE_MINIMAL_VERSION = '2.18.0' +DOCKER_COMPOSE_FILES = 'docker-compose.yml', 'docker-compose.yaml' +DOCKER_STATUS_DONE = frozenset(( + 'Started', + 'Healthy', + 'Exited', + 'Restarted', + 'Running', + 'Created', + 'Stopped', + 'Killed', + 'Removed', + # An extra, specific to containers + 'Recreated', +)) +DOCKER_STATUS_WORKING = frozenset(( + 'Creating', + 'Starting', + 'Waiting', + 'Restarting', + 'Stopping', + 'Killing', + 'Removing', + # An extra, specific to containers + 'Recreate', +)) +DOCKER_STATUS_ERROR = frozenset(( + 'Error', +)) + + +class ResourceType(object): + NETWORK = "network" + IMAGE = "image" + VOLUME = "volume" + CONTAINER = "container" + + @classmethod + def from_docker_compose_event(cls, resource_type): + # type: (Type[ResourceType], Text) -> Any + return { + "Network": cls.NETWORK, + "Image": cls.IMAGE, + "Volume": cls.VOLUME, + "Container": cls.CONTAINER, + }[resource_type] + + +ResourceEvent = namedtuple( + 'ResourceEvent', + ['resource_type', 'resource_id', 'status'] +) + + +_RE_RESOURCE_EVENT = re.compile( + r'^' + r'\s*' + r'(?PNetwork|Image|Volume|Container)' + r'\s+' + r'(?P[^\s]+)' + r'\s+' + r'(?P%s)' + r'\s*' + r'$' + % ( + "|".join(sorted(DOCKER_STATUS_DONE | DOCKER_STATUS_WORKING | DOCKER_STATUS_ERROR, key=lambda e: (len(e), e), reverse=True)) + ) +) + +_RE_RESOURCE_EVENT_DRY_RUN = re.compile( + r'^' + r'\s*' + r'DRY-RUN MODE -' + r'\s+' + r'(?PNetwork|Image|Volume|Container)' + r'\s+' + r'(?P[^\s]+)' + r'\s+' + r'(?P%s)' + r'\s*' + r'$' + % ( + "|".join(sorted(DOCKER_STATUS_DONE | DOCKER_STATUS_WORKING | DOCKER_STATUS_ERROR, key=lambda e: (len(e), e), reverse=True)) + ) +) + + +class ContainerManager(DockerBaseClass): + def __init__(self, client): + super(ContainerManager, self).__init__() + self.client = client + self.check_mode = self.client.check_mode + parameters = self.client.module.params + + self.project_src = parameters['project_src'] + self.project_name = parameters['project_name'] + self.env_files = parameters['env_files'] + self.profiles = parameters['profiles'] + self.state = parameters['state'] + self.dependencies = parameters['dependencies'] + self.recreate = parameters['recreate'] + self.remove_images = parameters['remove_images'] + self.remove_volumes = parameters['remove_volumes'] + self.remove_orphans = parameters['remove_orphans'] + self.timeout = parameters['timeout'] + + compose = self.client.get_client_plugin_info('compose') + if compose is None: + self.client.fail('Docker CLI {0} does not have the compose plugin installed'.format(self.client.get_cli())) + compose_version = compose['Version'].lstrip('v') + self.compose_version = LooseVersion(compose_version) + if self.compose_version < LooseVersion(DOCKER_COMPOSE_MINIMAL_VERSION): + self.client.fail('Docker CLI {cli} has the compose plugin with version {version}; need version {min_version} or later'.format( + cli=self.client.get_cli(), + version=compose_version, + min_version=DOCKER_COMPOSE_MINIMAL_VERSION, + )) + + if not os.path.isdir(self.project_src): + self.client.fail('"{0}" is not a directory'.format(self.project_src)) + + if all(not os.path.isfile(os.path.join(self.project_src, f)) for f in DOCKER_COMPOSE_FILES): + self.client.fail('"{0}" does not contain {1}'.format(self.project_src, ' or '.join(DOCKER_COMPOSE_FILES))) + + def get_base_args(self): + args = ['compose', '--ansi', 'never'] + if self.compose_version >= LooseVersion('2.19.0'): + args.extend(['--progress', 'plain']) + args.extend(['--project-directory', self.project_src]) + if self.project_name: + args.extend(['--project-name', self.project_name]) + for env_file in self.env_files or []: + args.extend(['--env-file', env_file]) + for profile in self.profiles or []: + args.extend(['--profile', profile]) + return args + + def list_containers_raw(self): + args = self.get_base_args() + ['ps', '--format', 'json', '--all'] + if self.compose_version >= LooseVersion('2.23.0'): + args.append('--no-trunc') + kwargs = dict(cwd=self.project_src, check_rc=True) + dummy, containers, dummy = self.client.call_cli_json_stream(*args, **kwargs) + return containers + + def list_containers(self): + result = [] + for container in self.list_containers_raw(): + labels = {} + if container.get('Labels'): + for part in container['Labels'].split(','): + label_value = part.split('=', 1) + labels[label_value[0]] = label_value[1] if len(label_value) > 1 else '' + container['Labels'] = labels + container['Names'] = container.get('Names', container['Name']).split(',') + container['Networks'] = container.get('Networks', '').split(',') + container['Publishers'] = container.get('Publishers') or [] + result.append(container) + return result + + def list_images(self): + args = self.get_base_args() + ['images', '--format', 'json'] + kwargs = dict(cwd=self.project_src, check_rc=True) + dummy, images, dummy = self.client.call_cli_json(*args, **kwargs) + return images + + def run(self): + if self.state == 'present': + result = self.cmd_up() + elif self.state == 'stopped': + result = self.cmd_stop() + elif self.state == 'restarted': + result = self.cmd_restart() + elif self.state == 'absent': + result = self.cmd_down() + + result['containers'] = self.list_containers() + result['images'] = self.list_images() + return result + + def parse_events(self, stderr, dry_run=False): + events = [] + for line in stderr.splitlines(): + line = to_native(line.strip()) + match = (_RE_RESOURCE_EVENT_DRY_RUN if dry_run else _RE_RESOURCE_EVENT).match(line) + if match is not None: + events.append( + ResourceEvent( + ResourceType.from_docker_compose_event(match.group('resource_type')), + match.group('resource_id'), + match.group('status') + ) + ) + return events + + def has_changes(self, events): + for event in events: + if event.status in DOCKER_STATUS_WORKING: + return True + return False + + def extract_actions(self, events): + actions = [] + for event in events: + if event.status in DOCKER_STATUS_WORKING: + actions.append({ + 'what': event.resource_type, + 'id': event.resource_id, + 'status': event.status, + }) + return actions + + def update_failed(self, result, events): + errors = [] + for event in events: + if event.status in DOCKER_STATUS_ERROR: + errors.append('Error when processing {resource_type} {resource_id}'.format(*event)) + if errors: + result['failed'] = True + result['msg'] = '\n'.join(errors) + + def get_up_cmd(self, dry_run, no_start=False): + args = self.get_base_args() + ['up', '--detach', '--no-color'] + if self.remove_orphans: + args.append('--remove-orphans') + if self.recreate == 'always': + args.append('--force-recreate') + if self.recreate == 'never': + args.append('--no-recreate') + if not self.dependencies: + args.append('--no-deps') + if self.timeout is not None: + args.extend(['--timeout', '%d' % self.timeout]) + if no_start: + args.append('--no-start') + if dry_run: + args.append('--dry-run') + args.append('--') + return args + + def cmd_up(self): + result = dict() + args = self.get_up_cmd(self.check_mode) + dummy, stdout, stderr = self.client.call_cli(*args, cwd=self.project_src, check_rc=True) + events = self.parse_events(stderr, dry_run=self.check_mode) + result['changed'] = self.has_changes(events) + result['actions'] = self.extract_actions(events) + self.update_failed(result, events) + return result + + def get_stop_cmd(self, dry_run): + args = self.get_base_args() + ['stop'] + if self.timeout is not None: + args.extend(['--timeout', '%d' % self.timeout]) + if dry_run: + args.append('--dry-run') + args.append('--') + return args + + def _are_containers_stopped(self): + for container in self.list_containers_raw(): + if container['State'] not in ('created', 'exited', 'stopped', 'killed'): + return False + return True + + def cmd_stop(self): + # Since 'docker compose stop' **always** claims its stopping containers, even if they are already + # stopped, we have to do this a bit more complicated. + + result = dict() + # Make sure all containers are created + args = self.get_up_cmd(self.check_mode, no_start=True) + dummy, stdout, stderr = self.client.call_cli(*args, cwd=self.project_src, check_rc=True) + events_1 = self.parse_events(stderr, dry_run=self.check_mode) + if not self._are_containers_stopped(): + # Make sure all containers are stopped + args = self.get_stop_cmd(self.check_mode) + dummy, stdout, stderr = self.client.call_cli(*args, cwd=self.project_src, check_rc=True) + events_2 = self.parse_events(stderr, dry_run=self.check_mode) + else: + events_2 = [] + # Compose result + result['changed'] = self.has_changes(events_1) or self.has_changes(events_2) + result['actions'] = self.extract_actions(events_1) + self.extract_actions(events_2) + self.update_failed(result, events_1 + events_2) + return result + + def get_restart_cmd(self, dry_run): + args = self.get_base_args() + ['restart'] + if not self.dependencies: + args.append('--no-deps') + if self.timeout is not None: + args.extend(['--timeout', '%d' % self.timeout]) + if dry_run: + args.append('--dry-run') + args.append('--') + return args + + def cmd_restart(self): + result = dict() + args = self.get_restart_cmd(self.check_mode) + dummy, stdout, stderr = self.client.call_cli(*args, cwd=self.project_src, check_rc=True) + events = self.parse_events(stderr, dry_run=self.check_mode) + result['changed'] = self.has_changes(events) + result['actions'] = self.extract_actions(events) + self.update_failed(result, events) + return result + + def get_down_cmd(self, dry_run): + args = self.get_base_args() + ['down'] + if self.remove_orphans: + args.append('--remove-orphans') + if self.remove_images: + args.extend(['--rmi', self.remove_images]) + if self.remove_volumes: + args.append('--volumes') + if self.timeout is not None: + args.extend(['--timeout', '%d' % self.timeout]) + if dry_run: + args.append('--dry-run') + args.append('--') + return args + + def cmd_down(self): + result = dict() + args = self.get_down_cmd(self.check_mode) + dummy, stdout, stderr = self.client.call_cli(*args, cwd=self.project_src, check_rc=True) + events = self.parse_events(stderr, dry_run=self.check_mode) + result['changed'] = self.has_changes(events) + result['actions'] = self.extract_actions(events) + self.update_failed(result, events) + return result + + +def main(): + argument_spec = dict( + project_src=dict(type='path', required=True), + project_name=dict(type='str'), + env_files=dict(type='list', elements='path'), + profiles=dict(type='list', elements='str'), + state=dict(type='str', default='present', choices=['absent', 'present', 'stopped', 'restarted']), + dependencies=dict(type='bool', default=True), + recreate=dict(type='str', default='auto', choices=['always', 'never', 'auto']), + remove_images=dict(type='str', choices=['all', 'local']), + remove_volumes=dict(type='bool', default=False), + remove_orphans=dict(type='bool', default=False), + timeout=dict(type='int'), + ) + + client = AnsibleModuleDockerClient( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + try: + result = ContainerManager(client).run() + client.module.exit_json(**result) + except DockerException as e: + client.fail('An unexpected docker error occurred: {0}'.format(to_native(e)), exception=traceback.format_exc()) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/docker_compose_v2/aliases b/tests/integration/targets/docker_compose_v2/aliases new file mode 100644 index 000000000..2e1acc0ad --- /dev/null +++ b/tests/integration/targets/docker_compose_v2/aliases @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/4 +destructive diff --git a/tests/integration/targets/docker_compose_v2/meta/main.yml b/tests/integration/targets/docker_compose_v2/meta/main.yml new file mode 100644 index 000000000..aefcf50f2 --- /dev/null +++ b/tests/integration/targets/docker_compose_v2/meta/main.yml @@ -0,0 +1,10 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +dependencies: + - setup_docker_cli_compose + # The Python dependencies are needed for the other modules + - setup_docker_python_deps + - setup_remote_tmp_dir diff --git a/tests/integration/targets/docker_compose_v2/tasks/main.yml b/tests/integration/targets/docker_compose_v2/tasks/main.yml new file mode 100644 index 000000000..8813f0e71 --- /dev/null +++ b/tests/integration/targets/docker_compose_v2/tasks/main.yml @@ -0,0 +1,48 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Create random name prefix (for services, ...) +- name: Create random container name prefix + set_fact: + name_prefix: "{{ 'ansible-docker-test-%0x' % ((2**32) | random) }}" + cnames: [] + dnetworks: [] + +- debug: + msg: "Using name prefix {{ name_prefix }}" + +# Run the tests +- block: + - command: docker compose --help + + - include_tasks: run-test.yml + with_fileglob: + - "tests/*.yml" + loop_control: + loop_var: test_name + + always: + - name: "Make sure all containers are removed" + docker_container: + name: "{{ item }}" + state: absent + force_kill: true + with_items: "{{ cnames }}" + diff: false + + - name: "Make sure all networks are removed" + docker_network: + name: "{{ item }}" + state: absent + force: true + with_items: "{{ dnetworks }}" + diff: false + + when: docker_has_compose and docker_compose_version is version('2.18.0', '>=') diff --git a/tests/integration/targets/docker_compose_v2/tasks/run-test.yml b/tests/integration/targets/docker_compose_v2/tasks/run-test.yml new file mode 100644 index 000000000..72a58962d --- /dev/null +++ b/tests/integration/targets/docker_compose_v2/tasks/run-test.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: "Loading tasks from {{ test_name }}" + include_tasks: "{{ test_name }}" diff --git a/tests/integration/targets/docker_compose_v2/tasks/tests/start-stop.yml b/tests/integration/targets/docker_compose_v2/tasks/tests/start-stop.yml new file mode 100644 index 000000000..7fa836147 --- /dev/null +++ b/tests/integration/targets/docker_compose_v2/tasks/tests/start-stop.yml @@ -0,0 +1,276 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Set up project and container names + set_fact: + pname: "{{ name_prefix }}" + cname: "{{ name_prefix ~ '-hi' }}" + +- name: Registering container name + set_fact: + cnames: "{{ cnames + [pname ~ '-' ~ cname ~ '-1'] }}" + dnetworks: "{{ dnetworks + [pname ~ '_default'] }}" + +- name: Define services + set_fact: + project_src: "{{ remote_tmp_dir }}/{{ pname }}" + test_service: | + version: '3' + services: + {{ cname }}: + image: "{{ docker_test_image_alpine }}" + command: '/bin/sh -c "sleep 10m"' + stop_grace_period: 1s + test_service_mod: | + version: '3' + services: + {{ cname }}: + image: "{{ docker_test_image_alpine }}" + command: '/bin/sh -c "sleep 15m"' + stop_grace_period: 1s + +- name: Create project directory + file: + path: '{{ project_src }}' + state: directory + +#################################################################### +## Present ######################################################### +#################################################################### + +- name: Template default project file + copy: + dest: '{{ project_src }}/docker-compose.yml' + content: '{{ test_service }}' + +- name: Present (check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + check_mode: true + register: present_1_check + +- name: Present + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + register: present_1 + +- name: Present (idempotent check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + check_mode: true + register: present_2_check + +- name: Present (idempotent) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + register: present_2 + +- name: Template modified project file + copy: + dest: '{{ project_src }}/docker-compose.yml' + content: '{{ test_service_mod }}' + +- name: Present (changed check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + check_mode: true + register: present_3_check + +- name: Present (changed) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + register: present_3 + +- assert: + that: + - present_1_check is changed + - present_1 is changed + - present_1.containers | length == 1 + - present_1.containers[0].Name == pname ~ '-' ~ cname ~ '-1' + - present_1.containers[0].Image == docker_test_image_alpine + - present_1.images | length == 1 + - present_1.images[0].ContainerName == pname ~ '-' ~ cname ~ '-1' + - present_1.images[0].Repository == (docker_test_image_alpine | split(':') | first) + - present_1.images[0].Tag == (docker_test_image_alpine | split(':') | last) + - present_2_check is not changed + - present_2 is not changed + - present_3_check is changed + - present_3 is changed + +#################################################################### +## Absent ########################################################## +#################################################################### + +- name: Absent (check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: absent + check_mode: true + register: absent_1_check + +- name: Absent + docker_compose_v2: + project_src: '{{ project_src }}' + state: absent + register: absent_1 + +- name: Absent (idempotent check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: absent + check_mode: true + register: absent_2_check + +- name: Absent (idempotent) + docker_compose_v2: + project_src: '{{ project_src }}' + state: absent + register: absent_2 + +- assert: + that: + - absent_1_check is changed + - absent_1 is changed + - absent_2_check is not changed + - absent_2 is not changed + +#################################################################### +## Stopping and starting ########################################### +#################################################################### + +- name: Template default project file + copy: + dest: '{{ project_src }}/docker-compose.yml' + content: '{{ test_service }}' + +- name: Present stopped (check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: stopped + check_mode: true + register: present_1_check + +- name: Present stopped + docker_compose_v2: + project_src: '{{ project_src }}' + state: stopped + register: present_1 + +- name: Present stopped (idempotent check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: stopped + check_mode: true + register: present_2_check + +- name: Present stopped (idempotent) + docker_compose_v2: + project_src: '{{ project_src }}' + state: stopped + register: present_2 + +- name: Started (check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + check_mode: true + register: present_3_check + +- name: Started + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + register: present_3 + +- name: Started (idempotent check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + check_mode: true + register: present_4_check + +- name: Started (idempotent) + docker_compose_v2: + project_src: '{{ project_src }}' + state: present + register: present_4 + +- name: Restarted (check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: restarted + check_mode: true + register: present_5_check + +- name: Restarted + docker_compose_v2: + project_src: '{{ project_src }}' + state: restarted + register: present_5 + +- name: Stopped (check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: stopped + check_mode: true + register: present_6_check + +- name: Stopped + docker_compose_v2: + project_src: '{{ project_src }}' + state: stopped + register: present_6 + +- name: Restarted (check) + docker_compose_v2: + project_src: '{{ project_src }}' + state: restarted + check_mode: true + register: present_7_check + +- name: Restarted + docker_compose_v2: + project_src: '{{ project_src }}' + state: restarted + register: present_7 + +- name: Cleanup + docker_compose_v2: + project_src: '{{ project_src }}' + state: absent + +- assert: + that: + - present_1_check is changed + - present_1 is changed + - present_2_check is not changed + - present_2 is not changed + - present_3_check is changed + - present_3 is changed + - present_4_check is not changed + - present_4 is not changed + - present_5_check is changed + - present_5 is changed + - present_7_check is changed + - present_7 is changed + +# For some reason, the started containers aren't restarted with version 2.18.x. +- assert: + that: + - present_6_check is not changed + - present_6 is not changed + when: docker_compose_version is version('2.19', '<') + +- assert: + that: + - present_6_check is changed + - present_6 is changed + when: docker_compose_version is version('2.19', '>=') diff --git a/tests/integration/targets/setup_docker_cli_compose/meta/main.yml b/tests/integration/targets/setup_docker_cli_compose/meta/main.yml new file mode 100644 index 000000000..5769ff1cb --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/meta/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +dependencies: + - setup_docker diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/Alpine.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/Alpine.yml new file mode 100644 index 000000000..7190dbad5 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/Alpine.yml @@ -0,0 +1,13 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Compose is available from Alpine 3.15 on + set_fact: + docker_has_compose: "{{ ansible_facts.distribution_version is version('3.15', '>=') }}" + +- name: Install Docker compose CLI plugin + apk: + name: docker-cli-compose + when: docker_has_compose diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/Archlinux.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/Archlinux.yml new file mode 100644 index 000000000..89dedb0b2 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/Archlinux.yml @@ -0,0 +1,8 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Install Docker compose CLI plugin + community.general.pacman: + name: docker-compose diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/Debian.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/Debian.yml new file mode 120000 index 000000000..0b0695149 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/Debian.yml @@ -0,0 +1 @@ +nothing.yml \ No newline at end of file diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/Fedora.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/Fedora.yml new file mode 120000 index 000000000..0b0695149 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/Fedora.yml @@ -0,0 +1 @@ +nothing.yml \ No newline at end of file diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-7.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-7.yml new file mode 120000 index 000000000..0b0695149 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-7.yml @@ -0,0 +1 @@ +nothing.yml \ No newline at end of file diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-8.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-8.yml new file mode 100644 index 000000000..b4ece59ae --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-8.yml @@ -0,0 +1,8 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: For some reason we don't have the buildx plugin + set_fact: + docker_has_compose: false diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-9.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-9.yml new file mode 120000 index 000000000..0b0695149 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/RedHat-9.yml @@ -0,0 +1 @@ +nothing.yml \ No newline at end of file diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/Suse.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/Suse.yml new file mode 100644 index 000000000..5b80302bf --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/Suse.yml @@ -0,0 +1,14 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Compose is available in OpenSuSE 15.5, not sure which versions before + set_fact: + docker_has_compose: "{{ ansible_facts.distribution_version is version('15.5', '>=') }}" + +- name: Install Docker compose CLI plugin + community.general.zypper: + name: docker-compose + disable_gpg_check: true + when: docker_has_compose diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/main.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/main.yml new file mode 100644 index 000000000..7a971c229 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/main.yml @@ -0,0 +1,65 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +- name: Setup Docker + when: ansible_facts.distribution ~ ansible_facts.distribution_major_version not in ['CentOS6', 'RedHat6'] + block: + - name: + debug: + msg: |- + OS family: {{ ansible_facts.os_family }} + Distribution: {{ ansible_facts.distribution }} + Distribution major version: {{ ansible_facts.distribution_major_version }} + Distribution full version: {{ ansible_facts.distribution_version }} + + - name: Set facts for Docker features to defaults + set_fact: + docker_has_compose: true + docker_compose_version: "0.0" + + - name: Include distribution specific variables + include_vars: "{{ lookup('first_found', params) }}" + vars: + params: + files: + - "{{ ansible_facts.distribution }}-{{ ansible_facts.distribution_major_version }}.yml" + - "{{ ansible_facts.os_family }}-{{ ansible_facts.distribution_major_version }}.yml" + - "{{ ansible_facts.distribution }}.yml" + - "{{ ansible_facts.os_family }}.yml" + - default.yml + paths: + - "{{ role_path }}/vars" + + - name: Include distribution specific tasks + include_tasks: "{{ lookup('first_found', params) }}" + vars: + params: + files: + - "{{ ansible_facts.distribution }}-{{ ansible_facts.distribution_major_version }}.yml" + - "{{ ansible_facts.os_family }}-{{ ansible_facts.distribution_major_version }}.yml" + - "{{ ansible_facts.distribution }}.yml" + - "{{ ansible_facts.os_family }}.yml" + paths: + - "{{ role_path }}/tasks" + + - name: Obtain Docker Compose version + when: docker_has_compose + block: + - command: + cmd: docker info --format '{% raw %}{{ json .ClientInfo.Plugins }}{% endraw %}' + register: docker_cli_plugins_stdout + - set_fact: + docker_has_compose: >- + {{ docker_cli_plugins_stdout.stdout | from_json | selectattr('Name', 'eq', 'compose') | map(attribute='Version') | length > 0 }} + docker_compose_version: >- + {{ docker_cli_plugins_stdout.stdout | from_json | selectattr('Name', 'eq', 'compose') | map(attribute='Version') | first | default('0.0') | regex_replace('^v', '') }} + + - debug: + msg: "Has Docker compoes plugin: {{ docker_has_compose }}; Docker compose plugin version: {{ docker_compose_version }}" diff --git a/tests/integration/targets/setup_docker_cli_compose/tasks/nothing.yml b/tests/integration/targets/setup_docker_cli_compose/tasks/nothing.yml new file mode 100644 index 000000000..a93d788ac --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/tasks/nothing.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# nothing to do +[] diff --git a/tests/integration/targets/setup_docker_cli_compose/vars/default.yml b/tests/integration/targets/setup_docker_cli_compose/vars/default.yml new file mode 100644 index 000000000..f55df21f8 --- /dev/null +++ b/tests/integration/targets/setup_docker_cli_compose/vars/default.yml @@ -0,0 +1,4 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/sanity/ignore-2.10.txt b/tests/sanity/ignore-2.10.txt index 1a9f48884..e5d7ab536 100644 --- a/tests/sanity/ignore-2.10.txt +++ b/tests/sanity/ignore-2.10.txt @@ -7,5 +7,6 @@ plugins/modules/current_container_facts.py validate-modules:return-syntax-error plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax +plugins/modules/docker_compose_v2.py validate-modules:return-syntax-error plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin diff --git a/tests/sanity/ignore-2.11.txt b/tests/sanity/ignore-2.11.txt index 1a9f48884..e5d7ab536 100644 --- a/tests/sanity/ignore-2.11.txt +++ b/tests/sanity/ignore-2.11.txt @@ -7,5 +7,6 @@ plugins/modules/current_container_facts.py validate-modules:return-syntax-error plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax +plugins/modules/docker_compose_v2.py validate-modules:return-syntax-error plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin diff --git a/tests/sanity/ignore-2.12.txt b/tests/sanity/ignore-2.12.txt index 3d71834db..91497724f 100644 --- a/tests/sanity/ignore-2.12.txt +++ b/tests/sanity/ignore-2.12.txt @@ -1,3 +1,4 @@ .azure-pipelines/scripts/publish-codecov.py replace-urlopen plugins/modules/current_container_facts.py validate-modules:return-syntax-error +plugins/modules/docker_compose_v2.py validate-modules:return-syntax-error plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin diff --git a/tests/sanity/ignore-2.13.txt b/tests/sanity/ignore-2.13.txt index 2a06013da..c0d5c549c 100644 --- a/tests/sanity/ignore-2.13.txt +++ b/tests/sanity/ignore-2.13.txt @@ -1,2 +1,3 @@ .azure-pipelines/scripts/publish-codecov.py replace-urlopen +plugins/modules/docker_compose_v2.py validate-modules:return-syntax-error plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin diff --git a/tests/sanity/ignore-2.9.txt b/tests/sanity/ignore-2.9.txt index 81b68cbd8..8e4b495d2 100644 --- a/tests/sanity/ignore-2.9.txt +++ b/tests/sanity/ignore-2.9.txt @@ -6,5 +6,6 @@ .azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate plugins/module_utils/module_container/module.py compile-2.6!skip # Uses Python 2.7+ syntax plugins/module_utils/module_container/module.py import-2.6!skip # Uses Python 2.7+ syntax +plugins/modules/docker_compose_v2.py validate-modules:return-syntax-error plugins/modules/docker_container.py import-2.6!skip # Import uses Python 2.7+ syntax plugins/modules/docker_container_copy_into.py validate-modules:undocumented-parameter # _max_file_size_for_diff is used by the action plugin