Skip to content

Commit a4fd597

Browse files
committed
Check presence of errors in server response to image push
When pushing an image, the server response might have status code 200 (OK) even though the operation has failed. To detect the occurrence of an error, inspect each JSON chunk in the server response and verify that no "error" field is present. Fixes: docker#3277 Signed-off-by: Francesco Zardi <[email protected]>
1 parent bcf3e11 commit a4fd597

File tree

4 files changed

+109
-1
lines changed

4 files changed

+109
-1
lines changed

docker/api/image.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import itertools
2+
import json
13
import logging
24
import os
35

46
from .. import auth, errors, utils
57
from ..constants import DEFAULT_DATA_CHUNK_SIZE
8+
from ..utils.json_stream import json_stream
69

710
log = logging.getLogger(__name__)
811

@@ -433,6 +436,28 @@ def pull(self, repository, tag=None, stream=False, auth_config=None,
433436

434437
return self._result(response)
435438

439+
@staticmethod
440+
def _raise_if_error(chunk, response):
441+
"""
442+
Raise an exception if the JSON value contains an error message.
443+
Otherwise, return the chunk.
444+
445+
Args:
446+
chunk (dict): A chunk of the server response.
447+
response (Response): The full server response to be attached to the
448+
exception (if one is raised).
449+
450+
Returns:
451+
(dict): The input chunk of the server response.
452+
453+
Raises:
454+
:py:class:`docker.errors.APIError`
455+
If the chunk of the server response contains an error message.
456+
"""
457+
if isinstance(chunk, dict) and 'error' in chunk:
458+
raise errors.APIError(chunk['error'], response=response)
459+
return chunk
460+
436461
def push(self, repository, tag=None, stream=False, auth_config=None,
437462
decode=False):
438463
"""
@@ -494,8 +519,25 @@ def push(self, repository, tag=None, stream=False, auth_config=None,
494519

495520
self._raise_for_status(response)
496521

522+
# The server response might have status code 200 (OK) even though the
523+
# push operation has failed. To detect errors, inspect each JSON chunk
524+
# of the server response and check if an "error" entry is present.
525+
# See: https://github.com/docker/docker-py/issues/3277
497526
if stream:
498-
return self._stream_helper(response, decode=decode)
527+
if decode:
528+
return (self._raise_if_error(chunk, response) for chunk in
529+
self._stream_helper(response, decode=True))
530+
else:
531+
result_stream, internal_stream = itertools.tee(
532+
self._stream_helper(response, decode=False))
533+
for chunk_json in json_stream(internal_stream):
534+
self._raise_if_error(chunk_json, response)
535+
return result_stream
536+
537+
for chunk_str in response.text.splitlines():
538+
chunk_json = json.loads(chunk_str)
539+
if 'error' in chunk_json:
540+
raise errors.APIError(chunk_json['error'], response=response)
499541

500542
return self._result(response)
501543

tests/unit/api_image_test.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,33 @@ def test_push_image_with_auth(self):
271271
timeout=DEFAULT_TIMEOUT_SECONDS
272272
)
273273

274+
275+
def test_push_image_with_auth_error(self):
276+
auth_config = {
277+
'username': "test_user",
278+
'password': "test_password",
279+
'serveraddress': "test_server",
280+
}
281+
encoded_auth = auth.encode_header(auth_config)
282+
with pytest.raises(docker.errors.APIError, match='bad auth'):
283+
self.client.push(
284+
fake_api.FAKE_IMAGE_NAME_ERROR, tag=fake_api.FAKE_TAG_NAME,
285+
auth_config=auth_config
286+
)
287+
288+
fake_request.assert_called_with(
289+
'POST',
290+
f"{url_prefix}images/test_image_error/push",
291+
params={
292+
'tag': fake_api.FAKE_TAG_NAME,
293+
},
294+
data='{}',
295+
headers={'Content-Type': 'application/json',
296+
'X-Registry-Auth': encoded_auth},
297+
stream=False,
298+
timeout=DEFAULT_TIMEOUT_SECONDS
299+
)
300+
274301
def test_push_image_stream(self):
275302
with mock.patch('docker.auth.resolve_authconfig',
276303
fake_resolve_authconfig):
@@ -315,6 +342,32 @@ def test_push_image_stream_with_auth(self):
315342
)
316343

317344

345+
def test_push_image_stream_with_auth_error(self):
346+
auth_config = {
347+
'username': "test_user",
348+
'password': "test_password",
349+
'serveraddress': "test_server",
350+
}
351+
encoded_auth = auth.encode_header(auth_config)
352+
with pytest.raises(docker.errors.APIError, match='bad auth'):
353+
self.client.push(
354+
fake_api.FAKE_IMAGE_NAME_ERROR, tag=fake_api.FAKE_TAG_NAME,
355+
auth_config=auth_config, stream=True
356+
)
357+
358+
fake_request.assert_called_with(
359+
'POST',
360+
f"{url_prefix}images/test_image_error/push",
361+
params={
362+
'tag': fake_api.FAKE_TAG_NAME,
363+
},
364+
data='{}',
365+
headers={'Content-Type': 'application/json',
366+
'X-Registry-Auth': encoded_auth},
367+
stream=True,
368+
timeout=DEFAULT_TIMEOUT_SECONDS
369+
)
370+
318371
def test_tag_image(self):
319372
self.client.tag(fake_api.FAKE_IMAGE_ID, fake_api.FAKE_REPO_NAME)
320373

tests/unit/api_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def response(status_code=200, content='', headers=None, reason=None, elapsed=0,
3131
request=None, raw=None):
3232
res = requests.Response()
3333
res.status_code = status_code
34+
if isinstance(content, str):
35+
content = content.encode('ascii')
3436
if not isinstance(content, bytes):
3537
content = json.dumps(content).encode('ascii')
3638
res._content = content

tests/unit/fake_api.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
13
from docker import constants
24

35
from . import fake_stat
@@ -9,6 +11,7 @@
911
FAKE_EXEC_ID = 'b098ec855f10434b5c7c973c78484208223a83f663ddaefb0f02a242840cb1c7'
1012
FAKE_NETWORK_ID = '1999cfb42e414483841a125ade3c276c3cb80cb3269b14e339354ac63a31b02c'
1113
FAKE_IMAGE_NAME = 'test_image'
14+
FAKE_IMAGE_NAME_ERROR = 'test_image_error'
1215
FAKE_TARBALL_PATH = '/path/to/tarball'
1316
FAKE_REPO_NAME = 'repo'
1417
FAKE_TAG_NAME = 'tag'
@@ -359,6 +362,12 @@ def post_fake_push():
359362
return status_code, response
360363

361364

365+
def post_fake_push_error():
366+
status_code = 200
367+
response = '{"status": "intermediate update"}\r\n{"error": "bad auth"}\r\n'
368+
return status_code, response
369+
370+
362371
def post_fake_build_container():
363372
status_code = 200
364373
response = {'Id': FAKE_CONTAINER_ID}
@@ -603,6 +612,8 @@ def post_fake_config():
603612
get_fake_insert_image,
604613
f'{prefix}/{CURRENT_VERSION}/images/test_image/push':
605614
post_fake_push,
615+
f'{prefix}/{CURRENT_VERSION}/images/test_image_error/push':
616+
post_fake_push_error,
606617
f'{prefix}/{CURRENT_VERSION}/commit':
607618
post_fake_commit,
608619
f'{prefix}/{CURRENT_VERSION}/containers/create':

0 commit comments

Comments
 (0)