diff --git a/compose/service.py b/compose/service.py index 5a79414bc..e49acf0c6 100644 --- a/compose/service.py +++ b/compose/service.py @@ -757,9 +757,9 @@ class Service(object): if 'image' not in self.options: return - repo, tag = parse_repository_tag(self.options['image']) + repo, tag, separator = parse_repository_tag(self.options['image']) tag = tag or 'latest' - log.info('Pulling %s (%s:%s)...' % (self.name, repo, tag)) + log.info('Pulling %s (%s%s%s)...' % (self.name, repo, separator, tag)) output = self.client.pull( repo, tag=tag, @@ -780,14 +780,31 @@ def build_container_name(project, service, number, one_off=False): # Images +def parse_repository_tag(repo_path): + """Splits image identification into base image path, tag/digest + and it's separator. -def parse_repository_tag(s): - if ":" not in s: - return s, "" - repo, tag = s.rsplit(":", 1) - if "/" in tag: - return s, "" - return repo, tag + Example: + + >>> parse_repository_tag('user/repo@sha256:digest') + ('user/repo', 'sha256:digest', '@') + >>> parse_repository_tag('user/repo:v1') + ('user/repo', 'v1', ':') + """ + tag_separator = ":" + digest_separator = "@" + + if digest_separator in repo_path: + repo, tag = repo_path.rsplit(digest_separator, 1) + return repo, tag, digest_separator + + repo, tag = repo_path, "" + if tag_separator in repo_path: + repo, tag = repo_path.rsplit(tag_separator, 1) + if "/" in tag: + repo, tag = repo_path, "" + + return repo, tag, tag_separator # Volumes diff --git a/docs/yml.md b/docs/yml.md index 3e9a35ca4..bad9c9bc1 100644 --- a/docs/yml.md +++ b/docs/yml.md @@ -25,12 +25,13 @@ Values for configuration options can contain environment variables, e.g. ### image -Tag or partial image ID. Can be local or remote - Compose will attempt to +Tag, partial image ID or digest. Can be local or remote - Compose will attempt to pull if it doesn't exist locally. image: ubuntu image: orchardup/postgresql image: a4bc65fd + image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d ### build diff --git a/tests/fixtures/simple-composefile/digest.yml b/tests/fixtures/simple-composefile/digest.yml new file mode 100644 index 000000000..08f1d993e --- /dev/null +++ b/tests/fixtures/simple-composefile/digest.yml @@ -0,0 +1,6 @@ +simple: + image: busybox:latest + command: top +digest: + image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d + command: top diff --git a/tests/integration/cli_test.py b/tests/integration/cli_test.py index ef789e19c..a02e072fd 100644 --- a/tests/integration/cli_test.py +++ b/tests/integration/cli_test.py @@ -88,6 +88,12 @@ class CLITestCase(DockerClientTestCase): mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') mock_logging.info.assert_any_call('Pulling another (busybox:latest)...') + @patch('compose.service.log') + def test_pull_with_digest(self, mock_logging): + self.command.dispatch(['-f', 'digest.yml', 'pull'], None) + mock_logging.info.assert_any_call('Pulling simple (busybox:latest)...') + mock_logging.info.assert_any_call('Pulling digest (busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d)...') + @patch('sys.stdout', new_callable=StringIO) def test_build_no_cache(self, mock_stdout): self.command.base_dir = 'tests/fixtures/simple-dockerfile' diff --git a/tests/unit/service_test.py b/tests/unit/service_test.py index bb68c9aa6..8b39a63ef 100644 --- a/tests/unit/service_test.py +++ b/tests/unit/service_test.py @@ -192,6 +192,16 @@ class ServiceTest(unittest.TestCase): tag='latest', stream=True) + @mock.patch('compose.service.log', autospec=True) + def test_pull_image_digest(self, mock_log): + service = Service('foo', client=self.mock_client, image='someimage@sha256:1234') + service.pull() + self.mock_client.pull.assert_called_once_with( + 'someimage', + tag='sha256:1234', + stream=True) + mock_log.info.assert_called_once_with('Pulling foo (someimage@sha256:1234)...') + @mock.patch('compose.service.Container', autospec=True) def test_recreate_container(self, _): mock_container = mock.create_autospec(Container) @@ -217,12 +227,16 @@ class ServiceTest(unittest.TestCase): mock_container.stop.assert_called_once_with(timeout=1) def test_parse_repository_tag(self): - self.assertEqual(parse_repository_tag("root"), ("root", "")) - self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag")) - self.assertEqual(parse_repository_tag("user/repo"), ("user/repo", "")) - self.assertEqual(parse_repository_tag("user/repo:tag"), ("user/repo", "tag")) - self.assertEqual(parse_repository_tag("url:5000/repo"), ("url:5000/repo", "")) - self.assertEqual(parse_repository_tag("url:5000/repo:tag"), ("url:5000/repo", "tag")) + self.assertEqual(parse_repository_tag("root"), ("root", "", ":")) + self.assertEqual(parse_repository_tag("root:tag"), ("root", "tag", ":")) + self.assertEqual(parse_repository_tag("user/repo"), ("user/repo", "", ":")) + self.assertEqual(parse_repository_tag("user/repo:tag"), ("user/repo", "tag", ":")) + self.assertEqual(parse_repository_tag("url:5000/repo"), ("url:5000/repo", "", ":")) + self.assertEqual(parse_repository_tag("url:5000/repo:tag"), ("url:5000/repo", "tag", ":")) + + self.assertEqual(parse_repository_tag("root@sha256:digest"), ("root", "sha256:digest", "@")) + self.assertEqual(parse_repository_tag("user/repo@sha256:digest"), ("user/repo", "sha256:digest", "@")) + self.assertEqual(parse_repository_tag("url:5000/repo@sha256:digest"), ("url:5000/repo", "sha256:digest", "@")) @mock.patch('compose.service.Container', autospec=True) def test_create_container_latest_is_used_when_no_tag_specified(self, mock_container):