Merge pull request #7930 from acran/profiles

Implement service profiles
This commit is contained in:
Anca Iordache 2020-12-02 19:57:19 +01:00 committed by GitHub
commit 5e3708e605
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 203 additions and 13 deletions

View File

@ -66,7 +66,8 @@ def project_from_options(project_dir, options, additional_options=None):
environment=environment, environment=environment,
override_dir=override_dir, override_dir=override_dir,
interpolate=(not additional_options.get('--no-interpolate')), interpolate=(not additional_options.get('--no-interpolate')),
environment_file=environment_file environment_file=environment_file,
enabled_profiles=get_profiles_from_options(options, environment)
) )
@ -115,9 +116,21 @@ def get_config_path_from_options(base_dir, options, environment):
return None return None
def get_profiles_from_options(options, environment):
profile_option = options.get('--profile')
if profile_option:
return profile_option
profiles = environment.get('COMPOSE_PROFILE')
if profiles:
return profiles.split(',')
return []
def get_project(project_dir, config_path=None, project_name=None, verbose=False, def get_project(project_dir, config_path=None, project_name=None, verbose=False,
context=None, environment=None, override_dir=None, context=None, environment=None, override_dir=None,
interpolate=True, environment_file=None): interpolate=True, environment_file=None, enabled_profiles=None):
if not environment: if not environment:
environment = Environment.from_env_file(project_dir) environment = Environment.from_env_file(project_dir)
config_details = config.find(project_dir, config_path, environment, override_dir) config_details = config.find(project_dir, config_path, environment, override_dir)
@ -139,6 +152,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False,
client, client,
environment.get('DOCKER_DEFAULT_PLATFORM'), environment.get('DOCKER_DEFAULT_PLATFORM'),
execution_context_labels(config_details, environment_file), execution_context_labels(config_details, environment_file),
enabled_profiles,
) )

View File

@ -182,7 +182,7 @@ class TopLevelCommand:
"""Define and run multi-container applications with Docker. """Define and run multi-container applications with Docker.
Usage: Usage:
docker-compose [-f <arg>...] [options] [--] [COMMAND] [ARGS...] docker-compose [-f <arg>...] [--profile <name>...] [options] [--] [COMMAND] [ARGS...]
docker-compose -h|--help docker-compose -h|--help
Options: Options:
@ -190,6 +190,7 @@ class TopLevelCommand:
(default: docker-compose.yml) (default: docker-compose.yml)
-p, --project-name NAME Specify an alternate project name -p, --project-name NAME Specify an alternate project name
(default: directory name) (default: directory name)
--profile NAME Specify a profile to enable
-c, --context NAME Specify a context name -c, --context NAME Specify a context name
--verbose Show more output --verbose Show more output
--log-level LEVEL Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) --log-level LEVEL Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)

View File

@ -328,6 +328,7 @@
"uniqueItems": true "uniqueItems": true
}, },
"privileged": {"type": "boolean"}, "privileged": {"type": "boolean"},
"profiles": {"$ref": "#/definitions/list_of_strings"},
"pull_policy": {"type": "string", "enum": [ "pull_policy": {"type": "string", "enum": [
"always", "never", "if_not_present" "always", "never", "if_not_present"
]}, ]},

View File

@ -133,6 +133,7 @@ ALLOWED_KEYS = DOCKER_CONFIG_KEYS + [
'logging', 'logging',
'network_mode', 'network_mode',
'platform', 'platform',
'profiles',
'scale', 'scale',
'stop_grace_period', 'stop_grace_period',
] ]
@ -1047,7 +1048,7 @@ def merge_service_dicts(base, override, version):
for field in [ for field in [
'cap_add', 'cap_drop', 'expose', 'external_links', 'cap_add', 'cap_drop', 'expose', 'external_links',
'volumes_from', 'device_cgroup_rules', 'volumes_from', 'device_cgroup_rules', 'profiles',
]: ]:
md.merge_field(field, merge_unique_items_lists, default=[]) md.merge_field(field, merge_unique_items_lists, default=[])

View File

@ -68,13 +68,15 @@ class Project:
""" """
A collection of services. A collection of services.
""" """
def __init__(self, name, services, client, networks=None, volumes=None, config_version=None): def __init__(self, name, services, client, networks=None, volumes=None, config_version=None,
enabled_profiles=None):
self.name = name self.name = name
self.services = services self.services = services
self.client = client self.client = client
self.volumes = volumes or ProjectVolumes({}) self.volumes = volumes or ProjectVolumes({})
self.networks = networks or ProjectNetworks({}, False) self.networks = networks or ProjectNetworks({}, False)
self.config_version = config_version self.config_version = config_version
self.enabled_profiles = enabled_profiles or []
def labels(self, one_off=OneOffFilter.exclude, legacy=False): def labels(self, one_off=OneOffFilter.exclude, legacy=False):
name = self.name name = self.name
@ -86,7 +88,8 @@ class Project:
return labels return labels
@classmethod @classmethod
def from_config(cls, name, config_data, client, default_platform=None, extra_labels=None): def from_config(cls, name, config_data, client, default_platform=None, extra_labels=None,
enabled_profiles=None):
""" """
Construct a Project from a config.Config object. Construct a Project from a config.Config object.
""" """
@ -98,7 +101,7 @@ class Project:
networks, networks,
use_networking) use_networking)
volumes = ProjectVolumes.from_config(name, config_data, client) volumes = ProjectVolumes.from_config(name, config_data, client)
project = cls(name, [], client, project_networks, volumes, config_data.version) project = cls(name, [], client, project_networks, volumes, config_data.version, enabled_profiles)
for service_dict in config_data.services: for service_dict in config_data.services:
service_dict = dict(service_dict) service_dict = dict(service_dict)
@ -185,7 +188,7 @@ class Project:
if name not in valid_names: if name not in valid_names:
raise NoSuchService(name) raise NoSuchService(name)
def get_services(self, service_names=None, include_deps=False): def get_services(self, service_names=None, include_deps=False, auto_enable_profiles=True):
""" """
Returns a list of this project's services filtered Returns a list of this project's services filtered
by the provided list of names, or all services if service_names is None by the provided list of names, or all services if service_names is None
@ -198,15 +201,36 @@ class Project:
reordering as needed to resolve dependencies. reordering as needed to resolve dependencies.
Raises NoSuchService if any of the named services do not exist. Raises NoSuchService if any of the named services do not exist.
Raises ConfigurationError if any service depended on is not enabled by active profiles
""" """
# create a copy so we can *locally* add auto-enabled profiles later
enabled_profiles = self.enabled_profiles.copy()
if service_names is None or len(service_names) == 0: if service_names is None or len(service_names) == 0:
service_names = self.service_names auto_enable_profiles = False
service_names = [
service.name
for service in self.services
if service.enabled_for_profiles(enabled_profiles)
]
unsorted = [self.get_service(name) for name in service_names] unsorted = [self.get_service(name) for name in service_names]
services = [s for s in self.services if s in unsorted] services = [s for s in self.services if s in unsorted]
if auto_enable_profiles:
# enable profiles of explicitly targeted services
for service in services:
for profile in service.get_profiles():
if profile not in enabled_profiles:
enabled_profiles.append(profile)
if include_deps: if include_deps:
services = reduce(self._inject_deps, services, []) services = reduce(
lambda acc, s: self._inject_deps(acc, s, enabled_profiles),
services,
[]
)
uniques = [] uniques = []
[uniques.append(s) for s in services if s not in uniques] [uniques.append(s) for s in services if s not in uniques]
@ -437,10 +461,12 @@ class Project:
self.remove_images(remove_image_type) self.remove_images(remove_image_type)
def remove_images(self, remove_image_type): def remove_images(self, remove_image_type):
for service in self.get_services(): for service in self.services:
service.remove_image(remove_image_type) service.remove_image(remove_image_type)
def restart(self, service_names=None, **options): def restart(self, service_names=None, **options):
# filter service_names by enabled profiles
service_names = [s.name for s in self.get_services(service_names)]
containers = self.containers(service_names, stopped=True) containers = self.containers(service_names, stopped=True)
parallel.parallel_execute( parallel.parallel_execute(
@ -855,14 +881,26 @@ class Project:
) )
) )
def _inject_deps(self, acc, service): def _inject_deps(self, acc, service, enabled_profiles):
dep_names = service.get_dependency_names() dep_names = service.get_dependency_names()
if len(dep_names) > 0: if len(dep_names) > 0:
dep_services = self.get_services( dep_services = self.get_services(
service_names=list(set(dep_names)), service_names=list(set(dep_names)),
include_deps=True include_deps=True,
auto_enable_profiles=False
) )
for dep in dep_services:
if not dep.enabled_for_profiles(enabled_profiles):
raise ConfigurationError(
'Service "{dep_name}" was pulled in as a dependency of '
'service "{service_name}" but is not enabled by the '
'active profiles. '
'You may fix this by adding a common profile to '
'"{dep_name}" and "{service_name}".'
.format(dep_name=dep.name, service_name=service.name)
)
else: else:
dep_services = [] dep_services = []

View File

@ -1329,6 +1329,24 @@ class Service:
return result return result
def get_profiles(self):
if 'profiles' not in self.options:
return []
return self.options.get('profiles')
def enabled_for_profiles(self, enabled_profiles):
# if service has no profiles specified it is always enabled
if 'profiles' not in self.options:
return True
service_profiles = self.options.get('profiles')
for profile in enabled_profiles:
if profile in service_profiles:
return True
return False
def short_id_alias_exists(container, network): def short_id_alias_exists(container, network):
aliases = container.get( aliases = container.get(

View File

@ -1719,6 +1719,98 @@ services:
shareable_mode_container = self.project.get_service('shareable').containers()[0] shareable_mode_container = self.project.get_service('shareable').containers()[0]
assert shareable_mode_container.get('HostConfig.IpcMode') == 'shareable' assert shareable_mode_container.get('HostConfig.IpcMode') == 'shareable'
def test_profiles_up_with_no_profile(self):
self.base_dir = 'tests/fixtures/profiles'
self.dispatch(['up'])
containers = self.project.containers(stopped=True)
service_names = [c.service for c in containers]
assert 'foo' in service_names
assert len(containers) == 1
def test_profiles_up_with_profile(self):
self.base_dir = 'tests/fixtures/profiles'
self.dispatch(['--profile', 'test', 'up'])
containers = self.project.containers(stopped=True)
service_names = [c.service for c in containers]
assert 'foo' in service_names
assert 'bar' in service_names
assert 'baz' in service_names
assert len(containers) == 3
def test_profiles_up_invalid_dependency(self):
self.base_dir = 'tests/fixtures/profiles'
result = self.dispatch(['--profile', 'debug', 'up'], returncode=1)
assert ('Service "bar" was pulled in as a dependency of service "zot" '
'but is not enabled by the active profiles.') in result.stderr
def test_profiles_up_with_multiple_profiles(self):
self.base_dir = 'tests/fixtures/profiles'
self.dispatch(['--profile', 'debug', '--profile', 'test', 'up'])
containers = self.project.containers(stopped=True)
service_names = [c.service for c in containers]
assert 'foo' in service_names
assert 'bar' in service_names
assert 'baz' in service_names
assert 'zot' in service_names
assert len(containers) == 4
def test_profiles_up_with_profile_enabled_by_service(self):
self.base_dir = 'tests/fixtures/profiles'
self.dispatch(['up', 'bar'])
containers = self.project.containers(stopped=True)
service_names = [c.service for c in containers]
assert 'bar' in service_names
assert len(containers) == 1
def test_profiles_up_with_dependency_and_profile_enabled_by_service(self):
self.base_dir = 'tests/fixtures/profiles'
self.dispatch(['up', 'baz'])
containers = self.project.containers(stopped=True)
service_names = [c.service for c in containers]
assert 'bar' in service_names
assert 'baz' in service_names
assert len(containers) == 2
def test_profiles_up_with_invalid_dependency_for_target_service(self):
self.base_dir = 'tests/fixtures/profiles'
result = self.dispatch(['up', 'zot'], returncode=1)
assert ('Service "bar" was pulled in as a dependency of service "zot" '
'but is not enabled by the active profiles.') in result.stderr
def test_profiles_up_with_profile_for_dependency(self):
self.base_dir = 'tests/fixtures/profiles'
self.dispatch(['--profile', 'test', 'up', 'zot'])
containers = self.project.containers(stopped=True)
service_names = [c.service for c in containers]
assert 'bar' in service_names
assert 'zot' in service_names
assert len(containers) == 2
def test_profiles_up_with_merged_profiles(self):
self.base_dir = 'tests/fixtures/profiles'
self.dispatch(['-f', 'docker-compose.yml', '-f', 'merge-profiles.yml', 'up', 'zot'])
containers = self.project.containers(stopped=True)
service_names = [c.service for c in containers]
assert 'bar' in service_names
assert 'zot' in service_names
assert len(containers) == 2
def test_exec_without_tty(self): def test_exec_without_tty(self):
self.base_dir = 'tests/fixtures/links-composefile' self.base_dir = 'tests/fixtures/links-composefile'
self.dispatch(['up', '-d', 'console']) self.dispatch(['up', '-d', 'console'])

View File

@ -0,0 +1,20 @@
version: "3"
services:
foo:
image: busybox:1.31.0-uclibc
bar:
image: busybox:1.31.0-uclibc
profiles:
- test
baz:
image: busybox:1.31.0-uclibc
depends_on:
- bar
profiles:
- test
zot:
image: busybox:1.31.0-uclibc
depends_on:
- bar
profiles:
- debug

View File

@ -0,0 +1,5 @@
version: "3"
services:
bar:
profiles:
- debug