adds --no-interpolate to docker-compose config

Signed-off-by: Peter Nagy <pnagy@gratex.com>
This commit is contained in:
Peter Nagy (NPE) 2018-11-23 11:30:49 +01:00
parent b09d8802ed
commit e34d329227
5 changed files with 103 additions and 29 deletions

View File

@ -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') override_dir = options.get('--project-directory')
environment_file = options.get('--env-file') environment_file = options.get('--env-file')
environment = Environment.from_env_file(override_dir or project_dir, environment_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, environment=environment,
override_dir=override_dir, override_dir=override_dir,
compatibility=options.get('--compatibility'), 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) 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') override_dir = options.get('--project-directory')
environment_file = options.get('--env-file') environment_file = options.get('--env-file')
environment = Environment.from_env_file(override_dir or base_dir, environment_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( return config.load(
config.find(base_dir, config_path, environment, override_dir), 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, def get_project(project_dir, config_path=None, project_name=None, verbose=False,
host=None, tls_config=None, environment=None, override_dir=None, host=None, tls_config=None, environment=None, override_dir=None,
compatibility=False): compatibility=False, interpolate=True):
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)
project_name = get_project_name( project_name = get_project_name(
config_details.working_dir, project_name, environment 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( api_version = environment.get(
'COMPOSE_API_VERSION', 'COMPOSE_API_VERSION',

View File

@ -331,6 +331,7 @@ class TopLevelCommand(object):
Options: Options:
--resolve-image-digests Pin image tags to digests. --resolve-image-digests Pin image tags to digests.
--no-interpolate Don't interpolate environment variables
-q, --quiet Only validate the configuration, don't print -q, --quiet Only validate the configuration, don't print
anything. anything.
--services Print the service names, one per line. --services Print the service names, one per line.
@ -340,11 +341,12 @@ class TopLevelCommand(object):
or use the wildcard symbol to display all services 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 image_digests = None
if options['--resolve-image-digests']: 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): with errors.handle_connection_errors(self.project.client):
image_digests = image_digests_for_project(self.project) image_digests = image_digests_for_project(self.project)
@ -361,14 +363,14 @@ class TopLevelCommand(object):
if options['--hash'] is not None: if options['--hash'] is not None:
h = options['--hash'] 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 services = [svc for svc in options['--hash'].split(',')] if h != '*' else None
with errors.handle_connection_errors(self.project.client): with errors.handle_connection_errors(self.project.client):
for service in self.project.get_services(services): for service in self.project.get_services(services):
print('{} {}'.format(service.name, service.config_hash)) print('{} {}'.format(service.name, service.config_hash))
return return
print(serialize_config(compose_config, image_digests)) print(serialize_config(compose_config, image_digests, not options['--no-interpolate']))
def create(self, options): def create(self, options):
""" """

View File

@ -373,7 +373,7 @@ def check_swarm_only_config(service_dicts, compatibility=False):
check_swarm_only_key(service_dicts, 'configs') 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 """Load the configuration from a working directory and a list of
configuration files. Files are loaded in order, and merged on top configuration files. Files are loaded in order, and merged on top
of each other to create the final configuration. 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) validate_config_version(config_details.config_files)
processed_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 for config_file in config_details.config_files
] ]
config_details = config_details._replace(config_files=processed_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): def interpolate_config_section(config_file, config, section, environment):
validate_config_section(config_file.filename, config, section)
return interpolate_environment_variables( return interpolate_environment_variables(
config_file.version, config_file.version,
config, config,
@ -514,38 +513,60 @@ def interpolate_config_section(config_file, config, section, environment):
) )
def process_config_file(config_file, environment, service_name=None): def process_config_section(config_file, config, section, environment, interpolate):
services = interpolate_config_section( 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,
config_file.get_service_dicts(), config_file.get_service_dicts(),
'service', 'service',
environment) environment,
interpolate,
)
if config_file.version > V1: if config_file.version > V1:
processed_config = dict(config_file.config) processed_config = dict(config_file.config)
processed_config['services'] = services processed_config['services'] = services
processed_config['volumes'] = interpolate_config_section( processed_config['volumes'] = process_config_section(
config_file, config_file,
config_file.get_volumes(), config_file.get_volumes(),
'volume', 'volume',
environment) environment,
processed_config['networks'] = interpolate_config_section( interpolate,
)
processed_config['networks'] = process_config_section(
config_file, config_file,
config_file.get_networks(), config_file.get_networks(),
'network', 'network',
environment) environment,
interpolate,
)
if config_file.version >= const.COMPOSEFILE_V3_1: if config_file.version >= const.COMPOSEFILE_V3_1:
processed_config['secrets'] = interpolate_config_section( processed_config['secrets'] = process_config_section(
config_file, config_file,
config_file.get_secrets(), config_file.get_secrets(),
'secret', 'secret',
environment) environment,
interpolate,
)
if config_file.version >= const.COMPOSEFILE_V3_3: if config_file.version >= const.COMPOSEFILE_V3_3:
processed_config['configs'] = interpolate_config_section( processed_config['configs'] = process_config_section(
config_file, config_file,
config_file.get_configs(), config_file.get_configs(),
'config', 'config',
environment environment,
interpolate,
) )
else: else:
processed_config = services processed_config = services

View File

@ -24,14 +24,12 @@ def serialize_dict_type(dumper, data):
def serialize_string(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 representer = dumper.represent_str if six.PY3 else dumper.represent_unicode
if isinstance(data, six.binary_type): if isinstance(data, six.binary_type):
data = data.decode('utf-8') data = data.decode('utf-8')
data = data.replace('$', '$$')
if data.lower() in ('y', 'n', 'yes', 'no', 'on', 'off', 'true', 'false'): 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 # 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. # depending on which PyYaml version is being used. Err on safe side.
@ -39,6 +37,12 @@ def serialize_string(dumper, data):
return representer(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.MountSpec, serialize_dict_type)
yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type) yaml.SafeDumper.add_representer(types.VolumeFromSpec, serialize_config_type)
yaml.SafeDumper.add_representer(types.VolumeSpec, 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.ServiceSecret, serialize_dict_type)
yaml.SafeDumper.add_representer(types.ServiceConfig, 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(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): def denormalize_config(config, image_digests=None):
@ -93,7 +95,13 @@ def v3_introduced_name_key(key):
return V3_5 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( return yaml.safe_dump(
denormalize_config(config, image_digests), denormalize_config(config, image_digests),
default_flow_style=False, default_flow_style=False,

View File

@ -613,6 +613,25 @@ class ConfigTest(unittest.TestCase):
excinfo.exconly() 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): def test_config_integer_service_property_raise_validation_error(self):
with pytest.raises(ConfigurationError) as excinfo: with pytest.raises(ConfigurationError) as excinfo:
config.load( config.load(
@ -5328,6 +5347,28 @@ class SerializeTest(unittest.TestCase):
assert serialized_service['command'] == 'echo $$FOO' assert serialized_service['command'] == 'echo $$FOO'
assert serialized_service['entrypoint'][0] == '$$SHELL' 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): def test_serialize_unicode_values(self):
cfg = { cfg = {
'version': '2.3', 'version': '2.3',