diff --git a/compose/cli/command.py b/compose/cli/command.py index f9e698e17..21ab9a39f 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -37,7 +37,7 @@ SILENT_COMMANDS = set(( )) -def project_from_options(project_dir, options): +def project_from_options(project_dir, options, additional_options={}): override_dir = options.get('--project-directory') environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or project_dir, environment_file) @@ -57,6 +57,7 @@ def project_from_options(project_dir, options): environment=environment, override_dir=override_dir, compatibility=options.get('--compatibility'), + interpolate=(not additional_options.get('--no-interpolate')) ) @@ -76,7 +77,7 @@ def set_parallel_limit(environment): parallel.GlobalLimit.set_global_limit(parallel_limit) -def get_config_from_options(base_dir, options): +def get_config_from_options(base_dir, options, additional_options={}): override_dir = options.get('--project-directory') environment_file = options.get('--env-file') environment = Environment.from_env_file(override_dir or base_dir, environment_file) @@ -85,7 +86,8 @@ def get_config_from_options(base_dir, options): ) return config.load( config.find(base_dir, config_path, environment, override_dir), - options.get('--compatibility') + options.get('--compatibility'), + not additional_options.get('--no-interpolate') ) @@ -123,14 +125,14 @@ def get_client(environment, verbose=False, version=None, tls_config=None, host=N def get_project(project_dir, config_path=None, project_name=None, verbose=False, host=None, tls_config=None, environment=None, override_dir=None, - compatibility=False): + compatibility=False, interpolate=True): if not environment: environment = Environment.from_env_file(project_dir) config_details = config.find(project_dir, config_path, environment, override_dir) project_name = get_project_name( config_details.working_dir, project_name, environment ) - config_data = config.load(config_details, compatibility) + config_data = config.load(config_details, compatibility, interpolate) api_version = environment.get( 'COMPOSE_API_VERSION', diff --git a/compose/cli/main.py b/compose/cli/main.py index d8793c85b..1f8c58ca4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -331,6 +331,7 @@ class TopLevelCommand(object): Options: --resolve-image-digests Pin image tags to digests. + --no-interpolate Don't interpolate environment variables -q, --quiet Only validate the configuration, don't print anything. --services Print the service names, one per line. @@ -340,11 +341,12 @@ class TopLevelCommand(object): or use the wildcard symbol to display all services """ - compose_config = get_config_from_options('.', self.toplevel_options) + additional_options = {'--no-interpolate': options.get('--no-interpolate')} + compose_config = get_config_from_options('.', self.toplevel_options, additional_options) image_digests = None if options['--resolve-image-digests']: - self.project = project_from_options('.', self.toplevel_options) + self.project = project_from_options('.', self.toplevel_options, additional_options) with errors.handle_connection_errors(self.project.client): image_digests = image_digests_for_project(self.project) @@ -361,14 +363,14 @@ class TopLevelCommand(object): if options['--hash'] is not None: h = options['--hash'] - self.project = project_from_options('.', self.toplevel_options) + self.project = project_from_options('.', self.toplevel_options, additional_options) services = [svc for svc in options['--hash'].split(',')] if h != '*' else None with errors.handle_connection_errors(self.project.client): for service in self.project.get_services(services): print('{} {}'.format(service.name, service.config_hash)) return - print(serialize_config(compose_config, image_digests)) + print(serialize_config(compose_config, image_digests, not options['--no-interpolate'])) def create(self, options): """ diff --git a/compose/config/config.py b/compose/config/config.py index e2ed29a47..c91c5aeb2 100644 --- a/compose/config/config.py +++ b/compose/config/config.py @@ -373,7 +373,7 @@ def check_swarm_only_config(service_dicts, compatibility=False): check_swarm_only_key(service_dicts, 'configs') -def load(config_details, compatibility=False): +def load(config_details, compatibility=False, interpolate=True): """Load the configuration from a working directory and a list of configuration files. Files are loaded in order, and merged on top of each other to create the final configuration. @@ -383,7 +383,7 @@ def load(config_details, compatibility=False): validate_config_version(config_details.config_files) processed_files = [ - process_config_file(config_file, config_details.environment) + process_config_file(config_file, config_details.environment, interpolate=interpolate) for config_file in config_details.config_files ] config_details = config_details._replace(config_files=processed_files) @@ -505,7 +505,6 @@ def load_services(config_details, config_file, compatibility=False): def interpolate_config_section(config_file, config, section, environment): - validate_config_section(config_file.filename, config, section) return interpolate_environment_variables( config_file.version, config, @@ -514,38 +513,60 @@ def interpolate_config_section(config_file, config, section, environment): ) -def process_config_file(config_file, environment, service_name=None): - services = interpolate_config_section( +def process_config_section(config_file, config, section, environment, interpolate): + validate_config_section(config_file.filename, config, section) + if interpolate: + return interpolate_environment_variables( + config_file.version, + config, + section, + environment + ) + else: + return config + + +def process_config_file(config_file, environment, service_name=None, interpolate=True): + services = process_config_section( config_file, config_file.get_service_dicts(), 'service', - environment) + environment, + interpolate, + ) if config_file.version > V1: processed_config = dict(config_file.config) processed_config['services'] = services - processed_config['volumes'] = interpolate_config_section( + processed_config['volumes'] = process_config_section( config_file, config_file.get_volumes(), 'volume', - environment) - processed_config['networks'] = interpolate_config_section( + environment, + interpolate, + ) + processed_config['networks'] = process_config_section( config_file, config_file.get_networks(), 'network', - environment) + environment, + interpolate, + ) if config_file.version >= const.COMPOSEFILE_V3_1: - processed_config['secrets'] = interpolate_config_section( + processed_config['secrets'] = process_config_section( config_file, config_file.get_secrets(), 'secret', - environment) + environment, + interpolate, + ) if config_file.version >= const.COMPOSEFILE_V3_3: - processed_config['configs'] = interpolate_config_section( + processed_config['configs'] = process_config_section( config_file, config_file.get_configs(), 'config', - environment + environment, + interpolate, ) else: processed_config = services diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 8cb8a2808..5776ce957 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -24,14 +24,12 @@ def serialize_dict_type(dumper, data): def serialize_string(dumper, data): - """ Ensure boolean-like strings are quoted in the output and escape $ characters """ + """ Ensure boolean-like strings are quoted in the output """ representer = dumper.represent_str if six.PY3 else dumper.represent_unicode if isinstance(data, six.binary_type): data = data.decode('utf-8') - data = data.replace('$', '$$') - if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): # Empirically only y/n appears to be an issue, but this might change # depending on which PyYaml version is being used. Err on safe side. @@ -39,6 +37,12 @@ def serialize_string(dumper, data): return representer(data) +def serialize_string_escape_dollar(dumper, data): + """ Ensure boolean-like strings are quoted in the output and escape $ characters """ + data = data.replace('$', '$$') + return serialize_string(dumper, data) + + yaml.SafeDumper.add_representer(types.MountSpec, serialize_dict_type) yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeSpec, serialize_config_type) @@ -46,8 +50,6 @@ yaml.SafeDumper.add_representer(types.SecurityOpt, serialize_config_type) yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServiceConfig, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) -yaml.SafeDumper.add_representer(str, serialize_string) -yaml.SafeDumper.add_representer(six.text_type, serialize_string) def denormalize_config(config, image_digests=None): @@ -93,7 +95,13 @@ def v3_introduced_name_key(key): return V3_5 -def serialize_config(config, image_digests=None): +def serialize_config(config, image_digests=None, escape_dollar=True): + if escape_dollar: + yaml.SafeDumper.add_representer(str, serialize_string_escape_dollar) + yaml.SafeDumper.add_representer(six.text_type, serialize_string_escape_dollar) + else: + yaml.SafeDumper.add_representer(str, serialize_string) + yaml.SafeDumper.add_representer(six.text_type, serialize_string) return yaml.safe_dump( denormalize_config(config, image_digests), default_flow_style=False, diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index 888c0ae58..d9d8496af 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -613,6 +613,25 @@ class ConfigTest(unittest.TestCase): excinfo.exconly() ) + def test_config_integer_service_name_raise_validation_error_v2_when_no_interpolate(self): + with pytest.raises(ConfigurationError) as excinfo: + config.load( + build_config_details( + { + 'version': '2', + 'services': {1: {'image': 'busybox'}} + }, + 'working_dir', + 'filename.yml' + ), + interpolate=False + ) + + assert ( + "In file 'filename.yml', the service name 1 must be a quoted string, i.e. '1'." in + excinfo.exconly() + ) + def test_config_integer_service_property_raise_validation_error(self): with pytest.raises(ConfigurationError) as excinfo: config.load( @@ -5328,6 +5347,28 @@ class SerializeTest(unittest.TestCase): assert serialized_service['command'] == 'echo $$FOO' assert serialized_service['entrypoint'][0] == '$$SHELL' + def test_serialize_escape_dont_interpolate(self): + cfg = { + 'version': '2.2', + 'services': { + 'web': { + 'image': 'busybox', + 'command': 'echo $FOO', + 'environment': { + 'CURRENCY': '$' + }, + 'entrypoint': ['$SHELL', '-c'], + } + } + } + config_dict = config.load(build_config_details(cfg), interpolate=False) + + serialized_config = yaml.load(serialize_config(config_dict, escape_dollar=False)) + serialized_service = serialized_config['services']['web'] + assert serialized_service['environment']['CURRENCY'] == '$' + assert serialized_service['command'] == 'echo $FOO' + assert serialized_service['entrypoint'][0] == '$SHELL' + def test_serialize_unicode_values(self): cfg = { 'version': '2.3',