705 lines
26 KiB
Python
705 lines
26 KiB
Python
|
import hashlib
|
||
|
import json
|
||
|
|
||
|
from enum import Enum, unique
|
||
|
|
||
|
from image.docker.schema1 import (DockerSchema1ManifestBuilder, DockerSchema1Manifest,
|
||
|
DOCKER_SCHEMA1_CONTENT_TYPES)
|
||
|
from image.docker.schema2 import DOCKER_SCHEMA2_CONTENT_TYPES
|
||
|
from image.docker.schema2.manifest import DockerSchema2ManifestBuilder
|
||
|
from image.docker.schema2.config import DockerSchema2Config
|
||
|
from image.docker.schema2.list import DOCKER_SCHEMA2_MANIFESTLIST_CONTENT_TYPE
|
||
|
from image.docker.schemas import parse_manifest_from_bytes
|
||
|
from test.registry.protocols import (RegistryProtocol, Failures, ProtocolOptions, PushResult,
|
||
|
PullResult)
|
||
|
from util.bytes import Bytes
|
||
|
|
||
|
|
||
|
@unique
|
||
|
class V2ProtocolSteps(Enum):
|
||
|
""" Defines the various steps of the protocol, for matching failures. """
|
||
|
AUTH = 'auth'
|
||
|
BLOB_HEAD_CHECK = 'blob-head-check'
|
||
|
GET_MANIFEST = 'get-manifest'
|
||
|
GET_MANIFEST_LIST = 'get-manifest-list'
|
||
|
PUT_MANIFEST = 'put-manifest'
|
||
|
PUT_MANIFEST_LIST = 'put-manifest-list'
|
||
|
MOUNT_BLOB = 'mount-blob'
|
||
|
CATALOG = 'catalog'
|
||
|
LIST_TAGS = 'list-tags'
|
||
|
START_UPLOAD = 'start-upload'
|
||
|
GET_BLOB = 'get-blob'
|
||
|
|
||
|
|
||
|
class V2Protocol(RegistryProtocol):
|
||
|
FAILURE_CODES = {
|
||
|
V2ProtocolSteps.AUTH: {
|
||
|
Failures.UNAUTHENTICATED: 401,
|
||
|
Failures.INVALID_AUTHENTICATION: 401,
|
||
|
Failures.INVALID_REGISTRY: 400,
|
||
|
Failures.APP_REPOSITORY: 405,
|
||
|
Failures.ANONYMOUS_NOT_ALLOWED: 401,
|
||
|
Failures.INVALID_REPOSITORY: 400,
|
||
|
Failures.SLASH_REPOSITORY: 400,
|
||
|
Failures.NAMESPACE_DISABLED: 405,
|
||
|
},
|
||
|
V2ProtocolSteps.MOUNT_BLOB: {
|
||
|
Failures.UNAUTHORIZED_FOR_MOUNT: 202,
|
||
|
Failures.READONLY_REGISTRY: 405,
|
||
|
},
|
||
|
V2ProtocolSteps.GET_MANIFEST: {
|
||
|
Failures.UNKNOWN_TAG: 404,
|
||
|
Failures.UNAUTHORIZED: 401,
|
||
|
Failures.DISALLOWED_LIBRARY_NAMESPACE: 400,
|
||
|
Failures.ANONYMOUS_NOT_ALLOWED: 401,
|
||
|
},
|
||
|
V2ProtocolSteps.GET_BLOB: {
|
||
|
Failures.GEO_BLOCKED: 403,
|
||
|
},
|
||
|
V2ProtocolSteps.BLOB_HEAD_CHECK: {
|
||
|
Failures.DISALLOWED_LIBRARY_NAMESPACE: 400,
|
||
|
},
|
||
|
V2ProtocolSteps.START_UPLOAD: {
|
||
|
Failures.DISALLOWED_LIBRARY_NAMESPACE: 400,
|
||
|
Failures.READ_ONLY: 401,
|
||
|
Failures.MIRROR_ONLY: 401,
|
||
|
Failures.MIRROR_MISCONFIGURED: 401,
|
||
|
Failures.MIRROR_ROBOT_MISSING: 401,
|
||
|
Failures.READ_ONLY: 401,
|
||
|
Failures.READONLY_REGISTRY: 405,
|
||
|
},
|
||
|
V2ProtocolSteps.PUT_MANIFEST: {
|
||
|
Failures.DISALLOWED_LIBRARY_NAMESPACE: 400,
|
||
|
Failures.MISSING_TAG: 404,
|
||
|
Failures.INVALID_TAG: 404,
|
||
|
Failures.INVALID_IMAGES: 400,
|
||
|
Failures.INVALID_BLOB: 400,
|
||
|
Failures.UNSUPPORTED_CONTENT_TYPE: 415,
|
||
|
Failures.READ_ONLY: 401,
|
||
|
Failures.MIRROR_ONLY: 401,
|
||
|
Failures.MIRROR_MISCONFIGURED: 401,
|
||
|
Failures.MIRROR_ROBOT_MISSING: 401,
|
||
|
Failures.READONLY_REGISTRY: 405,
|
||
|
},
|
||
|
V2ProtocolSteps.PUT_MANIFEST_LIST: {
|
||
|
Failures.INVALID_MANIFEST: 400,
|
||
|
Failures.READ_ONLY: 401,
|
||
|
Failures.MIRROR_ONLY: 401,
|
||
|
Failures.MIRROR_MISCONFIGURED: 401,
|
||
|
Failures.MIRROR_ROBOT_MISSING: 401,
|
||
|
Failures.READONLY_REGISTRY: 405,
|
||
|
}
|
||
|
}
|
||
|
|
||
|
def __init__(self, jwk, schema2=False):
|
||
|
self.jwk = jwk
|
||
|
self.schema2 = schema2
|
||
|
|
||
|
def ping(self, session):
|
||
|
result = session.get('/v2/')
|
||
|
assert result.status_code == 401
|
||
|
assert result.headers['Docker-Distribution-API-Version'] == 'registry/2.0'
|
||
|
|
||
|
def login(self, session, username, password, scopes, expect_success):
|
||
|
scopes = scopes if isinstance(scopes, list) else [scopes]
|
||
|
params = {
|
||
|
'account': username,
|
||
|
'service': 'localhost:5000',
|
||
|
'scope': scopes,
|
||
|
}
|
||
|
|
||
|
auth = (username, password)
|
||
|
if not username or not password:
|
||
|
auth = None
|
||
|
|
||
|
response = session.get('/v2/auth', params=params, auth=auth)
|
||
|
if expect_success:
|
||
|
assert response.status_code / 100 == 2
|
||
|
else:
|
||
|
assert response.status_code / 100 == 4
|
||
|
|
||
|
return response
|
||
|
|
||
|
def auth(self, session, credentials, namespace, repo_name, scopes=None,
|
||
|
expected_failure=None):
|
||
|
"""
|
||
|
Performs the V2 Auth flow, returning the token (if any) and the response.
|
||
|
|
||
|
Spec: https://docs.docker.com/registry/spec/auth/token/
|
||
|
"""
|
||
|
|
||
|
scopes = scopes or []
|
||
|
auth = None
|
||
|
username = None
|
||
|
|
||
|
if credentials is not None:
|
||
|
username, _ = credentials
|
||
|
auth = credentials
|
||
|
|
||
|
params = {
|
||
|
'account': username,
|
||
|
'service': 'localhost:5000',
|
||
|
}
|
||
|
|
||
|
if scopes:
|
||
|
params['scope'] = scopes
|
||
|
|
||
|
response = self.conduct(session, 'GET', '/v2/auth', params=params, auth=auth,
|
||
|
expected_status=(200, expected_failure, V2ProtocolSteps.AUTH))
|
||
|
expect_token = (expected_failure is None or
|
||
|
not V2Protocol.FAILURE_CODES[V2ProtocolSteps.AUTH].get(expected_failure))
|
||
|
if expect_token:
|
||
|
assert response.json().get('token') is not None
|
||
|
return response.json().get('token'), response
|
||
|
|
||
|
return None, response
|
||
|
|
||
|
def pull_list(self, session, namespace, repo_name, tag_names, manifestlist,
|
||
|
credentials=None, expected_failure=None, options=None):
|
||
|
options = options or ProtocolOptions()
|
||
|
scopes = options.scopes or ['repository:%s:push,pull' % self.repo_name(namespace, repo_name)]
|
||
|
tag_names = [tag_names] if isinstance(tag_names, str) else tag_names
|
||
|
|
||
|
# Ping!
|
||
|
self.ping(session)
|
||
|
|
||
|
# Perform auth and retrieve a token.
|
||
|
token, _ = self.auth(session, credentials, namespace, repo_name, scopes=scopes,
|
||
|
expected_failure=expected_failure)
|
||
|
if token is None:
|
||
|
assert V2Protocol.FAILURE_CODES[V2ProtocolSteps.AUTH].get(expected_failure)
|
||
|
return
|
||
|
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + token,
|
||
|
'Accept': ','.join(DOCKER_SCHEMA2_CONTENT_TYPES),
|
||
|
}
|
||
|
|
||
|
for tag_name in tag_names:
|
||
|
# Retrieve the manifest for the tag or digest.
|
||
|
response = self.conduct(session, 'GET',
|
||
|
'/v2/%s/manifests/%s' % (self.repo_name(namespace, repo_name),
|
||
|
tag_name),
|
||
|
expected_status=(200, expected_failure,
|
||
|
V2ProtocolSteps.GET_MANIFEST_LIST),
|
||
|
headers=headers)
|
||
|
if expected_failure is not None:
|
||
|
return None
|
||
|
|
||
|
# Parse the returned manifest list and ensure it matches.
|
||
|
ct = response.headers['Content-Type']
|
||
|
assert ct == DOCKER_SCHEMA2_MANIFESTLIST_CONTENT_TYPE
|
||
|
retrieved = parse_manifest_from_bytes(Bytes.for_string_or_unicode(response.text), ct)
|
||
|
assert retrieved.schema_version == 2
|
||
|
assert retrieved.is_manifest_list
|
||
|
assert retrieved.digest == manifestlist.digest
|
||
|
|
||
|
# Pull each of the manifests inside and ensure they can be retrieved.
|
||
|
for manifest_digest in retrieved.child_manifest_digests():
|
||
|
response = self.conduct(session, 'GET',
|
||
|
'/v2/%s/manifests/%s' % (self.repo_name(namespace, repo_name),
|
||
|
manifest_digest),
|
||
|
expected_status=(200, expected_failure,
|
||
|
V2ProtocolSteps.GET_MANIFEST),
|
||
|
headers=headers)
|
||
|
if expected_failure is not None:
|
||
|
return None
|
||
|
|
||
|
ct = response.headers['Content-Type']
|
||
|
manifest = parse_manifest_from_bytes(Bytes.for_string_or_unicode(response.text), ct)
|
||
|
assert not manifest.is_manifest_list
|
||
|
assert manifest.digest == manifest_digest
|
||
|
|
||
|
def push_list(self, session, namespace, repo_name, tag_names, manifestlist, manifests, blobs,
|
||
|
credentials=None, expected_failure=None, options=None):
|
||
|
options = options or ProtocolOptions()
|
||
|
scopes = options.scopes or ['repository:%s:push,pull' % self.repo_name(namespace, repo_name)]
|
||
|
tag_names = [tag_names] if isinstance(tag_names, str) else tag_names
|
||
|
|
||
|
# Ping!
|
||
|
self.ping(session)
|
||
|
|
||
|
# Perform auth and retrieve a token.
|
||
|
token, _ = self.auth(session, credentials, namespace, repo_name, scopes=scopes,
|
||
|
expected_failure=expected_failure)
|
||
|
if token is None:
|
||
|
assert V2Protocol.FAILURE_CODES[V2ProtocolSteps.AUTH].get(expected_failure)
|
||
|
return
|
||
|
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + token,
|
||
|
'Accept': ','.join(options.accept_mimetypes) if options.accept_mimetypes is not None else '*/*',
|
||
|
}
|
||
|
|
||
|
# Push all blobs.
|
||
|
if not self._push_blobs(blobs, session, namespace, repo_name, headers, options,
|
||
|
expected_failure):
|
||
|
return
|
||
|
|
||
|
# Push the individual manifests.
|
||
|
for manifest in manifests:
|
||
|
manifest_headers = {'Content-Type': manifest.media_type}
|
||
|
manifest_headers.update(headers)
|
||
|
|
||
|
self.conduct(session, 'PUT',
|
||
|
'/v2/%s/manifests/%s' % (self.repo_name(namespace, repo_name), manifest.digest),
|
||
|
data=manifest.bytes.as_encoded_str(),
|
||
|
expected_status=(202, expected_failure, V2ProtocolSteps.PUT_MANIFEST),
|
||
|
headers=manifest_headers)
|
||
|
|
||
|
# Push the manifest list.
|
||
|
for tag_name in tag_names:
|
||
|
manifest_headers = {'Content-Type': manifestlist.media_type}
|
||
|
manifest_headers.update(headers)
|
||
|
|
||
|
if options.manifest_content_type is not None:
|
||
|
manifest_headers['Content-Type'] = options.manifest_content_type
|
||
|
|
||
|
self.conduct(session, 'PUT',
|
||
|
'/v2/%s/manifests/%s' % (self.repo_name(namespace, repo_name), tag_name),
|
||
|
data=manifestlist.bytes.as_encoded_str(),
|
||
|
expected_status=(202, expected_failure, V2ProtocolSteps.PUT_MANIFEST_LIST),
|
||
|
headers=manifest_headers)
|
||
|
|
||
|
return PushResult(manifests=None, headers=headers)
|
||
|
|
||
|
def build_schema2(self, images, blobs, options):
|
||
|
builder = DockerSchema2ManifestBuilder()
|
||
|
for image in images:
|
||
|
checksum = 'sha256:' + hashlib.sha256(image.bytes).hexdigest()
|
||
|
|
||
|
if image.urls is None:
|
||
|
blobs[checksum] = image.bytes
|
||
|
|
||
|
# If invalid blob references were requested, just make it up.
|
||
|
if options.manifest_invalid_blob_references:
|
||
|
checksum = 'sha256:' + hashlib.sha256('notarealthing').hexdigest()
|
||
|
|
||
|
if not image.is_empty:
|
||
|
builder.add_layer(checksum, len(image.bytes), urls=image.urls)
|
||
|
|
||
|
def history_for_image(image):
|
||
|
history = {
|
||
|
'created': '2018-04-03T18:37:09.284840891Z',
|
||
|
'created_by': (('/bin/sh -c #(nop) ENTRYPOINT %s' % image.config['Entrypoint'])
|
||
|
if image.config and image.config.get('Entrypoint')
|
||
|
else '/bin/sh -c #(nop) %s' % image.id),
|
||
|
}
|
||
|
|
||
|
if image.is_empty:
|
||
|
history['empty_layer'] = True
|
||
|
|
||
|
return history
|
||
|
|
||
|
config = {
|
||
|
"os": "linux",
|
||
|
"rootfs": {
|
||
|
"type": "layers",
|
||
|
"diff_ids": []
|
||
|
},
|
||
|
"history": [history_for_image(image) for image in images],
|
||
|
}
|
||
|
|
||
|
if images[-1].config:
|
||
|
config['config'] = images[-1].config
|
||
|
|
||
|
config_json = json.dumps(config, ensure_ascii=options.ensure_ascii)
|
||
|
schema2_config = DockerSchema2Config(Bytes.for_string_or_unicode(config_json))
|
||
|
builder.set_config(schema2_config)
|
||
|
|
||
|
blobs[schema2_config.digest] = schema2_config.bytes.as_encoded_str()
|
||
|
return builder.build(ensure_ascii=options.ensure_ascii)
|
||
|
|
||
|
def build_schema1(self, namespace, repo_name, tag_name, images, blobs, options, arch='amd64'):
|
||
|
builder = DockerSchema1ManifestBuilder(namespace, repo_name, tag_name, arch)
|
||
|
|
||
|
for image in reversed(images):
|
||
|
assert image.urls is None
|
||
|
|
||
|
checksum = 'sha256:' + hashlib.sha256(image.bytes).hexdigest()
|
||
|
blobs[checksum] = image.bytes
|
||
|
|
||
|
# If invalid blob references were requested, just make it up.
|
||
|
if options.manifest_invalid_blob_references:
|
||
|
checksum = 'sha256:' + hashlib.sha256('notarealthing').hexdigest()
|
||
|
|
||
|
layer_dict = {'id': image.id, 'parent': image.parent_id}
|
||
|
if image.config is not None:
|
||
|
layer_dict['config'] = image.config
|
||
|
|
||
|
if image.size is not None:
|
||
|
layer_dict['Size'] = image.size
|
||
|
|
||
|
if image.created is not None:
|
||
|
layer_dict['created'] = image.created
|
||
|
|
||
|
builder.add_layer(checksum, json.dumps(layer_dict, ensure_ascii=options.ensure_ascii))
|
||
|
|
||
|
# Build the manifest.
|
||
|
built = builder.build(self.jwk, ensure_ascii=options.ensure_ascii)
|
||
|
|
||
|
# Validate it before we send it.
|
||
|
DockerSchema1Manifest(built.bytes)
|
||
|
return built
|
||
|
|
||
|
def push(self, session, namespace, repo_name, tag_names, images, credentials=None,
|
||
|
expected_failure=None, options=None):
|
||
|
options = options or ProtocolOptions()
|
||
|
scopes = options.scopes or ['repository:%s:push,pull' % self.repo_name(namespace, repo_name)]
|
||
|
tag_names = [tag_names] if isinstance(tag_names, str) else tag_names
|
||
|
|
||
|
# Ping!
|
||
|
self.ping(session)
|
||
|
|
||
|
# Perform auth and retrieve a token.
|
||
|
token, _ = self.auth(session, credentials, namespace, repo_name, scopes=scopes,
|
||
|
expected_failure=expected_failure)
|
||
|
if token is None:
|
||
|
assert V2Protocol.FAILURE_CODES[V2ProtocolSteps.AUTH].get(expected_failure)
|
||
|
return
|
||
|
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + token,
|
||
|
'Accept': ','.join(options.accept_mimetypes) if options.accept_mimetypes is not None else '*/*',
|
||
|
}
|
||
|
|
||
|
# Build fake manifests.
|
||
|
manifests = {}
|
||
|
blobs = {}
|
||
|
for tag_name in tag_names:
|
||
|
if self.schema2:
|
||
|
manifests[tag_name] = self.build_schema2(images, blobs, options)
|
||
|
else:
|
||
|
manifests[tag_name] = self.build_schema1(namespace, repo_name, tag_name, images, blobs,
|
||
|
options)
|
||
|
|
||
|
# Push the blob data.
|
||
|
if not self._push_blobs(blobs, session, namespace, repo_name, headers, options,
|
||
|
expected_failure):
|
||
|
return
|
||
|
|
||
|
# Write a manifest for each tag.
|
||
|
for tag_name in tag_names:
|
||
|
manifest = manifests[tag_name]
|
||
|
|
||
|
# Write the manifest. If we expect it to be invalid, we expect a 404 code. Otherwise, we
|
||
|
# expect a 202 response for success.
|
||
|
put_code = 404 if options.manifest_invalid_blob_references else 202
|
||
|
manifest_headers = {'Content-Type': manifest.media_type}
|
||
|
manifest_headers.update(headers)
|
||
|
|
||
|
if options.manifest_content_type is not None:
|
||
|
manifest_headers['Content-Type'] = options.manifest_content_type
|
||
|
|
||
|
tag_or_digest = tag_name if not options.push_by_manifest_digest else manifest.digest
|
||
|
self.conduct(session, 'PUT',
|
||
|
'/v2/%s/manifests/%s' % (self.repo_name(namespace, repo_name), tag_or_digest),
|
||
|
data=manifest.bytes.as_encoded_str(),
|
||
|
expected_status=(put_code, expected_failure, V2ProtocolSteps.PUT_MANIFEST),
|
||
|
headers=manifest_headers)
|
||
|
|
||
|
return PushResult(manifests=manifests, headers=headers)
|
||
|
|
||
|
def _push_blobs(self, blobs, session, namespace, repo_name, headers, options, expected_failure):
|
||
|
for blob_digest, blob_bytes in blobs.iteritems():
|
||
|
if not options.skip_head_checks:
|
||
|
# Blob data should not yet exist.
|
||
|
self.conduct(session, 'HEAD',
|
||
|
'/v2/%s/blobs/%s' % (self.repo_name(namespace, repo_name), blob_digest),
|
||
|
expected_status=(404, expected_failure, V2ProtocolSteps.BLOB_HEAD_CHECK),
|
||
|
headers=headers)
|
||
|
|
||
|
# Check for mounting of blobs.
|
||
|
if options.mount_blobs and blob_digest in options.mount_blobs:
|
||
|
self.conduct(session, 'POST',
|
||
|
'/v2/%s/blobs/uploads/' % self.repo_name(namespace, repo_name),
|
||
|
params={
|
||
|
'mount': blob_digest,
|
||
|
'from': options.mount_blobs[blob_digest],
|
||
|
},
|
||
|
expected_status=(201, expected_failure, V2ProtocolSteps.MOUNT_BLOB),
|
||
|
headers=headers)
|
||
|
if expected_failure is not None:
|
||
|
return
|
||
|
else:
|
||
|
# Start a new upload of the blob data.
|
||
|
response = self.conduct(session, 'POST',
|
||
|
'/v2/%s/blobs/uploads/' % self.repo_name(namespace, repo_name),
|
||
|
expected_status=(202, expected_failure,
|
||
|
V2ProtocolSteps.START_UPLOAD),
|
||
|
headers=headers)
|
||
|
if response.status_code != 202:
|
||
|
continue
|
||
|
|
||
|
upload_uuid = response.headers['Docker-Upload-UUID']
|
||
|
new_upload_location = response.headers['Location']
|
||
|
assert new_upload_location.startswith('http://localhost:5000')
|
||
|
|
||
|
# We need to make this relative just for the tests because the live server test
|
||
|
# case modifies the port.
|
||
|
location = response.headers['Location'][len('http://localhost:5000'):]
|
||
|
|
||
|
# PATCH the data into the blob.
|
||
|
if options.chunks_for_upload is None:
|
||
|
self.conduct(session, 'PATCH', location, data=blob_bytes, expected_status=204,
|
||
|
headers=headers)
|
||
|
else:
|
||
|
# If chunked upload is requested, upload the data as a series of chunks, checking
|
||
|
# status at every point.
|
||
|
for chunk_data in options.chunks_for_upload:
|
||
|
if len(chunk_data) == 3:
|
||
|
(start_byte, end_byte, expected_code) = chunk_data
|
||
|
else:
|
||
|
(start_byte, end_byte) = chunk_data
|
||
|
expected_code = 204
|
||
|
|
||
|
patch_headers = {'Range': 'bytes=%s-%s' % (start_byte, end_byte)}
|
||
|
patch_headers.update(headers)
|
||
|
|
||
|
contents_chunk = blob_bytes[start_byte:end_byte]
|
||
|
self.conduct(session, 'PATCH', location, data=contents_chunk,
|
||
|
expected_status=expected_code,
|
||
|
headers=patch_headers)
|
||
|
if expected_code != 204:
|
||
|
return False
|
||
|
|
||
|
# Retrieve the upload status at each point, and ensure it is valid.
|
||
|
status_url = '/v2/%s/blobs/uploads/%s' % (self.repo_name(namespace, repo_name),
|
||
|
upload_uuid)
|
||
|
response = self.conduct(session, 'GET', status_url, expected_status=204,
|
||
|
headers=headers)
|
||
|
assert response.headers['Docker-Upload-UUID'] == upload_uuid
|
||
|
assert response.headers['Range'] == "bytes=0-%s" % end_byte
|
||
|
|
||
|
if options.cancel_blob_upload:
|
||
|
self.conduct(session, 'DELETE', location, params=dict(digest=blob_digest),
|
||
|
expected_status=204, headers=headers)
|
||
|
|
||
|
# Ensure the upload was canceled.
|
||
|
status_url = '/v2/%s/blobs/uploads/%s' % (self.repo_name(namespace, repo_name),
|
||
|
upload_uuid)
|
||
|
self.conduct(session, 'GET', status_url, expected_status=404, headers=headers)
|
||
|
return False
|
||
|
|
||
|
# Finish the blob upload with a PUT.
|
||
|
response = self.conduct(session, 'PUT', location, params=dict(digest=blob_digest),
|
||
|
expected_status=201, headers=headers)
|
||
|
assert response.headers['Docker-Content-Digest'] == blob_digest
|
||
|
|
||
|
# Ensure the blob exists now.
|
||
|
response = self.conduct(session, 'HEAD',
|
||
|
'/v2/%s/blobs/%s' % (self.repo_name(namespace, repo_name),
|
||
|
blob_digest),
|
||
|
expected_status=200, headers=headers)
|
||
|
|
||
|
assert response.headers['Docker-Content-Digest'] == blob_digest
|
||
|
assert response.headers['Content-Length'] == str(len(blob_bytes))
|
||
|
|
||
|
# And retrieve the blob data.
|
||
|
if not options.skip_blob_push_checks:
|
||
|
result = self.conduct(session, 'GET',
|
||
|
'/v2/%s/blobs/%s' % (self.repo_name(namespace, repo_name), blob_digest),
|
||
|
headers=headers, expected_status=200)
|
||
|
assert result.content == blob_bytes
|
||
|
|
||
|
return True
|
||
|
|
||
|
def delete(self, session, namespace, repo_name, tag_names, credentials=None,
|
||
|
expected_failure=None, options=None):
|
||
|
options = options or ProtocolOptions()
|
||
|
scopes = options.scopes or ['repository:%s:*' % self.repo_name(namespace, repo_name)]
|
||
|
tag_names = [tag_names] if isinstance(tag_names, str) else tag_names
|
||
|
|
||
|
# Ping!
|
||
|
self.ping(session)
|
||
|
|
||
|
# Perform auth and retrieve a token.
|
||
|
token, _ = self.auth(session, credentials, namespace, repo_name, scopes=scopes,
|
||
|
expected_failure=expected_failure)
|
||
|
if token is None:
|
||
|
return None
|
||
|
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + token,
|
||
|
}
|
||
|
|
||
|
for tag_name in tag_names:
|
||
|
self.conduct(session, 'DELETE',
|
||
|
'/v2/%s/manifests/%s' % (self.repo_name(namespace, repo_name), tag_name),
|
||
|
headers=headers,
|
||
|
expected_status=202)
|
||
|
|
||
|
|
||
|
def pull(self, session, namespace, repo_name, tag_names, images, credentials=None,
|
||
|
expected_failure=None, options=None):
|
||
|
options = options or ProtocolOptions()
|
||
|
scopes = options.scopes or ['repository:%s:pull' % self.repo_name(namespace, repo_name)]
|
||
|
tag_names = [tag_names] if isinstance(tag_names, str) else tag_names
|
||
|
|
||
|
# Ping!
|
||
|
self.ping(session)
|
||
|
|
||
|
# Perform auth and retrieve a token.
|
||
|
token, _ = self.auth(session, credentials, namespace, repo_name, scopes=scopes,
|
||
|
expected_failure=expected_failure)
|
||
|
if token is None and not options.attempt_pull_without_token:
|
||
|
return None
|
||
|
|
||
|
headers = {}
|
||
|
if token:
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + token,
|
||
|
}
|
||
|
|
||
|
if self.schema2:
|
||
|
headers['Accept'] = ','.join(options.accept_mimetypes
|
||
|
if options.accept_mimetypes is not None
|
||
|
else DOCKER_SCHEMA2_CONTENT_TYPES)
|
||
|
|
||
|
manifests = {}
|
||
|
image_ids = {}
|
||
|
for tag_name in tag_names:
|
||
|
# Retrieve the manifest for the tag or digest.
|
||
|
response = self.conduct(session, 'GET',
|
||
|
'/v2/%s/manifests/%s' % (self.repo_name(namespace, repo_name),
|
||
|
tag_name),
|
||
|
expected_status=(200, expected_failure, V2ProtocolSteps.GET_MANIFEST),
|
||
|
headers=headers)
|
||
|
if response.status_code == 401:
|
||
|
assert 'WWW-Authenticate' in response.headers
|
||
|
|
||
|
response.encoding = 'utf-8'
|
||
|
if expected_failure is not None:
|
||
|
return None
|
||
|
|
||
|
# Ensure the manifest returned by us is valid.
|
||
|
ct = response.headers['Content-Type']
|
||
|
if not self.schema2:
|
||
|
assert ct in DOCKER_SCHEMA1_CONTENT_TYPES
|
||
|
|
||
|
manifest = parse_manifest_from_bytes(Bytes.for_string_or_unicode(response.text), ct)
|
||
|
manifests[tag_name] = manifest
|
||
|
|
||
|
if manifest.schema_version == 1:
|
||
|
image_ids[tag_name] = manifest.leaf_layer_v1_image_id
|
||
|
|
||
|
# Verify the blobs.
|
||
|
layer_index = 0
|
||
|
empty_count = 0
|
||
|
blob_digests = list(manifest.blob_digests)
|
||
|
for image in images:
|
||
|
if manifest.schema_version == 2 and image.is_empty:
|
||
|
empty_count += 1
|
||
|
continue
|
||
|
|
||
|
# If the layer is remote, then we expect the blob to *not* exist in the system.
|
||
|
blob_digest = blob_digests[layer_index]
|
||
|
expected_status = 404 if image.urls else 200
|
||
|
result = self.conduct(session, 'GET',
|
||
|
'/v2/%s/blobs/%s' % (self.repo_name(namespace, repo_name),
|
||
|
blob_digest),
|
||
|
expected_status=(expected_status, expected_failure,
|
||
|
V2ProtocolSteps.GET_BLOB),
|
||
|
headers=headers,
|
||
|
options=options)
|
||
|
|
||
|
if expected_status == 200:
|
||
|
assert result.content == image.bytes
|
||
|
|
||
|
layer_index += 1
|
||
|
|
||
|
assert (len(blob_digests) + empty_count) >= len(images) # Schema 2 has 1 extra for config
|
||
|
|
||
|
return PullResult(manifests=manifests, image_ids=image_ids)
|
||
|
|
||
|
|
||
|
def tags(self, session, namespace, repo_name, page_size=2, credentials=None, options=None,
|
||
|
expected_failure=None):
|
||
|
options = options or ProtocolOptions()
|
||
|
scopes = options.scopes or ['repository:%s:pull' % self.repo_name(namespace, repo_name)]
|
||
|
|
||
|
# Ping!
|
||
|
self.ping(session)
|
||
|
|
||
|
# Perform auth and retrieve a token.
|
||
|
headers = {}
|
||
|
if credentials is not None:
|
||
|
token, _ = self.auth(session, credentials, namespace, repo_name, scopes=scopes,
|
||
|
expected_failure=expected_failure)
|
||
|
if token is None:
|
||
|
return None
|
||
|
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + token,
|
||
|
}
|
||
|
|
||
|
results = []
|
||
|
url = '/v2/%s/tags/list' % (self.repo_name(namespace, repo_name))
|
||
|
params = {}
|
||
|
if page_size is not None:
|
||
|
params['n'] = page_size
|
||
|
|
||
|
while True:
|
||
|
response = self.conduct(session, 'GET', url, headers=headers, params=params,
|
||
|
expected_status=(200, expected_failure, V2ProtocolSteps.LIST_TAGS))
|
||
|
data = response.json()
|
||
|
|
||
|
assert len(data['tags']) <= page_size
|
||
|
results.extend(data['tags'])
|
||
|
|
||
|
if not response.headers.get('Link'):
|
||
|
return results
|
||
|
|
||
|
link_url = response.headers['Link']
|
||
|
v2_index = link_url.find('/v2/')
|
||
|
url = link_url[v2_index:]
|
||
|
|
||
|
return results
|
||
|
|
||
|
def catalog(self, session, page_size=2, credentials=None, options=None, expected_failure=None,
|
||
|
namespace=None, repo_name=None, bearer_token=None):
|
||
|
options = options or ProtocolOptions()
|
||
|
scopes = options.scopes or []
|
||
|
|
||
|
# Ping!
|
||
|
self.ping(session)
|
||
|
|
||
|
# Perform auth and retrieve a token.
|
||
|
headers = {}
|
||
|
if credentials is not None:
|
||
|
token, _ = self.auth(session, credentials, namespace, repo_name, scopes=scopes,
|
||
|
expected_failure=expected_failure)
|
||
|
if token is None:
|
||
|
return None
|
||
|
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + token,
|
||
|
}
|
||
|
|
||
|
if bearer_token is not None:
|
||
|
headers = {
|
||
|
'Authorization': 'Bearer ' + bearer_token,
|
||
|
}
|
||
|
|
||
|
results = []
|
||
|
url = '/v2/_catalog'
|
||
|
params = {}
|
||
|
if page_size is not None:
|
||
|
params['n'] = page_size
|
||
|
|
||
|
while True:
|
||
|
response = self.conduct(session, 'GET', url, headers=headers, params=params,
|
||
|
expected_status=(200, expected_failure, V2ProtocolSteps.CATALOG))
|
||
|
data = response.json()
|
||
|
|
||
|
assert len(data['repositories']) <= page_size
|
||
|
results.extend(data['repositories'])
|
||
|
|
||
|
if not response.headers.get('Link'):
|
||
|
return results
|
||
|
|
||
|
link_url = response.headers['Link']
|
||
|
v2_index = link_url.find('/v2/')
|
||
|
url = link_url[v2_index:]
|
||
|
|
||
|
return results
|