├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docker-registry-show.py ├── docker_registry_client ├── AuthorizationService.py ├── DockerRegistryClient.py ├── Image.py ├── Repository.py ├── _BaseClient.py ├── __init__.py └── manifest.py ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── drc_test_utils │ ├── __init__.py │ └── mock_registry.py ├── integration │ ├── __init__.py │ ├── fixtures │ │ └── base │ │ │ ├── Dockerfile │ │ │ └── base.txt │ └── test_base_client.py ├── placeholder.txt ├── test_base_client.py ├── test_dockerregistryclient.py ├── test_image.py ├── test_init.py └── test_repository.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python 2 | 3 | ### Python ### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *,cover 50 | .hypothesis/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv/ 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # End of https://www.gitignore.io/api/python 97 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: true 3 | services: 4 | - docker 5 | python: 6 | - "2.7" 7 | - "3.5" 8 | - "3.6" 9 | install: pip install tox-travis 10 | script: tox 11 | cache: pip 12 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 0.5.2 (unreleased) 2 | ------------------ 3 | 4 | - Fix for "AttributeError: 'list' object has no attribute 'keys'" 5 | (`Issue #41 `_) 6 | - Added usage docs inside README.rst 7 | (`Issue #39 `_) 8 | (`Issue #45 `_) 9 | - Remove error logging when exception raised. 10 | (`Issue #37 `_) 11 | 12 | 13 | 0.5.1 (2017-01-12) 14 | ------------------ 15 | 16 | - Fixes to release process with zest 17 | 18 | 0.5.0 (2017-01-12) 19 | ------------------ 20 | 21 | - First version of docker-registry-client with changelog 22 | - Support get and push manifest on protocol v2, schema v1. 23 | (`Issue #33 `_) 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ## Code Style 2 | 3 | Follow pep8 and check style with pyflakes 4 | 5 | ## Testing 6 | 7 | Tests are run with `py.test tests`. Please write tests for new code. 8 | 9 | ## CI 10 | 11 | We use travis for CI. The build must be passing in order to merge a pull request. 12 | 13 | ## Deploying 14 | 15 | docker-registry-client is deployed to [pypi](https://pypi.python.org/pypi/docker-registry-client/) 16 | 17 | Use zest.releaser to publish new releases 18 | 19 | mkdir ~/.virtualenvs/ 20 | python3 -m venv ~/.virtualenvs/releaser 21 | source ~/.virtualenvs/releaser/bin/activate 22 | pip install -U pip setuptools wheel 23 | pip install zest.releaser 24 | fullrelease 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2015 Yodle, Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include README.rst 3 | 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Docker Registry Client 2 | ====================== 3 | 4 | |Build Status| |pypi| 5 | 6 | A Python REST client for the Docker Registry 7 | 8 | It's useful for automating image tagging and untagging 9 | 10 | .. |Build Status| image:: https://travis-ci.org/yodle/docker-registry-client.svg?branch=master 11 | :target: https://travis-ci.org/yodle/docker-registry-client 12 | :alt: Build status 13 | 14 | .. |pypi| image:: https://img.shields.io/pypi/v/docker-registry-client.svg 15 | :target: https://pypi.python.org/pypi/docker-registry-client 16 | :alt: Latest version released on PyPI 17 | 18 | Usage 19 | ----- 20 | 21 | The API provides several classes: ``DockerRegistryClient``, ``Repository``, and ``Image``. 22 | 23 | ``DockerRegistryClient`` has the following methods: 24 | 25 | - ``namespaces()`` -> a list of all namespaces in the registry 26 | - ``repository(repository_name, namespace)`` -> the corresponding repository object 27 | - ``repositories()`` -> all repositories in the registry 28 | 29 | ``Repository`` has the following methods: 30 | 31 | - ``tags()`` -> a list of all tags in the repository 32 | - ``data(tag)`` -> json data associated with ``tag`` 33 | - ``image(tag)`` -> the image associated with ``tag`` 34 | - ``untag(tag)`` -> remove ``tag`` from the repository 35 | - ``tag(tag, image_id)`` -> apply ``tag`` to ``image_id`` 36 | 37 | ``Image`` has the following methods: 38 | 39 | - ``get_layer()`` -> binary layer data for image 40 | - ``get_json()`` -> json metadata for image 41 | - ``get_data(field)`` -> single field from json data 42 | - ``ancestry()`` -> ids for image ancestors 43 | 44 | Alternatives 45 | ------------ 46 | 47 | * `python-dxf `_ (only supports V2) 48 | -------------------------------------------------------------------------------- /docker-registry-show.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2015 Red Hat, Inc 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | """ 16 | 17 | 18 | from __future__ import absolute_import 19 | 20 | import argparse 21 | from docker_registry_client import DockerRegistryClient 22 | import json 23 | import logging 24 | import requests 25 | 26 | 27 | class CLI(object): 28 | def __init__(self): 29 | self.parser = argparse.ArgumentParser() 30 | excl_group = self.parser.add_mutually_exclusive_group() 31 | excl_group.add_argument("-q", "--quiet", action="store_true") 32 | excl_group.add_argument("-v", "--verbose", action="store_true") 33 | 34 | self.parser.add_argument('--verify-ssl', dest='verify_ssl', 35 | action='store_true') 36 | self.parser.add_argument('--no-verify-ssl', dest='verify_ssl', 37 | action='store_false') 38 | self.parser.add_argument('--api-version', metavar='VER', type=int) 39 | self.parser.add_argument('--username', metavar='USERNAME') 40 | self.parser.add_argument('--password', metavar='PASSWORD') 41 | 42 | self.parser.add_argument('--authorization-service', metavar='AUTH_SERVICE', type=str, 43 | help='authorization service URL (including scheme) (for registry v2 only)') 44 | 45 | self.parser.add_argument('registry', metavar='REGISTRY', nargs=1, 46 | help='registry URL (including scheme)') 47 | self.parser.add_argument('repository', metavar='REPOSITORY', nargs='?', 48 | help='repository (including namespace)') 49 | self.parser.add_argument('ref', metavar='REF', nargs='?', 50 | help='tag or digest') 51 | 52 | self.parser.set_defaults(verify_ssl=True, api_version=None) 53 | 54 | def run(self): 55 | args = self.parser.parse_args() 56 | 57 | basic_config_args = {} 58 | if args.verbose: 59 | basic_config_args['level'] = logging.DEBUG 60 | elif args.quiet: 61 | basic_config_args['level'] = logging.WARNING 62 | 63 | logging.basicConfig(**basic_config_args) 64 | 65 | kwargs = { 66 | 'username': args.username, 67 | 'password': args.password, 68 | } 69 | 70 | if args.api_version: 71 | kwargs['api_version'] = args.api_version 72 | 73 | client = DockerRegistryClient(args.registry[0], 74 | auth_service_url=args.authorization_service, 75 | verify_ssl=args.verify_ssl, 76 | **kwargs) 77 | 78 | if args.repository: 79 | if args.ref: 80 | self.show_manifest(client, args.repository, args.ref) 81 | else: 82 | self.show_tags(client, args.repository) 83 | else: 84 | self.show_repositories(client) 85 | 86 | def show_repositories(self, client): 87 | try: 88 | repositories = client.repositories() 89 | except requests.HTTPError as e: 90 | if e.response.status_code == requests.codes.not_found: 91 | print("Catalog/Search not supported") 92 | else: 93 | raise 94 | else: 95 | print("Repositories:") 96 | for repository in repositories.keys(): 97 | print(" - {0}".format(repository)) 98 | 99 | def show_tags(self, client, repository): 100 | try: 101 | repo = client.repository(repository) 102 | except requests.HTTPError as e: 103 | if e.response.status_code == requests.codes.not_found: 104 | print("Repository {0} not found".format(repository)) 105 | else: 106 | raise 107 | else: 108 | print("Tags in repository {0}:".format(repository)) 109 | for tag in repo.tags(): 110 | print(" - {0}".format(tag)) 111 | 112 | def show_manifest(self, client, repository, ref): 113 | try: 114 | repo = client.repository(repository) 115 | except requests.HTTPError as e: 116 | if e.response.status_code == requests.codes.not_found: 117 | print("Repository {0} not found".format(repository)) 118 | else: 119 | raise 120 | else: 121 | assert client.api_version in [1, 2] 122 | if client.api_version == 2: 123 | manifest, digest = repo.manifest(ref) 124 | print("Digest: {0}".format(digest)) 125 | print("Manifest:") 126 | print(json.dumps(manifest, indent=2, sort_keys=True)) 127 | else: 128 | image = repo.image(ref) 129 | image_json = image.get_json() 130 | print("Image ID: {0}".format(image.image_id)) 131 | print("Image JSON:") 132 | print(json.dumps(image_json, indent=2, sort_keys=True)) 133 | 134 | 135 | if __name__ == '__main__': 136 | try: 137 | cli = CLI() 138 | cli.run() 139 | except KeyboardInterrupt: 140 | pass 141 | -------------------------------------------------------------------------------- /docker_registry_client/AuthorizationService.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib.parse import urlsplit 3 | except ImportError: 4 | from urlparse import urlsplit 5 | # import urlparse 6 | import requests 7 | import logging 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class AuthorizationService(object): 13 | """This class implements a Authorization Service for Docker registry v2. 14 | 15 | Specification can be found here : 16 | https://github.com/docker/distribution/blob/master/docs/spec/auth/token.md 17 | 18 | The idea is to delegate authentication to a third party and use a token to 19 | authenticate to the registry. Token has to be renew each time we change 20 | "scope". 21 | """ 22 | def __init__(self, registry, url="", auth=None, verify=False, 23 | api_timeout=None): 24 | # Registry ip:port 25 | self.registry = urlsplit(registry).netloc 26 | # Service url, ip:port 27 | self.url = url 28 | # Authentication (user, password) or None. Used by request to do 29 | # basicauth 30 | self.auth = auth 31 | # Timeout for HTTP request 32 | self.api_timeout = api_timeout 33 | 34 | # Desired scope is the scope needed for the next operation on the 35 | # registry 36 | self.desired_scope = "" 37 | # Scope of the token we have 38 | self.scope = "" 39 | # Token used to authenticate 40 | self.token = "" 41 | # Boolean to enfore https checks. Used by request 42 | self.verify = verify 43 | 44 | # If we have no url then token are not required. get_new_token will not 45 | # be called 46 | if url: 47 | split = urlsplit(url) 48 | # user in url will take precedence over giver username 49 | if split.username and split.password: 50 | self.auth = (split.username, split.password) 51 | 52 | self.token_required = True 53 | else: 54 | self.token_required = False 55 | 56 | def get_new_token(self): 57 | rsp = requests.get("%s/v2/token?service=%s&scope=%s" % 58 | (self.url, self.registry, self.desired_scope), 59 | auth=self.auth, verify=self.verify, 60 | timeout=self.api_timeout) 61 | if not rsp.ok: 62 | logger.error("Can't get token for authentication") 63 | self.token = "" 64 | 65 | self.token = rsp.json()['token'] 66 | # We managed to get a new token, update the current scope to the one we 67 | # wanted 68 | self.scope = self.desired_scope 69 | -------------------------------------------------------------------------------- /docker_registry_client/DockerRegistryClient.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from ._BaseClient import BaseClient 4 | from .Repository import Repository 5 | 6 | 7 | class DockerRegistryClient(object): 8 | def __init__(self, host, verify_ssl=None, api_version=None, username=None, 9 | password=None, auth_service_url="", api_timeout=None): 10 | """ 11 | Constructor 12 | 13 | :param host: str, registry URL including scheme 14 | :param verify_ssl: bool, whether to verify SSL certificate 15 | :param api_version: int, API version to require 16 | :param username: username to use for basic authentication when 17 | connecting to the registry 18 | :param password: password to use for basic authentication 19 | :param auth_service_url: authorization service URL (including scheme, 20 | for v2 only) 21 | :param api_timeout: timeout for external request 22 | """ 23 | 24 | self._base_client = BaseClient(host, verify_ssl=verify_ssl, 25 | api_version=api_version, 26 | username=username, password=password, 27 | auth_service_url=auth_service_url, 28 | api_timeout=api_timeout) 29 | self.api_version = self._base_client.version 30 | self._repositories = {} 31 | self._repositories_by_namespace = {} 32 | 33 | def namespaces(self): 34 | if not self._repositories: 35 | self.refresh() 36 | 37 | return list(self._repositories_by_namespace.keys()) 38 | 39 | def repository(self, repository, namespace=None): 40 | if '/' in repository: 41 | if namespace is not None: 42 | raise RuntimeError('cannot specify namespace twice') 43 | namespace, repository = repository.split('/', 1) 44 | 45 | return Repository(self._base_client, repository, namespace=namespace) 46 | 47 | def repositories(self, namespace=None): 48 | if not self._repositories: 49 | self.refresh() 50 | 51 | if namespace: 52 | return self._repositories_by_namespace[namespace] 53 | 54 | return self._repositories 55 | 56 | def refresh(self): 57 | if self._base_client.version == 1: 58 | self._refresh_v1() 59 | else: 60 | assert self._base_client.version == 2 61 | self._refresh_v2() 62 | 63 | def _refresh_v1(self): 64 | _repositories = self._base_client.search()['results'] 65 | for repository in _repositories: 66 | name = repository['name'] 67 | ns, repo = name.split('/', 1) 68 | 69 | r = Repository(self._base_client, repo, namespace=ns) 70 | self._repositories_by_namespace.setdefault(ns, {}) 71 | self._repositories_by_namespace[ns][name] = r 72 | self._repositories[name] = r 73 | 74 | def _refresh_v2(self): 75 | repositories = self._base_client.catalog()['repositories'] 76 | for name in repositories: 77 | try: 78 | ns, repo = name.split('/', 1) 79 | except ValueError: 80 | ns = None 81 | repo = name 82 | 83 | r = Repository(self._base_client, repo, namespace=ns) 84 | 85 | if ns is None: 86 | ns = 'library' 87 | 88 | self._repositories_by_namespace.setdefault(ns, {}) 89 | self._repositories_by_namespace[ns][name] = r 90 | self._repositories[name] = r 91 | -------------------------------------------------------------------------------- /docker_registry_client/Image.py: -------------------------------------------------------------------------------- 1 | class ImageV1(object): 2 | def __init__(self, client, image_id): 3 | self.image_id = image_id 4 | self._client = client 5 | 6 | def get_layer(self): 7 | return self._client.get_images_layer(self.image_id) 8 | 9 | def put_layer(self, data): 10 | # return self._client.put_images_layer(self.image_id, data) 11 | raise NotImplementedError() 12 | 13 | def get_json(self): 14 | return self._client.get_image_layer(self.image_id) 15 | 16 | def get_data(self, field): 17 | return self.get_json()[field] 18 | 19 | def put_json(self, data): 20 | # return self._client.put_image_layer(self.image_id, data) 21 | raise NotImplementedError() 22 | 23 | def ancestry(self): 24 | return self._client.get_image_ancestry(self.image_id) 25 | 26 | 27 | def Image(image_id, client): 28 | assert client.version == 1 29 | return ImageV1(client, image_id) 30 | -------------------------------------------------------------------------------- /docker_registry_client/Repository.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from .Image import Image 4 | 5 | 6 | class BaseRepository(object): 7 | def __init__(self, client, repository, namespace=None): 8 | self._client = client 9 | self.repository = repository 10 | self.namespace = namespace 11 | 12 | @property 13 | def name(self): 14 | if self.namespace: 15 | return "{self.namespace}/{self.repository}".format(self=self) 16 | return self.repository 17 | 18 | 19 | class RepositoryV1(BaseRepository): 20 | def __init__(self, client, repository, namespace=None): 21 | if namespace is None: 22 | namespace = 'library' 23 | 24 | super(RepositoryV1, self).__init__(client, repository, 25 | namespace=namespace) 26 | self._images = None 27 | 28 | def __repr__(self): 29 | return 'RepositoryV1({name})'.format(name=self.name) 30 | 31 | def refresh(self): 32 | self._images = self._client.get_repository_tags(self.namespace, 33 | self.repository) 34 | 35 | def tags(self): 36 | if self._images is None: 37 | self.refresh() 38 | 39 | if type(self._images) is list: 40 | return list(taginfo['name'] for taginfo in self._images) 41 | else: 42 | return list(self._images.keys()) 43 | 44 | def data(self, tag): 45 | return self._client.get_tag_json(self.namespace, self.repository, tag) 46 | 47 | def image(self, tag): 48 | if self._images is None: 49 | self.refresh() 50 | 51 | image_id = self._images[tag] 52 | return Image(image_id, self._client) 53 | 54 | def untag(self, tag): 55 | return self._client.delete_repository_tag(self.namespace, 56 | self.repository, tag) 57 | 58 | def tag(self, tag, image_id): 59 | return self._client.set_tag(self.namespace, self.repository, 60 | tag, image_id) 61 | 62 | def delete_repository(self): 63 | # self._client.delete_repository(self.namespace, self.repository) 64 | raise NotImplementedError() 65 | 66 | 67 | class RepositoryV2(BaseRepository): 68 | def __init__(self, client, repository, namespace=None): 69 | super(RepositoryV2, self).__init__(client, repository, 70 | namespace=namespace) 71 | self._tags = None 72 | 73 | def __repr__(self): 74 | return 'RepositoryV2({name})'.format(name=self.name) 75 | 76 | def tags(self): 77 | if self._tags is None: 78 | self.refresh() 79 | 80 | return self._tags 81 | 82 | def manifest(self, tag): 83 | """ 84 | Return a tuple, (manifest, digest), for a given tag 85 | """ 86 | return self._client.get_manifest_and_digest(self.name, tag) 87 | 88 | def delete_manifest(self, digest): 89 | return self._client.delete_manifest(self.name, digest) 90 | 91 | def refresh(self): 92 | response = self._client.get_repository_tags(self.name) 93 | self._tags = response['tags'] 94 | 95 | 96 | def Repository(client, *args, **kwargs): 97 | if client.version == 1: 98 | return RepositoryV1(client, *args, **kwargs) 99 | else: 100 | assert client.version == 2 101 | return RepositoryV2(client, *args, **kwargs) 102 | -------------------------------------------------------------------------------- /docker_registry_client/_BaseClient.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from requests import get, put, delete 3 | from requests.exceptions import HTTPError 4 | import json 5 | from .AuthorizationService import AuthorizationService 6 | from .manifest import sign as sign_manifest 7 | 8 | # urllib3 throws some ssl warnings with older versions of python 9 | # they're probably ok for the registry client to ignore 10 | import warnings 11 | warnings.filterwarnings("ignore") 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class CommonBaseClient(object): 18 | def __init__(self, host, verify_ssl=None, username=None, password=None, 19 | api_timeout=None): 20 | self.host = host 21 | 22 | self.method_kwargs = {} 23 | if verify_ssl is not None: 24 | self.method_kwargs['verify'] = verify_ssl 25 | if username is not None and password is not None: 26 | self.method_kwargs['auth'] = (username, password) 27 | if api_timeout is not None: 28 | self.method_kwargs['timeout'] = api_timeout 29 | 30 | def _http_response(self, url, method, data=None, **kwargs): 31 | """url -> full target url 32 | method -> method from requests 33 | data -> request body 34 | kwargs -> url formatting args 35 | """ 36 | header = {'content-type': 'application/json'} 37 | 38 | if data: 39 | data = json.dumps(data) 40 | path = url.format(**kwargs) 41 | logger.debug("%s %s", method.__name__.upper(), path) 42 | response = method(self.host + path, 43 | data=data, headers=header, **self.method_kwargs) 44 | logger.debug("%s %s", response.status_code, response.reason) 45 | response.raise_for_status() 46 | 47 | return response 48 | 49 | def _http_call(self, url, method, data=None, **kwargs): 50 | """url -> full target url 51 | method -> method from requests 52 | data -> request body 53 | kwargs -> url formatting args 54 | """ 55 | response = self._http_response(url, method, data=data, **kwargs) 56 | if not response.content: 57 | return {} 58 | 59 | return response.json() 60 | 61 | 62 | class BaseClientV1(CommonBaseClient): 63 | IMAGE_LAYER = '/v1/images/{image_id}/layer' 64 | IMAGE_JSON = '/v1/images/{image_id}/json' 65 | IMAGE_ANCESTRY = '/v1/images/{image_id}/ancestry' 66 | REPO = '/v1/repositories/{namespace}/{repository}' 67 | TAGS = REPO + '/tags' 68 | 69 | @property 70 | def version(self): 71 | return 1 72 | 73 | def search(self, q=''): 74 | """GET /v1/search""" 75 | if q: 76 | q = '?q=' + q 77 | return self._http_call('/v1/search' + q, get) 78 | 79 | def check_status(self): 80 | """GET /v1/_ping""" 81 | return self._http_call('/v1/_ping', get) 82 | 83 | def get_images_layer(self, image_id): 84 | """GET /v1/images/{image_id}/layer""" 85 | return self._http_call(self.IMAGE_LAYER, get, image_id=image_id) 86 | 87 | def put_images_layer(self, image_id, data): 88 | """PUT /v1/images/(image_id)/layer""" 89 | return self._http_call(self.IMAGE_LAYER, put, 90 | image_id=image_id, data=data) 91 | 92 | def put_image_layer(self, image_id, data): 93 | """PUT /v1/images/(image_id)/json""" 94 | return self._http_call(self.IMAGE_JSON, put, 95 | data=data, image_id=image_id) 96 | 97 | def get_image_layer(self, image_id): 98 | """GET /v1/images/(image_id)/json""" 99 | return self._http_call(self.IMAGE_JSON, get, image_id=image_id) 100 | 101 | def get_image_ancestry(self, image_id): 102 | """GET /v1/images/(image_id)/ancestry""" 103 | return self._http_call(self.IMAGE_ANCESTRY, get, image_id=image_id) 104 | 105 | def get_repository_tags(self, namespace, repository): 106 | """GET /v1/repositories/(namespace)/(repository)/tags""" 107 | return self._http_call(self.TAGS, get, 108 | namespace=namespace, repository=repository) 109 | 110 | def get_image_id(self, namespace, respository, tag): 111 | """GET /v1/repositories/(namespace)/(repository)/tags/(tag*)""" 112 | return self._http_call(self.TAGS + '/' + tag, get, 113 | namespace=namespace, repository=respository) 114 | 115 | def get_tag_json(self, namespace, repository, tag): 116 | """GET /v1/repositories(namespace)/(repository)tags(tag*)/json""" 117 | return self._http_call(self.TAGS + '/' + tag + '/json', get, 118 | namespace=namespace, repository=repository) 119 | 120 | def delete_repository_tag(self, namespace, repository, tag): 121 | """DELETE /v1/repositories/(namespace)/(repository)/tags/(tag*)""" 122 | return self._http_call(self.TAGS + '/' + tag, delete, 123 | namespace=namespace, repository=repository) 124 | 125 | def set_tag(self, namespace, repository, tag, image_id): 126 | """PUT /v1/repositories/(namespace)/(repository)/tags/(tag*)""" 127 | return self._http_call(self.TAGS + '/' + tag, put, data=image_id, 128 | namespace=namespace, repository=repository) 129 | 130 | def delete_repository(self, namespace, repository): 131 | """DELETE /v1/repositories/(namespace)/(repository)/""" 132 | return self._http_call(self.REPO, delete, 133 | namespace=namespace, repository=repository) 134 | 135 | 136 | class _Manifest(object): 137 | def __init__(self, content, type, digest): 138 | self._content = content 139 | self._type = type 140 | self._digest = digest 141 | 142 | 143 | BASE_CONTENT_TYPE = 'application/vnd.docker.distribution.manifest' 144 | 145 | 146 | class BaseClientV2(CommonBaseClient): 147 | LIST_TAGS = '/v2/{name}/tags/list' 148 | MANIFEST = '/v2/{name}/manifests/{reference}' 149 | BLOB = '/v2/{name}/blobs/{digest}' 150 | schema_1_signed = BASE_CONTENT_TYPE + '.v1+prettyjws' 151 | schema_1 = BASE_CONTENT_TYPE + '.v1+json' 152 | schema_2 = BASE_CONTENT_TYPE + '.v2+json' 153 | 154 | def __init__(self, *args, **kwargs): 155 | auth_service_url = kwargs.pop("auth_service_url", "") 156 | super(BaseClientV2, self).__init__(*args, **kwargs) 157 | self._manifest_digests = {} 158 | self.auth = AuthorizationService( 159 | registry=self.host, 160 | url=auth_service_url, 161 | verify=self.method_kwargs.get('verify', False), 162 | auth=self.method_kwargs.get('auth', None), 163 | api_timeout=self.method_kwargs.get('api_timeout') 164 | ) 165 | 166 | @property 167 | def version(self): 168 | return 2 169 | 170 | def check_status(self): 171 | self.auth.desired_scope = 'registry:catalog:*' 172 | return self._http_call('/v2/', get) 173 | 174 | def catalog(self): 175 | self.auth.desired_scope = 'registry:catalog:*' 176 | return self._http_call('/v2/_catalog', get) 177 | 178 | def get_repository_tags(self, name): 179 | self.auth.desired_scope = 'repository:%s:*' % name 180 | return self._http_call(self.LIST_TAGS, get, name=name) 181 | 182 | def get_manifest_and_digest(self, name, reference): 183 | m = self.get_manifest(name, reference) 184 | return m._content, m._digest 185 | 186 | def get_manifest(self, name, reference): 187 | self.auth.desired_scope = 'repository:%s:*' % name 188 | response = self._http_response( 189 | self.MANIFEST, get, name=name, reference=reference, 190 | schema=self.schema_1_signed, 191 | ) 192 | self._cache_manifest_digest(name, reference, response=response) 193 | return _Manifest( 194 | content=response.json(), 195 | type=response.headers.get('Content-Type', 'application/json'), 196 | digest=self._manifest_digests[name, reference], 197 | ) 198 | 199 | def put_manifest(self, name, reference, manifest): 200 | self.auth.desired_scope = 'repository:%s:*' % name 201 | content = {} 202 | content.update(manifest._content) 203 | content.update({'name': name, 'tag': reference}) 204 | 205 | return self._http_call( 206 | self.MANIFEST, put, data=sign_manifest(content), 207 | content_type=self.schema_1_signed, schema=self.schema_1_signed, 208 | name=name, reference=reference, 209 | ) 210 | 211 | def delete_manifest(self, name, digest): 212 | self.auth.desired_scope = 'repository:%s:*' % name 213 | return self._http_call(self.MANIFEST, delete, 214 | name=name, reference=digest) 215 | 216 | def delete_blob(self, name, digest): 217 | self.auth.desired_scope = 'repository:%s:*' % name 218 | return self._http_call(self.BLOB, delete, 219 | name=name, digest=digest) 220 | 221 | def _cache_manifest_digest(self, name, reference, response=None): 222 | if not response: 223 | # TODO: create our own digest 224 | raise NotImplementedError() 225 | 226 | untrusted_digest = response.headers.get('Docker-Content-Digest') 227 | self._manifest_digests[(name, reference)] = untrusted_digest 228 | 229 | def _http_response(self, url, method, data=None, content_type=None, 230 | schema=None, **kwargs): 231 | """url -> full target url 232 | method -> method from requests 233 | data -> request body 234 | kwargs -> url formatting args 235 | """ 236 | 237 | if schema is None: 238 | schema = self.schema_2 239 | 240 | header = { 241 | 'content-type': content_type or 'application/json', 242 | 'Accept': schema, 243 | } 244 | 245 | # Token specific part. We add the token in the header if necessary 246 | auth = self.auth 247 | token_required = auth.token_required 248 | token = auth.token 249 | desired_scope = auth.desired_scope 250 | scope = auth.scope 251 | 252 | if token_required: 253 | if not token or desired_scope != scope: 254 | logger.debug("Getting new token for scope: %s", desired_scope) 255 | auth.get_new_token() 256 | 257 | header['Authorization'] = 'Bearer %s' % self.auth.token 258 | 259 | if data and not content_type: 260 | data = json.dumps(data) 261 | 262 | path = url.format(**kwargs) 263 | logger.debug("%s %s", method.__name__.upper(), path) 264 | response = method(self.host + path, 265 | data=data, headers=header, **self.method_kwargs) 266 | logger.debug("%s %s", response.status_code, response.reason) 267 | response.raise_for_status() 268 | 269 | return response 270 | 271 | 272 | def BaseClient(host, verify_ssl=None, api_version=None, username=None, 273 | password=None, auth_service_url="", api_timeout=None): 274 | if api_version == 1: 275 | return BaseClientV1( 276 | host, verify_ssl=verify_ssl, username=username, password=password, 277 | api_timeout=api_timeout, 278 | ) 279 | elif api_version == 2: 280 | return BaseClientV2( 281 | host, verify_ssl=verify_ssl, username=username, password=password, 282 | auth_service_url=auth_service_url, api_timeout=api_timeout, 283 | ) 284 | elif api_version is None: 285 | # Try V2 first 286 | logger.debug("checking for v2 API") 287 | v2_client = BaseClientV2( 288 | host, verify_ssl=verify_ssl, username=username, password=password, 289 | auth_service_url=auth_service_url, api_timeout=api_timeout, 290 | ) 291 | try: 292 | v2_client.check_status() 293 | except HTTPError as e: 294 | if e.response.status_code == 404: 295 | logger.debug("falling back to v1 API") 296 | return BaseClientV1( 297 | host, verify_ssl=verify_ssl, username=username, 298 | password=password, api_timeout=api_timeout, 299 | ) 300 | 301 | raise 302 | else: 303 | logger.debug("using v2 API") 304 | return v2_client 305 | else: 306 | raise RuntimeError('invalid api_version') 307 | -------------------------------------------------------------------------------- /docker_registry_client/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from __future__ import absolute_import 4 | 5 | from .DockerRegistryClient import (DockerRegistryClient, BaseClient, Repository) 6 | -------------------------------------------------------------------------------- /docker_registry_client/manifest.py: -------------------------------------------------------------------------------- 1 | # Extracted from python-dxf (https://git.io/vM0EB) used under license (MIT). 2 | import base64 3 | import ecdsa 4 | import jws 5 | import json 6 | 7 | 8 | def assign(obj, *objs): 9 | for o in objs: 10 | obj.update(o) 11 | return obj 12 | 13 | 14 | def force_bytes(s, encoding='utf8'): 15 | return s if isinstance(s, bytes) else s.encode(encoding) 16 | 17 | 18 | def _urlsafe_b64encode_bytes(b): 19 | return base64.urlsafe_b64encode(b).rstrip(b'=').decode('utf-8') 20 | 21 | 22 | def _urlsafe_b64encode(s): 23 | return _urlsafe_b64encode_bytes(force_bytes(s)) 24 | 25 | 26 | jws.utils.to_bytes_2and3 = force_bytes 27 | jws.algos.to_bytes_2and3 = force_bytes 28 | 29 | 30 | def _num_to_base64(n): 31 | b = bytearray() 32 | while n: 33 | b.insert(0, n & 0xFF) 34 | n >>= 8 35 | # need to pad to 32 bytes 36 | while len(b) < 32: 37 | b.insert(0, 0) 38 | return _urlsafe_b64encode_bytes(b) 39 | 40 | 41 | def sign(manifest, key=None): 42 | m = assign({}, manifest) 43 | 44 | try: 45 | del m['signatures'] 46 | except KeyError: 47 | pass 48 | 49 | assert len(m) > 0 50 | 51 | manifest_json = json.dumps(m, sort_keys=True) 52 | 53 | if key is None: 54 | key = ecdsa.SigningKey.generate(curve=ecdsa.NIST256p) 55 | 56 | manifest64 = _urlsafe_b64encode(manifest_json) 57 | format_length = manifest_json.rfind('}') 58 | format_tail = manifest_json[format_length:] 59 | protected_json = json.dumps({ 60 | 'formatLength': format_length, 61 | 'formatTail': _urlsafe_b64encode(format_tail) 62 | }) 63 | protected64 = _urlsafe_b64encode(protected_json) 64 | point = key.privkey.public_key.point 65 | data = { 66 | 'key': key, 67 | 'header': { 68 | 'alg': 'ES256' 69 | } 70 | } 71 | jws.header.process(data, 'sign') 72 | sig = data['signer']("%s.%s" % (protected64, manifest64), key) 73 | signatures = [{ 74 | 'header': { 75 | 'jwk': { 76 | 'kty': 'EC', 77 | 'crv': 'P-256', 78 | 'x': _num_to_base64(point.x()), 79 | 'y': _num_to_base64(point.y()) 80 | }, 81 | 'alg': 'ES256' 82 | }, 83 | 'signature': _urlsafe_b64encode(sig), 84 | 'protected': protected64 85 | }] 86 | return ( 87 | manifest_json[:format_length] + 88 | ', "signatures": ' + 89 | json.dumps(signatures) + 90 | format_tail 91 | ) 92 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [zest.releaser] 5 | create-wheel = yes 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | readme = open('README.rst').read() 4 | history = open('CHANGES.rst').read().replace('.. :changelog:', '') 5 | 6 | setup( 7 | name="docker-registry-client", 8 | version='0.5.2.dev0', 9 | description='Client for Docker Registry V1 and V2', 10 | long_description=readme + '\n\n' + history, 11 | author='John Downs', 12 | author_email='john.downs@yodle.com', 13 | url='https://github.com/yodle/docker-registry-client', 14 | license="Apache License 2.0", 15 | classifiers=[ 16 | 'Development Status :: 3 - Alpha', 17 | 'Intended Audience :: Developers', 18 | 'Topic :: System :: Software Distribution', 19 | 'License :: OSI Approved :: Apache Software License', 20 | 'Programming Language :: Python :: 2', 21 | 'Programming Language :: Python :: 2.7', 22 | 'Programming Language :: Python :: 3', 23 | 'Programming Language :: Python :: 3.5', 24 | 'Programming Language :: Python :: 3.6', 25 | ], 26 | keywords='docker docker-registry REST', 27 | packages=find_packages(), 28 | install_requires=[ 29 | 'requests>=2.4.3, <3.0.0', 30 | 'ecdsa>=0.13.0, <0.14.0', 31 | 'jws>=0.1.3, <0.2.0', 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import time 4 | 5 | import requests 6 | from requests import exceptions 7 | import docker 8 | import pytest 9 | from docker import utils as docker_utils 10 | 11 | 12 | @pytest.fixture(scope='session') 13 | def docker_client(): 14 | client_cfg = docker_utils.kwargs_from_env() 15 | return docker.Client(version='1.21', **client_cfg) 16 | 17 | 18 | def wait_till_up(url, attempts): 19 | for i in range(attempts-1): 20 | try: 21 | requests.get(url) 22 | return 23 | except exceptions.ConnectionError as e: 24 | time.sleep(0.1 * 2**i) 25 | else: 26 | requests.get(url) 27 | 28 | 29 | @pytest.yield_fixture() 30 | def registry(docker_client): 31 | cli = docker_client 32 | cli.pull('registry', '2') 33 | cont = cli.create_container( 34 | 'registry:2', 35 | ports=[5000], 36 | host_config=cli.create_host_config( 37 | port_bindings={ 38 | 5000: 5000, 39 | }, 40 | ), 41 | ) 42 | try: 43 | cli.start(cont) 44 | wait_till_up('http://localhost:5000', 3) 45 | try: 46 | yield 47 | finally: 48 | cli.stop(cont) 49 | finally: 50 | cli.remove_container(cont, v=True, force=True) 51 | -------------------------------------------------------------------------------- /tests/drc_test_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yodle/docker-registry-client/8abf6b0200a68bed986f698dcbf02d444257b75c/tests/drc_test_utils/__init__.py -------------------------------------------------------------------------------- /tests/drc_test_utils/mock_registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from flexmock import flexmock 4 | from docker_registry_client import _BaseClient 5 | import json 6 | from requests.exceptions import HTTPError 7 | from requests.models import Response 8 | 9 | 10 | REGISTRY_URL = "https://registry.example.com:5000" 11 | TEST_NAMESPACE = 'library' 12 | TEST_REPO = 'myrepo' 13 | TEST_NAME = '%s/%s' % (TEST_NAMESPACE, TEST_REPO) 14 | TEST_TAG = 'latest' 15 | TEST_MANIFEST_DIGEST = '''\ 16 | sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b''' 17 | 18 | 19 | class MockResponse(object): 20 | def __init__(self, code, data=None, text=None, headers=None): 21 | self.ok = (code >= 200 and code < 400) 22 | self.status_code = code 23 | self.data = data 24 | self.text = text or '' 25 | self.headers = headers or {} 26 | self.reason = '' 27 | 28 | @property 29 | def content(self): 30 | if self.data is None: 31 | return None 32 | 33 | return json.dumps(self.data).encode() 34 | 35 | def raise_for_status(self): 36 | if not self.ok: 37 | response = Response() 38 | response.status_code = self.status_code 39 | raise HTTPError(response=response) 40 | 41 | def json(self): 42 | return self.data 43 | 44 | 45 | class MockRegistry(object): 46 | GET_MAP = {} 47 | DELETE_MAP = {} 48 | 49 | @staticmethod 50 | def format(s): 51 | return s.format(namespace=TEST_NAMESPACE, 52 | repo=TEST_REPO, 53 | name=TEST_NAME, 54 | tag=TEST_TAG, 55 | digest=TEST_MANIFEST_DIGEST) 56 | 57 | def call(self, response_map, url, data=None, headers=None): 58 | assert url.startswith(REGISTRY_URL) 59 | request = self.format(url[len(REGISTRY_URL):]) 60 | try: 61 | return response_map[request] 62 | except KeyError: 63 | return MockResponse(code=404, text='Not found: %s' % request) 64 | 65 | def get(self, *args, **kwargs): 66 | return self.call(self.GET_MAP, *args, **kwargs) 67 | 68 | def delete(self, *args, **kwargs): 69 | return self.call(self.DELETE_MAP, *args, **kwargs) 70 | 71 | 72 | class MockV1Registry(MockRegistry): 73 | TAGS = MockRegistry.format('/v1/repositories/{namespace}/{repo}/tags') 74 | TAGS_LIBRARY = MockRegistry.format('/v1/repositories/{repo}/tags') 75 | 76 | GET_MAP = { 77 | '/v1/_ping': MockResponse(200), 78 | 79 | '/v1/search': MockResponse(200, data={ 80 | 'results': [{'name': '%s/%s' % (TEST_NAMESPACE, TEST_REPO)}]}), 81 | 82 | TAGS: MockResponse(200, data={TEST_TAG: ''}), 83 | 84 | TAGS_LIBRARY: MockResponse(200, data={TEST_TAG: ''}), 85 | } 86 | 87 | 88 | def mock_v1_registry(): 89 | v1_registry = MockV1Registry() 90 | flexmock(_BaseClient, get=v1_registry.get) 91 | return REGISTRY_URL 92 | 93 | 94 | class MockV2Registry(MockRegistry): 95 | TAGS = MockRegistry.format('/v2/{name}/tags/list') 96 | TAGS_LIBRARY = MockRegistry.format('/v2/{repo}/tags/list') 97 | MANIFEST_TAG = MockRegistry.format('/v2/{name}/manifests/{tag}') 98 | MANIFEST_DIGEST = MockRegistry.format('/v2/{name}/manifests/{digest}') 99 | 100 | GET_MAP = { 101 | '/v2/': MockResponse(200), 102 | 103 | '/v2/_catalog': MockResponse(200, data={'repositories': [TEST_NAME]}), 104 | 105 | TAGS: 106 | MockResponse(200, data={'name': TEST_NAME, 107 | 'tags': [TEST_TAG]}), 108 | 109 | TAGS_LIBRARY: 110 | MockResponse(200, data={'name': TEST_NAME, 111 | 'tags': [TEST_TAG]}), 112 | 113 | MANIFEST_TAG: 114 | MockResponse(200, 115 | data={ 116 | 'name': TEST_NAME, 117 | 'tag': TEST_TAG, 118 | 'fsLayers': [] 119 | }, 120 | headers={ 121 | 'Docker-Content-Digest': TEST_MANIFEST_DIGEST, 122 | }), 123 | } 124 | 125 | DELETE_MAP = { 126 | MANIFEST_DIGEST: MockResponse(202, data={}), 127 | } 128 | 129 | 130 | def mock_v2_registry(): 131 | v2_registry = MockV2Registry() 132 | flexmock(_BaseClient, 133 | get=v2_registry.get, 134 | delete=v2_registry.delete) 135 | return REGISTRY_URL 136 | 137 | 138 | def mock_registry(version): 139 | if version == 1: 140 | return mock_v1_registry() 141 | elif version == 2: 142 | return mock_v2_registry() 143 | else: 144 | raise NotImplementedError() 145 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yodle/docker-registry-client/8abf6b0200a68bed986f698dcbf02d444257b75c/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/fixtures/base/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM busybox 2 | ADD . /code 3 | -------------------------------------------------------------------------------- /tests/integration/fixtures/base/base.txt: -------------------------------------------------------------------------------- 1 | Hi mom 2 | -------------------------------------------------------------------------------- /tests/integration/test_base_client.py: -------------------------------------------------------------------------------- 1 | from docker_registry_client import BaseClient 2 | import pkg_resources 3 | 4 | 5 | def test_base_client(registry): 6 | cli = BaseClient('http://localhost:5000', api_version=2) 7 | assert cli.catalog() == {'repositories': []} 8 | 9 | 10 | def test_base_client_edit_manifest(docker_client, registry): 11 | cli = BaseClient('http://localhost:5000', api_version=2) 12 | build = docker_client.build( 13 | pkg_resources.resource_filename(__name__, 'fixtures/base'), 14 | 'localhost:5000/x-drc-example:x-drc-test', stream=True, 15 | ) 16 | for line in build: 17 | print(line) 18 | 19 | push = docker_client.push( 20 | 'localhost:5000/x-drc-example', 'x-drc-test', stream=True, 21 | insecure_registry=True, 22 | ) 23 | 24 | for line in push: 25 | print(line) 26 | 27 | m = cli.get_manifest('x-drc-example', 'x-drc-test') 28 | assert m._content['name'] == 'x-drc-example' 29 | assert m._content['tag'] == 'x-drc-test' 30 | 31 | cli.put_manifest('x-drc-example', 'x-drc-test-put', m) 32 | 33 | pull = docker_client.pull( 34 | 'localhost:5000/x-drc-example', 'x-drc-test-put', stream=True, 35 | insecure_registry=True, decode=True, 36 | ) 37 | 38 | pull = list(pull) 39 | tag = 'localhost:5000/x-drc-example:x-drc-test-put' 40 | 41 | expected_statuses = { 42 | 'Status: Downloaded newer image for ' + tag, 43 | 'Status: Image is up to date for ' + tag, 44 | } 45 | 46 | errors = [evt for evt in pull if 'error' in evt] 47 | assert errors == [] 48 | 49 | assert {evt.get('status') for evt in pull} & expected_statuses 50 | -------------------------------------------------------------------------------- /tests/placeholder.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yodle/docker-registry-client/8abf6b0200a68bed986f698dcbf02d444257b75c/tests/placeholder.txt -------------------------------------------------------------------------------- /tests/test_base_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from docker_registry_client._BaseClient import BaseClientV1, BaseClientV2 4 | from drc_test_utils.mock_registry import ( 5 | mock_v1_registry, mock_v2_registry, TEST_NAME, TEST_TAG, 6 | ) 7 | 8 | 9 | class TestBaseClientV1(object): 10 | def test_check_status(self): 11 | url = mock_v1_registry() 12 | BaseClientV1(url).check_status() 13 | 14 | 15 | class TestBaseClientV2(object): 16 | def test_check_status(self): 17 | url = mock_v2_registry() 18 | BaseClientV2(url).check_status() 19 | 20 | def test_get_manifest_and_digest(self): 21 | url = mock_v2_registry() 22 | manifest, digest = BaseClientV2(url).get_manifest_and_digest(TEST_NAME, 23 | TEST_TAG) 24 | -------------------------------------------------------------------------------- /tests/test_dockerregistryclient.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from docker_registry_client import DockerRegistryClient 4 | from docker_registry_client.Repository import BaseRepository 5 | import pytest 6 | from requests import HTTPError 7 | from drc_test_utils.mock_registry import ( 8 | mock_registry, mock_v2_registry, TEST_NAMESPACE, TEST_REPO, TEST_NAME, 9 | TEST_TAG, 10 | ) 11 | 12 | 13 | class TestDockerRegistryClient(object): 14 | @pytest.mark.parametrize('version', [1, 2]) 15 | def test_api_version_in_use(self, version): 16 | url = mock_registry(version) 17 | client = DockerRegistryClient(url) 18 | assert client.api_version == version 19 | 20 | @pytest.mark.parametrize('version', [1, 2]) 21 | def test_namespaces(self, version): 22 | url = mock_registry(version) 23 | client = DockerRegistryClient(url) 24 | assert client.namespaces() == [TEST_NAMESPACE] 25 | 26 | @pytest.mark.parametrize('version', [1, 2]) 27 | @pytest.mark.parametrize(('repository', 'namespace'), [ 28 | (TEST_REPO, None), 29 | (TEST_REPO, TEST_NAMESPACE), 30 | ('{0}/{1}'.format(TEST_NAMESPACE, TEST_REPO), None), 31 | ]) 32 | def test_repository(self, version, repository, namespace): 33 | url = mock_registry(version) 34 | client = DockerRegistryClient(url) 35 | repository = client.repository(repository, namespace=namespace) 36 | assert isinstance(repository, BaseRepository) 37 | 38 | @pytest.mark.parametrize('version', [1, 2]) 39 | def test_repository_namespace_incorrect(self, version): 40 | url = mock_registry(version) 41 | client = DockerRegistryClient(url) 42 | with pytest.raises(RuntimeError): 43 | client.repository('{0}/{1}'.format(TEST_NAMESPACE, TEST_REPO), 44 | namespace=TEST_NAMESPACE) 45 | 46 | @pytest.mark.parametrize('namespace', [TEST_NAMESPACE, None]) 47 | @pytest.mark.parametrize('version', [1, 2]) 48 | def test_repositories(self, version, namespace): 49 | url = mock_registry(version) 50 | client = DockerRegistryClient(url) 51 | repositories = client.repositories(TEST_NAMESPACE) 52 | assert len(repositories) == 1 53 | assert TEST_NAME in repositories 54 | repository = repositories[TEST_NAME] 55 | assert repository.name == "%s/%s" % (TEST_NAMESPACE, TEST_REPO) 56 | 57 | @pytest.mark.parametrize('version', [1, 2]) 58 | def test_repository_tags(self, version): 59 | url = mock_registry(version) 60 | client = DockerRegistryClient(url) 61 | repositories = client.repositories(TEST_NAMESPACE) 62 | assert TEST_NAME in repositories 63 | repository = repositories[TEST_NAME] 64 | tags = repository.tags() 65 | assert len(tags) == 1 66 | assert TEST_TAG in tags 67 | 68 | def test_repository_manifest(self): 69 | url = mock_v2_registry() 70 | client = DockerRegistryClient(url) 71 | repository = client.repositories()[TEST_NAME] 72 | manifest, digest = repository.manifest(TEST_TAG) 73 | repository.delete_manifest(digest) 74 | 75 | @pytest.mark.parametrize(('client_api_version', 76 | 'registry_api_version', 77 | 'should_succeed'), [ 78 | (1, 1, True), 79 | (2, 2, True), 80 | (1, 2, False), 81 | (2, 1, False), 82 | ]) 83 | def test_api_version(self, client_api_version, registry_api_version, 84 | should_succeed): 85 | url = mock_registry(registry_api_version) 86 | if should_succeed: 87 | client = DockerRegistryClient(url, api_version=client_api_version) 88 | assert client.api_version == client_api_version 89 | else: 90 | with pytest.raises(HTTPError): 91 | client = DockerRegistryClient(url, 92 | api_version=client_api_version) 93 | client.refresh() 94 | -------------------------------------------------------------------------------- /tests/test_image.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from docker_registry_client.Image import Image 4 | from docker_registry_client._BaseClient import BaseClientV1 5 | from drc_test_utils.mock_registry import mock_v1_registry 6 | 7 | 8 | class TestImage(object): 9 | def test_init(self): 10 | url = mock_v1_registry() 11 | image_id = 'test_image_id' 12 | image = Image(image_id, BaseClientV1(url)) 13 | assert image.image_id == image_id 14 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import docker_registry_client 2 | 3 | 4 | def test_exported_symbols(): 5 | assert hasattr(docker_registry_client, 'DockerRegistryClient') 6 | assert hasattr(docker_registry_client, 'BaseClient') 7 | assert hasattr(docker_registry_client, 'Repository') 8 | -------------------------------------------------------------------------------- /tests/test_repository.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from docker_registry_client.Repository import Repository 4 | from docker_registry_client._BaseClient import BaseClientV1, BaseClientV2 5 | from drc_test_utils.mock_registry import ( 6 | mock_v1_registry, mock_v2_registry, TEST_NAMESPACE, TEST_REPO, TEST_NAME, 7 | ) 8 | 9 | 10 | class TestRepository(object): 11 | def test_initv1(self): 12 | url = mock_v1_registry() 13 | Repository(BaseClientV1(url), TEST_REPO, namespace=TEST_NAMESPACE) 14 | 15 | def test_initv2(self): 16 | url = mock_v2_registry() 17 | Repository(BaseClientV2(url), TEST_NAME) 18 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{27,35,36}, lint 3 | 4 | [testenv] 5 | commands = py.test {posargs} 6 | deps = 7 | docker-py==1.10.6 8 | flexmock==0.10.2 9 | pytest==3.0.5 10 | 11 | [testenv:lint] 12 | deps = 13 | flake8==3.2.1 14 | commands=flake8 docker_registry_client tests setup.py 15 | 16 | [travis] 17 | python = 18 | 3.6: py36, lint 19 | --------------------------------------------------------------------------------