Skip to content

Commit

Permalink
docker_compose_v2: allow to specify inline compose definitions (#832)
Browse files Browse the repository at this point in the history
* Allow to specify inline compose definitions.

* Remove comma that trips Python 2.7.

* Add tests.

* Add PyYAML as EE dependency.

* Be more explicit on PyYAML.
  • Loading branch information
felixfontein authored Apr 9, 2024
1 parent 2925334 commit 9e8c367
Show file tree
Hide file tree
Showing 7 changed files with 374 additions and 19 deletions.
8 changes: 8 additions & 0 deletions changelogs/fragments/832-docker_compose_v2-definition.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
minor_changes:
- "docker_compose_v2* modules - allow to provide an inline definition of the compose content
instead of having to provide a ``project_src`` directory with the compose file written into it
(https://github.com/ansible-collections/community.docker/issues/829, https://github.com/ansible-collections/community.docker/pull/832)."
- "The EE requirements now include PyYAML, since the ``docker_compose_v2*`` modules depend on it
when the ``definition`` option is used. This should not have a noticable effect on generated EEs
since ansible-core itself depends on PyYAML as well, and ansible-builder explicitly ignores this dependency
(https://github.com/ansible-collections/community.docker/pull/832)."
1 change: 1 addition & 0 deletions meta/ee-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ docker
urllib3
requests
paramiko
pyyaml

# We assume that EEs are not based on Windows, and have Python >= 3.5.
# (ansible-builder does not support conditionals, it will simply add
Expand Down
14 changes: 13 additions & 1 deletion plugins/doc_fragments/compose_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,30 @@ class ModuleDocFragment(object):
- Path to a directory containing a Compose file
(C(compose.yml), C(compose.yaml), C(docker-compose.yml), or C(docker-compose.yaml)).
- If O(files) is provided, will look for these files in this directory instead.
- Mutually exclusive with O(definition).
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).
- Required when O(definition) is provided.
type: str
files:
description:
- List of Compose file names relative to O(project_src) to be used instead of the main Compose file
(C(compose.yml), C(compose.yaml), C(docker-compose.yml), or C(docker-compose.yaml)).
- Files are loaded and merged in the order given.
- Mutually exclusive with O(definition).
type: list
elements: path
version_added: 3.7.0
definition:
description:
- Compose file describing one or more services, networks and volumes.
- Mutually exclusive with O(project_src) and O(files).
- If provided, PyYAML must be available to this module, and O(project_name) must be specified.
- Note that a temporary directory will be created and deleted afterwards when using this option.
type: dict
version_added: 3.9.0
env_files:
description:
- By default environment files are loaded from a C(.env) file located directly under the O(project_src) directory.
Expand All @@ -45,6 +55,8 @@ class ModuleDocFragment(object):
- Equivalent to C(docker compose --profile).
type: list
elements: str
requirements:
- "PyYAML if O(definition) is used"
notes:
- |-
The Docker compose CLI plugin has no stable output format (see for example U(https://github.com/docker/compose/issues/10872)),
Expand Down
78 changes: 70 additions & 8 deletions plugins/module_utils/compose_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@

import os
import re
import shutil
import tempfile
import traceback
from collections import namedtuple

from ansible.module_utils.basic import missing_required_lib
from ansible.module_utils.common.text.converters import to_native
from ansible.module_utils.six.moves import shlex_quote

Expand All @@ -21,6 +25,19 @@
parse_line as _parse_logfmt_line,
)

try:
import yaml
try:
# use C version if possible for speedup
from yaml import CSafeDumper as _SafeDumper
except ImportError:
from yaml import SafeDumper as _SafeDumper
HAS_PYYAML = True
PYYAML_IMPORT_ERROR = None
except ImportError:
HAS_PYYAML = False
PYYAML_IMPORT_ERROR = traceback.format_exc()


DOCKER_COMPOSE_FILES = ('compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml')

Expand Down Expand Up @@ -484,14 +501,28 @@ def update_failed(result, events, args, stdout, stderr, rc, cli):

def common_compose_argspec():
return dict(
project_src=dict(type='path', required=True),
project_src=dict(type='path'),
project_name=dict(type='str'),
files=dict(type='list', elements='path'),
definition=dict(type='dict'),
env_files=dict(type='list', elements='path'),
profiles=dict(type='list', elements='str'),
)


def common_compose_argspec_ex():
return dict(
argspec=common_compose_argspec(),
mutually_exclusive=[
('definition', 'project_src'),
('definition', 'files')
],
required_by={
'definition': ('project_name', ),
},
)


def combine_binary_output(*outputs):
return b'\n'.join(out for out in outputs if out)

Expand All @@ -505,43 +536,66 @@ def __init__(self, client, min_version=MINIMUM_COMPOSE_VERSION):
super(BaseComposeManager, self).__init__()
self.client = client
self.check_mode = self.client.check_mode
self.cleanup_dirs = set()
parameters = self.client.module.params

self.project_src = os.path.abspath(parameters['project_src'])
if parameters['definition'] is not None and not HAS_PYYAML:
self.fail(
missing_required_lib('PyYAML'),
exception=PYYAML_IMPORT_ERROR
)

self.project_name = parameters['project_name']
if parameters['definition'] is not None:
self.project_src = tempfile.mkdtemp(prefix='ansible')
self.cleanup_dirs.add(self.project_src)
compose_file = os.path.join(self.project_src, 'compose.yaml')
self.client.module.add_cleanup_file(compose_file)
try:
with open(compose_file, 'wb') as f:
yaml.dump(parameters['definition'], f, encoding="utf-8", Dumper=_SafeDumper)
except Exception as exc:
self.fail("Error writing to %s - %s" % (compose_file, to_native(exc)))
else:
self.project_src = os.path.abspath(parameters['project_src'])

self.files = parameters['files']
self.env_files = parameters['env_files']
self.profiles = parameters['profiles']

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()))
self.fail('Docker CLI {0} does not have the compose plugin installed'.format(self.client.get_cli()))
if compose['Version'] == 'dev':
self.client.fail(
self.fail(
'Docker CLI {0} has a compose plugin installed, but it reports version "dev".'
' Please use a version of the plugin that returns a proper version.'
.format(self.client.get_cli())
)
compose_version = compose['Version'].lstrip('v')
self.compose_version = LooseVersion(compose_version)
if self.compose_version < LooseVersion(min_version):
self.client.fail('Docker CLI {cli} has the compose plugin with version {version}; need version {min_version} or later'.format(
self.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=min_version,
))

if not os.path.isdir(self.project_src):
self.client.fail('"{0}" is not a directory'.format(self.project_src))
self.fail('"{0}" is not a directory'.format(self.project_src))

if self.files:
for file in self.files:
path = os.path.join(self.project_src, file)
if not os.path.exists(path):
self.client.fail('Cannot find Compose file "{0}" relative to project directory "{1}"'.format(file, self.project_src))
self.fail('Cannot find Compose file "{0}" relative to project directory "{1}"'.format(file, self.project_src))
elif all(not os.path.exists(os.path.join(self.project_src, f)) for f in DOCKER_COMPOSE_FILES):
filenames = ', '.join(DOCKER_COMPOSE_FILES[:-1])
self.client.fail('"{0}" does not contain {1}, or {2}'.format(self.project_src, filenames, DOCKER_COMPOSE_FILES[-1]))
self.fail('"{0}" does not contain {1}, or {2}'.format(self.project_src, filenames, DOCKER_COMPOSE_FILES[-1]))

def fail(self, msg, **kwargs):
self.cleanup()
self.client.fail(msg, **kwargs)

def get_base_args(self):
args = ['compose', '--ansi', 'never']
Expand Down Expand Up @@ -622,3 +676,11 @@ def cleanup_result(self, result):
for res in ('stdout', 'stderr'):
if result.get(res) == '':
result.pop(res)

def cleanup(self):
for dir in self.cleanup_dirs:
try:
shutil.rmtree(dir, True)
except Exception:
# shouldn't happen, but simply ignore to be on the safe side
pass
16 changes: 10 additions & 6 deletions plugins/modules/docker_compose_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@

from ansible_collections.community.docker.plugins.module_utils.compose_v2 import (
BaseComposeManager,
common_compose_argspec,
common_compose_argspec_ex,
is_failed,
)

Expand All @@ -435,13 +435,13 @@ def __init__(self, client):

for key, value in self.scale.items():
if not isinstance(key, string_types):
self.client.fail('The key %s for `scale` is not a string' % repr(key))
self.fail('The key %s for `scale` is not a string' % repr(key))
try:
value = check_type_int(value)
except TypeError as exc:
self.client.fail('The value %s for `scale[%s]` is not an integer' % (repr(value), repr(key)))
self.fail('The value %s for `scale[%s]` is not an integer' % (repr(value), repr(key)))
if value < 0:
self.client.fail('The value %s for `scale[%s]` is negative' % (repr(value), repr(key)))
self.fail('The value %s for `scale[%s]` is negative' % (repr(value), repr(key)))
self.scale[key] = value

def run(self):
Expand Down Expand Up @@ -620,15 +620,19 @@ def main():
wait=dict(type='bool', default=False),
wait_timeout=dict(type='int'),
)
argument_spec.update(common_compose_argspec())
argspec_ex = common_compose_argspec_ex()
argument_spec.update(argspec_ex.pop('argspec'))

client = AnsibleModuleDockerClient(
argument_spec=argument_spec,
supports_check_mode=True,
**argspec_ex
)

try:
result = ServicesManager(client).run()
manager = ServicesManager(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())
Expand Down
12 changes: 8 additions & 4 deletions plugins/modules/docker_compose_v2_pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@

from ansible_collections.community.docker.plugins.module_utils.compose_v2 import (
BaseComposeManager,
common_compose_argspec,
common_compose_argspec_ex,
)

from ansible_collections.community.docker.plugins.module_utils.version import LooseVersion
Expand All @@ -117,7 +117,7 @@ def __init__(self, client):

if self.policy != 'always' and self.compose_version < LooseVersion('2.22.0'):
# https://github.com/docker/compose/pull/10981 - 2.22.0
self.client.fail('A pull policy other than always is only supported since Docker Compose 2.22.0. {0} has version {1}'.format(
self.fail('A pull policy other than always is only supported since Docker Compose 2.22.0. {0} has version {1}'.format(
self.client.get_cli(), self.compose_version))

def get_pull_cmd(self, dry_run, no_start=False):
Expand Down Expand Up @@ -145,15 +145,19 @@ def main():
argument_spec = dict(
policy=dict(type='str', choices=['always', 'missing'], default='always'),
)
argument_spec.update(common_compose_argspec())
argspec_ex = common_compose_argspec_ex()
argument_spec.update(argspec_ex.pop('argspec'))

client = AnsibleModuleDockerClient(
argument_spec=argument_spec,
supports_check_mode=True,
**argspec_ex
)

try:
result = PullManager(client).run()
manager = PullManager(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())
Expand Down
Loading

0 comments on commit 9e8c367

Please sign in to comment.