This repository has been archived on 2020-03-24. You can view files and clone it, but cannot push or open issues or pull requests.
quay/util/tufmetadata/api.py
2017-04-12 17:33:51 -04:00

176 lines
6.7 KiB
Python

import logging
from urlparse import urljoin
from posixpath import join
import requests
from data.database import CloseForLongOperation
from util.failover import failover, FailoverException
from util.security.instancekeys import InstanceKeys
from util.security.registry_jwt import build_context_and_subject, generate_bearer_token, QUAY_TUF_ROOT
DEFAULT_HTTP_HEADERS = {'Connection': 'close'}
MITM_CERT_PATH = '/conf/mitm.cert'
TOKEN_VALIDITY_LIFETIME_S = 60 * 60 # 1 hour
logger = logging.getLogger(__name__)
class InvalidMetadataException(Exception):
""" Exception raised when the upstream API metadata that doesn't parse correctly. """
pass
class Non200ResponseException(Exception):
""" Exception raised when the upstream API returns a non-200 HTTP status code. """
def __init__(self, response):
super(Non200ResponseException, self).__init__()
self.response = response
class TUFMetadataAPI(object):
""" Helper class for talking to the TUF Metadata service (Apostille). """
def __init__(self, app, config, client=None):
self._app = app
self._instance_keys = InstanceKeys(app)
self._config = config
self._client = client or config['HTTPCLIENT']
self._gun_prefix = config['TUF_GUN_PREFIX'] or config['SERVER_HOSTNAME']
def get_default_tags_with_expiration(self, namespace, repository, targets_file=None):
"""
Gets the tag -> sha mappings for a repo, as well as the expiration of the signatures.
Does not verify the metadata, this is purely for display purposes.
Args:
namespace: namespace containing the repository
repository: the repo to get tags for
targets_file: the specific delegation to read from. Default: targets/releases.json
Returns:
targets, expiration or None, None
"""
if not targets_file:
targets_file = 'targets/releases.json'
gun = self._gun(namespace, repository)
try:
response = self._get(gun, targets_file)
signed = self._parse_signed(response.json())
targets = signed.get('targets')
expiration = signed.get('expires')
except requests.exceptions.Timeout:
logger.exception('Timeout when trying to get metadata for %s', gun)
return None, None
except requests.exceptions.ConnectionError:
logger.exception('Connection error when trying to get metadata for %s', gun)
return None, None
except (requests.exceptions.RequestException, ValueError):
logger.exception('Failed to get metadata for %s', gun)
return None, None
except Non200ResponseException as ex:
logger.exception('Failed request for %s: %s', gun, str(ex))
return None, None
except InvalidMetadataException as ex:
logger.exception('Failed to parse targets from metadata', str(ex))
return None, None
return targets, expiration
def delete_metadata(self, namespace, repository):
"""
Deletes the TUF metadata for a repo
Args:
namespace: namespace containing the repository
repository: the repo to delete metadata for
Returns:
True if successful, False otherwise
"""
gun = self._gun(namespace, repository)
try:
self._delete(gun)
except requests.exceptions.Timeout:
logger.exception('Timeout when trying to delete metadata for %s', gun)
return False
except requests.exceptions.ConnectionError:
logger.exception('Connection error when trying to delete metadata for %s', gun)
return False
except (requests.exceptions.RequestException, ValueError):
logger.exception('Failed to delete metadata for %s', gun)
return False
except Non200ResponseException as ex:
logger.exception('Failed request for %s: %s', gun, str(ex))
return False
return True
def _gun(self, namespace, repository):
return join(self._gun_prefix, namespace, repository)
def _parse_signed(self, json_response):
""" Attempts to parse the targets from a metadata response """
signed = json_response.get('signed')
if not signed:
raise InvalidMetadataException("Could not find `signed` in metadata: %s" % json_response)
return signed
def _auth_header(self, gun, actions):
""" Generate a registry auth token for apostille"""
access = [{
'type': 'repository',
'name': gun,
'actions': actions,
}]
context, subject = build_context_and_subject(user=None, token=None, oauthtoken=None, tuf_root=QUAY_TUF_ROOT)
token = generate_bearer_token(self._config["SERVER_HOSTNAME"], subject, context, access,
TOKEN_VALIDITY_LIFETIME_S, self._instance_keys)
return {'Authorization': 'Bearer %s' % token}
def _get(self, gun, metadata_file):
return self._call('GET', '/v2/%s/_trust/tuf/%s' % (gun, metadata_file), headers=self._auth_header(gun, ['pull']))
def _delete(self, gun):
return self._call('DELETE', '/v2/%s/_trust/tuf/' % (gun), headers=self._auth_header(gun, ['*']))
def _request(self, method, endpoint, path, body, headers, params, timeout):
""" Issues an HTTP request to the signing endpoint. """
url = urljoin(endpoint, path)
logger.debug('%sing signing URL %s', method.upper(), url)
headers.update(DEFAULT_HTTP_HEADERS)
resp = self._client.request(method, url, json=body, params=params, timeout=timeout,
verify=MITM_CERT_PATH, headers=headers)
if resp.status_code // 100 != 2:
raise Non200ResponseException(resp)
return resp
def _call(self, method, path, params=None, body=None, headers=None):
""" Issues an HTTP request to signing service and handles failover for GET requests.
"""
if self._config is None:
raise Exception('Cannot call unconfigured signing service')
timeout = self._config.get('TUF_API_TIMEOUT_SECONDS', 1)
endpoint = self._config['TUF_SERVER']
with CloseForLongOperation(self._config):
# If the request isn't a read do not fail over.
if method != 'GET':
return self._request(method, endpoint, path, body, headers, params, timeout)
# The request is read-only and can failover.
all_endpoints = [endpoint] + self._config.get('TUF_READONLY_FAILOVER_ENDPOINTS', [])
return _failover_read_request(*[((self._request, endpoint, path, body, headers, params, timeout), {})
for endpoint in all_endpoints])
@failover
def _failover_read_request(request_fn, endpoint, path, body, headers, params, timeout):
""" This function auto-retries read-only requests until they return a 2xx status code. """
try:
return request_fn('GET', endpoint, path, body, headers, params, timeout)
except (requests.exceptions.RequestException, Non200ResponseException) as ex:
raise FailoverException(ex)