diff --git a/compose/cli/main.py b/compose/cli/main.py index 63a0036b4..84cae9f53 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -263,43 +263,7 @@ class TopLevelCommand(object): if not output: output = "{}.dab".format(self.project.name) - with errors.handle_connection_errors(self.project.client): - try: - image_digests = get_image_digests( - self.project, - allow_push=options['--push-images'], - ) - except MissingDigests as e: - def list_images(images): - return "\n".join(" {}".format(name) for name in sorted(images)) - - paras = ["Some images are missing digests."] - - if e.needs_push: - command_hint = ( - "Use `docker-compose push {}` to push them. " - "You can do this automatically with `docker-compose bundle --push-images`." - .format(" ".join(sorted(e.needs_push))) - ) - paras += [ - "The following images can be pushed:", - list_images(e.needs_push), - command_hint, - ] - - if e.needs_pull: - command_hint = ( - "Use `docker-compose pull {}` to pull them. " - .format(" ".join(sorted(e.needs_pull))) - ) - - paras += [ - "The following images need to be pulled:", - list_images(e.needs_pull), - command_hint, - ] - - raise UserError("\n\n".join(paras)) + image_digests = image_digests_for_project(self.project, options['--push-images']) with open(output, 'w') as f: f.write(serialize_bundle(compose_config, image_digests)) @@ -313,13 +277,20 @@ class TopLevelCommand(object): Usage: config [options] Options: - -q, --quiet Only validate the configuration, don't print - anything. - --services Print the service names, one per line. - --volumes Print the volume names, one per line. + --resolve-image-digests Pin image tags to digests. + -q, --quiet Only validate the configuration, don't print + anything. + --services Print the service names, one per line. + --volumes Print the volume names, one per line. """ + compose_config = get_config_from_options(self.project_dir, config_options) + image_digests = None + + if options['--resolve-image-digests']: + self.project = project_from_options('.', config_options) + image_digests = image_digests_for_project(self.project) if options['--quiet']: return @@ -332,7 +303,7 @@ class TopLevelCommand(object): print('\n'.join(volume for volume in compose_config.volumes)) return - print(serialize_config(compose_config)) + print(serialize_config(compose_config, image_digests)) def create(self, options): """ @@ -1034,6 +1005,45 @@ def timeout_from_opts(options): return None if timeout is None else int(timeout) +def image_digests_for_project(project, allow_push=False): + with errors.handle_connection_errors(project.client): + try: + return get_image_digests( + project, + allow_push=allow_push + ) + except MissingDigests as e: + def list_images(images): + return "\n".join(" {}".format(name) for name in sorted(images)) + + paras = ["Some images are missing digests."] + + if e.needs_push: + command_hint = ( + "Use `docker-compose push {}` to push them. " + .format(" ".join(sorted(e.needs_push))) + ) + paras += [ + "The following images can be pushed:", + list_images(e.needs_push), + command_hint, + ] + + if e.needs_pull: + command_hint = ( + "Use `docker-compose pull {}` to pull them. " + .format(" ".join(sorted(e.needs_pull))) + ) + + paras += [ + "The following images need to be pulled:", + list_images(e.needs_pull), + command_hint, + ] + + raise UserError("\n\n".join(paras)) + + def exitval_from_opts(options, project): exit_value_from = options.get('--exit-code-from') if exit_value_from: diff --git a/compose/config/serialize.py b/compose/config/serialize.py index 1de1f14fb..5b36124d0 100644 --- a/compose/config/serialize.py +++ b/compose/config/serialize.py @@ -26,10 +26,13 @@ yaml.SafeDumper.add_representer(types.ServiceSecret, serialize_dict_type) yaml.SafeDumper.add_representer(types.ServicePort, serialize_dict_type) -def denormalize_config(config): +def denormalize_config(config, image_digests=None): result = {'version': V2_1 if config.version == V1 else config.version} denormalized_services = [ - denormalize_service_dict(service_dict, config.version) + denormalize_service_dict( + service_dict, + config.version, + image_digests[service_dict['name']] if image_digests else None) for service_dict in config.services ] result['services'] = { @@ -51,9 +54,9 @@ def denormalize_config(config): return result -def serialize_config(config): +def serialize_config(config, image_digests=None): return yaml.safe_dump( - denormalize_config(config), + denormalize_config(config, image_digests), default_flow_style=False, indent=2, width=80) @@ -78,9 +81,12 @@ def serialize_ns_time_value(value): return '{0}{1}'.format(*result) -def denormalize_service_dict(service_dict, version): +def denormalize_service_dict(service_dict, version, image_digest=None): service_dict = service_dict.copy() + if image_digest: + service_dict['image'] = image_digest + if 'restart' in service_dict: service_dict['restart'] = types.serialize_restart_spec( service_dict['restart'] diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py index c86485d7b..49da2b473 100644 --- a/tests/unit/config/config_test.py +++ b/tests/unit/config/config_test.py @@ -3654,6 +3654,25 @@ class SerializeTest(unittest.TestCase): assert denormalized_service['healthcheck']['interval'] == '100s' assert denormalized_service['healthcheck']['timeout'] == '30s' + def test_denormalize_image_has_digest(self): + service_dict = { + 'image': 'busybox' + } + image_digest = 'busybox@sha256:abcde' + + assert denormalize_service_dict(service_dict, V3_0, image_digest) == { + 'image': 'busybox@sha256:abcde' + } + + def test_denormalize_image_no_digest(self): + service_dict = { + 'image': 'busybox' + } + + assert denormalize_service_dict(service_dict, V3_0) == { + 'image': 'busybox' + } + def test_serialize_secrets(self): service_dict = { 'image': 'example/web',