diff --git a/README.md b/README.md index 622a2f450..d65c254ec 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ If you use the Ansible package and do not update collections independently, use - community.docker.docker_volume_info: retrieve information on Docker volumes * Docker Compose: - community.docker.docker_compose_v2: manage Docker Compose files (Docker compose CLI plugin) + - community.docker.docker_compose_v2_build: build images for a Docker compose project - community.docker.docker_compose_v2_exec: run command in a container of a Compose service - community.docker.docker_compose_v2_pull: pull a Docker compose project - community.docker.docker_compose_v2_run: run command in a new container of a Compose service diff --git a/docs/docsite/rst/scenario_guide.rst b/docs/docsite/rst/scenario_guide.rst index e17bae561..152996b8c 100644 --- a/docs/docsite/rst/scenario_guide.rst +++ b/docs/docsite/rst/scenario_guide.rst @@ -263,6 +263,9 @@ There are several modules for working with Docker Compose projects: community.docker.docker_compose_v2 The :ansplugin:`community.docker.docker_compose_v2 module ` allows you to use your existing Docker Compose files to orchestrate containers on a single Docker daemon or on Swarm. + community.docker.docker_compose_v2_build + The :ansplugin:`community.docker.docker_compose_v2_pull module ` allows you to build images for Docker compose projects. + community.docker.docker_compose_v2_exec The :ansplugin:`community.docker.docker_compose_v2_exec module ` allows you to run a command in a container of Docker Compose projects. diff --git a/meta/runtime.yml b/meta/runtime.yml index e228ab555..195911d17 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -7,6 +7,7 @@ requires_ansible: '>=2.15.0' action_groups: docker: - docker_compose_v2 + - docker_compose_v2_build - docker_compose_v2_exec - docker_compose_v2_pull - docker_compose_v2_run diff --git a/plugins/modules/docker_compose_v2.py b/plugins/modules/docker_compose_v2.py index cab98b29b..224823a3a 100644 --- a/plugins/modules/docker_compose_v2.py +++ b/plugins/modules/docker_compose_v2.py @@ -174,7 +174,10 @@ - Felix Fontein (@felixfontein) seealso: + - module: community.docker.docker_compose_v2_build + - module: community.docker.docker_compose_v2_exec - module: community.docker.docker_compose_v2_pull + - module: community.docker.docker_compose_v2_run """ EXAMPLES = r""" diff --git a/plugins/modules/docker_compose_v2_build.py b/plugins/modules/docker_compose_v2_build.py new file mode 100644 index 000000000..df002d53a --- /dev/null +++ b/plugins/modules/docker_compose_v2_build.py @@ -0,0 +1,190 @@ +#!/usr/bin/python +# +# Copyright (c) 2023, Felix Fontein +# Copyright (c) 2025, Maciej Bogusz (@mjbogusz) +# 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 = r""" +module: docker_compose_v2_build + +short_description: Build a Docker compose project + +version_added: 4.7.0 + +description: + - Uses Docker Compose to build images for a project. +extends_documentation_fragment: + - community.docker.compose_v2 + - community.docker.compose_v2.minimum_version + - community.docker.docker.cli_documentation + - community.docker.attributes + - community.docker.attributes.actiongroup_docker + +attributes: + check_mode: + support: full + diff_mode: + support: none + idempotent: + support: full + +options: + no_cache: + description: + - If set to V(true), will not use cache when building the images. + type: bool + default: false + pull: + description: + - If set to V(true), will attempt to pull newer version of the image. + type: bool + default: false + with_dependencies: + description: + - If set to V(true), also build services that are declared as dependencies. + - This only makes sense if O(services) is used. + type: bool + default: false + memory_limit: + description: + - Memory limit for the build container, in bytes. Not supported by BuildKit. + type: int + services: + description: + - Specifies a subset of services to be targeted. + type: list + elements: str + +author: + - Maciej Bogusz (@mjbogusz) + +seealso: + - module: community.docker.docker_compose_v2 +""" + +EXAMPLES = r""" +--- +- name: Build images for flask project + community.docker.docker_compose_v2_build: + project_src: /path/to/flask +""" + +RETURN = r""" +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: + - image + - unknown + id: + description: + - The ID of the resource that was changed. + type: str + sample: container + status: + description: + - The status change that happened. + type: str + sample: Building + choices: + - Building +""" + +import traceback + +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.compose_v2 import ( + BaseComposeManager, + common_compose_argspec_ex, +) + + +class BuildManager(BaseComposeManager): + def __init__(self, client): + super(BuildManager, self).__init__(client) + parameters = self.client.module.params + + self.no_cache = parameters['no_cache'] + self.pull = parameters['pull'] + self.with_dependencies = parameters['with_dependencies'] + self.memory_limit = parameters['memory_limit'] + self.services = parameters['services'] or [] + + def get_build_cmd(self, dry_run): + args = self.get_base_args() + ['build'] + if self.no_cache: + args.append('--no-cache') + if self.pull: + args.append('--pull') + if self.with_dependencies: + args.append('--with-dependencies') + if self.memory_limit: + args.extend(['--memory', str(self.memory_limit)]) + if dry_run: + args.append('--dry-run') + args.append('--') + for service in self.services: + args.append(service) + return args + + def run(self): + result = dict() + args = self.get_build_cmd(self.check_mode) + rc, stdout, stderr = self.client.call_cli(*args, cwd=self.project_src) + events = self.parse_events(stderr, dry_run=self.check_mode, nonzero_rc=rc != 0) + self.emit_warnings(events) + self.update_result(result, events, stdout, stderr, ignore_build_events=False) + self.update_failed(result, events, args, stdout, stderr, rc) + self.cleanup_result(result) + return result + + +def main(): + argument_spec = dict( + no_cache=dict(type='bool', default=False), + pull=dict(type='bool', default=False), + with_dependencies=dict(type='bool', default=False), + memory_limit=dict(type='int'), + services=dict(type='list', elements='str'), + ) + argspec_ex = common_compose_argspec_ex() + argument_spec.update(argspec_ex.pop('argspec')) + + client = AnsibleModuleDockerClient( + argument_spec=argument_spec, + supports_check_mode=True, + needs_api_version=False, + **argspec_ex + ) + + try: + manager = BuildManager(client) + result = manager.run() + manager.cleanup() + 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_build/aliases b/tests/integration/targets/docker_compose_v2_build/aliases new file mode 100644 index 000000000..2e1acc0ad --- /dev/null +++ b/tests/integration/targets/docker_compose_v2_build/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_build/meta/main.yml b/tests/integration/targets/docker_compose_v2_build/meta/main.yml new file mode 100644 index 000000000..aefcf50f2 --- /dev/null +++ b/tests/integration/targets/docker_compose_v2_build/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_build/tasks/main.yml b/tests/integration/targets/docker_compose_v2_build/tasks/main.yml new file mode 100644 index 000000000..89784660c --- /dev/null +++ b/tests/integration/targets/docker_compose_v2_build/tasks/main.yml @@ -0,0 +1,59 @@ +--- +# 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: [] + inames: [] + dnetworks: [] + +- debug: + msg: "Using name prefix {{ name_prefix }}" + +- name: Show images + command: docker images --all --digests + +# Run the tests +- block: + - name: Show docker compose --help output + 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 images are removed" + docker_image_remove: + name: "{{ item }}" + with_items: "{{ inames }}" + 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_build/tasks/run-test.yml b/tests/integration/targets/docker_compose_v2_build/tasks/run-test.yml new file mode 100644 index 000000000..72a58962d --- /dev/null +++ b/tests/integration/targets/docker_compose_v2_build/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_build/tasks/tests/build.yml b/tests/integration/targets/docker_compose_v2_build/tasks/tests/build.yml new file mode 100644 index 000000000..e13676301 --- /dev/null +++ b/tests/integration/targets/docker_compose_v2_build/tasks/tests/build.yml @@ -0,0 +1,159 @@ +--- +# 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 + +- vars: + bname: "{{ name_prefix }}-build" + cname: "{{ name_prefix }}-cont" + non_existing_image: does-not-exist:latest + dockerfile_path: test-dockerfile + base_image: "{{ docker_test_image_hello_world }}" + image_name: "{{ name_prefix }}-image" + project_src: "{{ remote_tmp_dir }}/{{ bname }}" + test_service_non_existing: | + services: + {{ cname }}: + image: {{ non_existing_image }} + build: + dockerfile: Dockerfile-does-not-exist + test_service_simple: | + services: + {{ cname }}: + image: {{ image_name }} + build: + dockerfile: {{ dockerfile_path }} + command: 10m + stop_grace_period: 1s + test_service_simple_dockerfile: | + FROM {{ base_image }} + RUN [ "/hello" ] + + block: + - name: Registering container name + set_fact: + cnames: "{{ cnames + [bname ~ '-' ~ cname ~ '-1'] }}" + inames: "{{ inames + [ base_image, image_name ] }}" + dnetworks: "{{ dnetworks + [bname ~ '_default'] }}" + + - name: Create project directory + file: + path: '{{ project_src }}' + state: directory + + - name: Make sure images are not around + docker_image_remove: + name: '{{ item }}' + loop: + - '{{ non_existing_image }}' + - '{{ image_name }}' + + - name: Prune docker build cache + docker_prune: + builder_cache: true + builder_cache_all: true + +#################################################################### +## Image with missing dockerfile ################################### +#################################################################### + + - name: Template project file with non-existing image + copy: + dest: '{{ project_src }}/docker-compose.yml' + content: '{{ test_service_non_existing }}' + + - name: Build (check) + docker_compose_v2_build: + project_src: '{{ project_src }}' + check_mode: true + register: build_1_check + ignore_errors: true + + - name: Build + docker_compose_v2_build: + project_src: '{{ project_src }}' + register: build_1 + ignore_errors: true + + - assert: + that: + - build_1_check is failed + - >- + build_1_check.msg | trim == "General error: failed to solve: failed to read dockerfile: open Dockerfile-does-not-exist: no such file or directory" + - build_1_check.warnings | default([]) | select('regex', 'Cannot parse event from ') | length == 0 + - build_1 is failed + - >- + build_1.msg | trim == "General error: failed to solve: failed to read dockerfile: open Dockerfile-does-not-exist: no such file or directory" + - build_1.warnings | default([]) | select('regex', 'Cannot parse event from ') | length == 0 + +#################################################################### +## Regular image ################################################### +#################################################################### + + - name: Template project file with simple dockerfile + copy: + dest: '{{ project_src }}/docker-compose.yml' + content: '{{ test_service_simple }}' + + - name: Template dockerfile + copy: + dest: '{{ project_src }}/{{ dockerfile_path }}' + content: '{{ test_service_simple_dockerfile }}' + + - docker_image_info: + name: "{{ image_name }}" + register: pre_image + + - name: Build (check) + docker_compose_v2_build: + project_src: '{{ project_src }}' + check_mode: true + register: build_1_check + + - docker_image_info: + name: "{{ image_name }}" + register: build_1_check_image + + - name: Build + docker_compose_v2_build: + project_src: '{{ project_src }}' + register: build_1 + + - docker_image_info: + name: "{{ image_name }}" + register: build_1_image + + - name: Build (idempotent, check) + docker_compose_v2_build: + project_src: '{{ project_src }}' + check_mode: true + register: build_2_check + + - docker_image_info: + name: "{{ image_name }}" + register: build_2_check_image + + - name: Build (idempotent) + docker_compose_v2_build: + project_src: '{{ project_src }}' + register: build_2 + + - docker_image_info: + name: "{{ image_name }}" + register: build_2_image + + - assert: + that: + - build_1_check is changed + - (build_1_check.actions | selectattr('status', 'eq', 'Building') | first) is truthy + - build_1_check.warnings | default([]) | select('regex', 'Cannot parse event from ') | length == 0 + - build_1 is changed + - (build_1.actions | selectattr('status', 'eq', 'Building') | first) is truthy + - build_1.warnings | default([]) | select('regex', 'Cannot parse event from ') | length == 0 + + - build_2_check is not changed + - build_2_check.actions | selectattr('status', 'eq', 'Building') | length == 0 + - build_2_check.warnings | default([]) | select('regex', 'Cannot parse event from ') | length == 0 + - build_2 is not changed + - build_2.actions | selectattr('status', 'eq', 'Building') | length == 0 + - build_2.warnings | default([]) | select('regex', 'Cannot parse event from ') | length == 0