Skip to content

Commit 69ae927

Browse files
author
Thomas Grainger
committed
add support for get and put manifest schema v1
1 parent 8c082b9 commit 69ae927

File tree

8 files changed

+211
-8
lines changed

8 files changed

+211
-8
lines changed

CHANGES.rst

+2
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
------------------
33

44
- First version of docker-registry-client with changelog
5+
- Support get and push manifest on protocol v2, schema v1.
6+
(`Issue #33 <https://github.com/yodle/docker-registry-client/pull/33>`_)

docker_registry_client/_BaseClient.py

+48-7
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from requests.exceptions import HTTPError
44
import json
55
from docker_registry_client.AuthorizationService import AuthorizationService
6+
from .manifest import sign as sign_manifest
67

78
# urllib3 throws some ssl warnings with older versions of python
89
# they're probably ok for the registry client to ignore
@@ -136,10 +137,23 @@ def delete_repository(self, namespace, repository):
136137
namespace=namespace, repository=repository)
137138

138139

140+
class _Manifest(object):
141+
def __init__(self, content, type, digest):
142+
self._content = content
143+
self._type = type
144+
self._digest = digest
145+
146+
147+
BASE_CONTENT_TYPE = 'application/vnd.docker.distribution.manifest'
148+
149+
139150
class BaseClientV2(CommonBaseClient):
140151
LIST_TAGS = '/v2/{name}/tags/list'
141152
MANIFEST = '/v2/{name}/manifests/{reference}'
142153
BLOB = '/v2/{name}/blobs/{digest}'
154+
schema_1_signed = BASE_CONTENT_TYPE + '.v1+prettyjws'
155+
schema_1 = BASE_CONTENT_TYPE + '.v1+json'
156+
schema_2 = BASE_CONTENT_TYPE + '.v2+json'
143157

144158
def __init__(self, *args, **kwargs):
145159
auth_service_url = kwargs.pop("auth_service_url", "")
@@ -169,11 +183,33 @@ def get_repository_tags(self, name):
169183
return self._http_call(self.LIST_TAGS, get, name=name)
170184

171185
def get_manifest_and_digest(self, name, reference):
186+
m = self.get_manifest(name, reference)
187+
return m._content, m._digest
188+
189+
def get_manifest(self, name, reference):
172190
self.auth.desired_scope = 'repository:%s:*' % name
173-
response = self._http_response(self.MANIFEST, get,
174-
name=name, reference=reference)
191+
response = self._http_response(
192+
self.MANIFEST, get, name=name, reference=reference,
193+
schema=self.schema_1_signed,
194+
)
175195
self._cache_manifest_digest(name, reference, response=response)
176-
return (response.json(), self._manifest_digests[name, reference])
196+
return _Manifest(
197+
content=response.json(),
198+
type=response.headers.get('Content-Type', 'application/json'),
199+
digest=self._manifest_digests[name, reference],
200+
)
201+
202+
def put_manifest(self, name, reference, manifest):
203+
self.auth.desired_scope = 'repository:%s:*' % name
204+
content = {}
205+
content.update(manifest._content)
206+
content.update({'name': name, 'tag': reference})
207+
208+
return self._http_call(
209+
self.MANIFEST, put, data=sign_manifest(content),
210+
content_type=self.schema_1_signed, schema=self.schema_1_signed,
211+
name=name, reference=reference,
212+
)
177213

178214
def delete_manifest(self, name, digest):
179215
self.auth.desired_scope = 'repository:%s:*' % name
@@ -193,16 +229,20 @@ def _cache_manifest_digest(self, name, reference, response=None):
193229
untrusted_digest = response.headers.get('Docker-Content-Digest')
194230
self._manifest_digests[(name, reference)] = untrusted_digest
195231

196-
def _http_response(self, url, method, data=None, **kwargs):
232+
def _http_response(self, url, method, data=None, content_type=None,
233+
schema=None, **kwargs):
197234
"""url -> full target url
198235
method -> method from requests
199236
data -> request body
200237
kwargs -> url formatting args
201238
"""
202239

240+
if schema is None:
241+
schema = self.schema_2
242+
203243
header = {
204-
'content-type': 'application/json',
205-
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
244+
'content-type': content_type or 'application/json',
245+
'Accept': schema,
206246
}
207247

208248
# Token specific part. We add the token in the header if necessary
@@ -219,8 +259,9 @@ def _http_response(self, url, method, data=None, **kwargs):
219259

220260
header['Authorization'] = 'Bearer %s' % self.auth.token
221261

222-
if data:
262+
if data and not content_type:
223263
data = json.dumps(data)
264+
224265
path = url.format(**kwargs)
225266
logger.debug("%s %s", method.__name__.upper(), path)
226267
response = method(self.host + path,

docker_registry_client/manifest.py

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# code extracted from https://git.io/vM0EB used under license:
2+
#
3+
# Copyright (c) 2015 David Halls <https://github.com/davedoesdev/>
4+
#
5+
# Permission is hereby granted, free of charge, to any person obtaining a copy
6+
# of this software and associated documentation files (the "Software"), to deal
7+
# in the Software without restriction, including without limitation the rights
8+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
# copies of the Software, and to permit persons to whom the Software is
10+
# furnished to do so, subject to the following conditions:
11+
#
12+
# The above copyright notice and this permission notice shall be included in
13+
# all copies or substantial portions of the Software.
14+
#
15+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
# THE SOFTWARE.
22+
23+
import base64
24+
import ecdsa
25+
import jws
26+
import json
27+
28+
29+
def assign(obj, *objs):
30+
for o in objs:
31+
obj.update(o)
32+
return obj
33+
34+
35+
def force_bytes(s, encoding='utf8'):
36+
return s if isinstance(s, bytes) else s.encode(encoding)
37+
38+
39+
def _urlsafe_b64encode_bytes(b):
40+
return base64.urlsafe_b64encode(b).rstrip(b'=').decode('utf-8')
41+
42+
43+
def _urlsafe_b64encode(s):
44+
return _urlsafe_b64encode_bytes(force_bytes(s))
45+
46+
47+
jws.utils.to_bytes_2and3 = force_bytes
48+
jws.algos.to_bytes_2and3 = force_bytes
49+
50+
51+
def _num_to_base64(n):
52+
b = bytearray()
53+
while n:
54+
b.insert(0, n & 0xFF)
55+
n >>= 8
56+
# need to pad to 32 bytes
57+
while len(b) < 32:
58+
b.insert(0, 0)
59+
return _urlsafe_b64encode_bytes(b)
60+
61+
62+
def sign(manifest, key=None):
63+
m = assign({}, manifest)
64+
65+
try:
66+
del m['signatures']
67+
except KeyError:
68+
pass
69+
70+
assert len(m) > 0
71+
72+
manifest_json = json.dumps(m, sort_keys=True)
73+
74+
if key is None:
75+
key = ecdsa.SigningKey.generate(curve=ecdsa.NIST256p)
76+
77+
manifest64 = _urlsafe_b64encode(manifest_json)
78+
format_length = manifest_json.rfind('}')
79+
format_tail = manifest_json[format_length:]
80+
protected_json = json.dumps({
81+
'formatLength': format_length,
82+
'formatTail': _urlsafe_b64encode(format_tail)
83+
})
84+
protected64 = _urlsafe_b64encode(protected_json)
85+
point = key.privkey.public_key.point
86+
data = {
87+
'key': key,
88+
'header': {
89+
'alg': 'ES256'
90+
}
91+
}
92+
jws.header.process(data, 'sign')
93+
sig = data['signer']("%s.%s" % (protected64, manifest64), key)
94+
signatures = [{
95+
'header': {
96+
'jwk': {
97+
'kty': 'EC',
98+
'crv': 'P-256',
99+
'x': _num_to_base64(point.x()),
100+
'y': _num_to_base64(point.y())
101+
},
102+
'alg': 'ES256'
103+
},
104+
'signature': _urlsafe_b64encode(sig),
105+
'protected': protected64
106+
}]
107+
return (
108+
manifest_json[:format_length] +
109+
', "signatures": ' +
110+
json.dumps(signatures) +
111+
format_tail
112+
)

setup.py

+2
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,7 @@
2727
packages=find_packages(),
2828
install_requires=[
2929
'requests>=2.4.3, <3.0.0',
30+
'ecdsa>=0.13.0, <0.14.0',
31+
'jws>=0.1.3, <0.2.0',
3032
],
3133
)

tests/conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def wait_till_up(url, attempts):
2626
requests.get(url)
2727

2828

29-
@pytest.yield_fixture(scope='session')
29+
@pytest.yield_fixture()
3030
def registry(docker_client):
3131
cli = docker_client
3232
cli.pull('registry', '2')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM busybox
2+
ADD . /code
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hi mom

tests/integration/test_base_client.py

+43
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,49 @@
11
from docker_registry_client import BaseClient
2+
import pkg_resources
23

34

45
def test_base_client(registry):
56
cli = BaseClient('http://localhost:5000', api_version=2)
67
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

0 commit comments

Comments
 (0)