c0e6660ac9
We don't need the manifest returned by the tags API except for manifest lists, so just load it lazily
330 lines
12 KiB
Python
330 lines
12 KiB
Python
""" Manage the tags of a repository. """
|
|
import json
|
|
|
|
from datetime import datetime
|
|
from flask import request, abort
|
|
|
|
from app import storage
|
|
from auth.auth_context import get_authenticated_user
|
|
from data.registry_model import registry_model
|
|
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
|
|
RepositoryParamResource, log_action, validate_json_request, path_param,
|
|
parse_args, query_param, truthy_bool, disallow_for_app_repositories,
|
|
format_date)
|
|
from endpoints.api.image import image_dict
|
|
from endpoints.exception import NotFound, InvalidRequest
|
|
from util.names import TAG_ERROR, TAG_REGEX
|
|
|
|
|
|
def _tag_dict(tag):
|
|
tag_info = {
|
|
'name': tag.name,
|
|
'reversion': tag.reversion,
|
|
}
|
|
|
|
if tag.lifetime_start_ts > 0:
|
|
tag_info['start_ts'] = tag.lifetime_start_ts
|
|
|
|
if tag.lifetime_end_ts > 0:
|
|
tag_info['end_ts'] = tag.lifetime_end_ts
|
|
|
|
# TODO(jschorr): Remove this once fully on OCI data model.
|
|
if tag.legacy_image_if_present:
|
|
tag_info['docker_image_id'] = tag.legacy_image.docker_image_id
|
|
tag_info['image_id'] = tag.legacy_image.docker_image_id
|
|
tag_info['size'] = tag.legacy_image.aggregate_size
|
|
|
|
# TODO(jschorr): Remove this check once fully on OCI data model.
|
|
if tag.manifest_digest:
|
|
tag_info['manifest_digest'] = tag.manifest_digest
|
|
|
|
if tag.manifest:
|
|
tag_info['is_manifest_list'] = tag.manifest.is_manifest_list
|
|
|
|
if 'size' not in tag_info:
|
|
tag_info['size'] = tag.manifest.layers_compressed_size
|
|
|
|
if tag.lifetime_start_ts > 0:
|
|
last_modified = format_date(datetime.utcfromtimestamp(tag.lifetime_start_ts))
|
|
tag_info['last_modified'] = last_modified
|
|
|
|
if tag.lifetime_end_ts is not None:
|
|
expiration = format_date(datetime.utcfromtimestamp(tag.lifetime_end_ts))
|
|
tag_info['expiration'] = expiration
|
|
|
|
return tag_info
|
|
|
|
|
|
@resource('/v1/repository/<apirepopath:repository>/tag/')
|
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
|
class ListRepositoryTags(RepositoryParamResource):
|
|
""" Resource for listing full repository tag history, alive *and dead*. """
|
|
|
|
@require_repo_read
|
|
@disallow_for_app_repositories
|
|
@parse_args()
|
|
@query_param('specificTag', 'Filters the tags to the specific tag.', type=str, default='')
|
|
@query_param('limit', 'Limit to the number of results to return per page. Max 100.', type=int,
|
|
default=50)
|
|
@query_param('page', 'Page index for the results. Default 1.', type=int, default=1)
|
|
@query_param('onlyActiveTags', 'Filter to only active tags.', type=truthy_bool, default=False)
|
|
@nickname('listRepoTags')
|
|
def get(self, namespace, repository, parsed_args):
|
|
specific_tag = parsed_args.get('specificTag') or None
|
|
page = max(1, parsed_args.get('page', 1))
|
|
limit = min(100, max(1, parsed_args.get('limit', 50)))
|
|
active_tags_only = parsed_args.get('onlyActiveTags')
|
|
|
|
repo_ref = registry_model.lookup_repository(namespace, repository)
|
|
if repo_ref is None:
|
|
raise NotFound()
|
|
|
|
history, has_more = registry_model.list_repository_tag_history(repo_ref, page=page,
|
|
size=limit,
|
|
specific_tag_name=specific_tag,
|
|
active_tags_only=active_tags_only)
|
|
return {
|
|
'tags': [_tag_dict(tag) for tag in history],
|
|
'page': page,
|
|
'has_additional': has_more,
|
|
}
|
|
|
|
|
|
@resource('/v1/repository/<apirepopath:repository>/tag/<tag>')
|
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
|
@path_param('tag', 'The name of the tag')
|
|
class RepositoryTag(RepositoryParamResource):
|
|
""" Resource for managing repository tags. """
|
|
schemas = {
|
|
'ChangeTag': {
|
|
'type': 'object',
|
|
'description': 'Makes changes to a specific tag',
|
|
'properties': {
|
|
'image': {
|
|
'type': ['string', 'null'],
|
|
'description': '(If specified) Image identifier to which the tag should point',
|
|
},
|
|
'expiration': {
|
|
'type': ['number', 'null'],
|
|
'description': '(If specified) The expiration for the image',
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_repo_write
|
|
@disallow_for_app_repositories
|
|
@nickname('changeTag')
|
|
@validate_json_request('ChangeTag')
|
|
def put(self, namespace, repository, tag):
|
|
""" Change which image a tag points to or create a new tag."""
|
|
if not TAG_REGEX.match(tag):
|
|
abort(400, TAG_ERROR)
|
|
|
|
repo_ref = registry_model.lookup_repository(namespace, repository)
|
|
if repo_ref is None:
|
|
raise NotFound()
|
|
|
|
if 'expiration' in request.get_json():
|
|
tag_ref = registry_model.get_repo_tag(repo_ref, tag)
|
|
if tag_ref is None:
|
|
raise NotFound()
|
|
|
|
expiration = request.get_json().get('expiration')
|
|
expiration_date = None
|
|
if expiration is not None:
|
|
try:
|
|
expiration_date = datetime.utcfromtimestamp(float(expiration))
|
|
except ValueError:
|
|
abort(400)
|
|
|
|
if expiration_date <= datetime.now():
|
|
abort(400)
|
|
|
|
existing_end_ts, ok = registry_model.change_repository_tag_expiration(tag_ref,
|
|
expiration_date)
|
|
if ok:
|
|
if not (existing_end_ts is None and expiration_date is None):
|
|
log_action('change_tag_expiration', namespace, {
|
|
'username': get_authenticated_user().username,
|
|
'repo': repository,
|
|
'tag': tag,
|
|
'namespace': namespace,
|
|
'expiration_date': expiration_date,
|
|
'old_expiration_date': existing_end_ts
|
|
}, repo_name=repository)
|
|
else:
|
|
raise InvalidRequest('Could not update tag expiration; Tag has probably changed')
|
|
|
|
if 'image' in request.get_json() or 'manifest_digest' in request.get_json():
|
|
existing_tag = registry_model.get_repo_tag(repo_ref, tag, include_legacy_image=True)
|
|
|
|
manifest_or_image = None
|
|
image_id = None
|
|
manifest_digest = None
|
|
|
|
if 'image' in request.get_json():
|
|
image_id = request.get_json()['image']
|
|
manifest_or_image = registry_model.get_legacy_image(repo_ref, image_id)
|
|
else:
|
|
manifest_digest = request.get_json()['manifest_digest']
|
|
manifest_or_image = registry_model.lookup_manifest_by_digest(repo_ref, manifest_digest)
|
|
|
|
if manifest_or_image is None:
|
|
raise NotFound()
|
|
|
|
# TODO(jschorr): Remove this check once fully on V22
|
|
existing_manifest_digest = None
|
|
if existing_tag:
|
|
existing_manifest = registry_model.get_manifest_for_tag(existing_tag)
|
|
existing_manifest_digest = existing_manifest.digest if existing_manifest else None
|
|
|
|
if not registry_model.retarget_tag(repo_ref, tag, manifest_or_image, storage):
|
|
raise InvalidRequest('Could not move tag')
|
|
|
|
username = get_authenticated_user().username
|
|
|
|
log_action('move_tag' if existing_tag else 'create_tag', namespace, {
|
|
'username': username,
|
|
'repo': repository,
|
|
'tag': tag,
|
|
'namespace': namespace,
|
|
'image': image_id,
|
|
'manifest_digest': manifest_digest,
|
|
'original_image': (existing_tag.legacy_image.docker_image_id
|
|
if existing_tag and existing_tag.legacy_image_if_present
|
|
else None),
|
|
'original_manifest_digest': existing_manifest_digest,
|
|
}, repo_name=repository)
|
|
|
|
return 'Updated', 201
|
|
|
|
@require_repo_write
|
|
@disallow_for_app_repositories
|
|
@nickname('deleteFullTag')
|
|
def delete(self, namespace, repository, tag):
|
|
""" Delete the specified repository tag. """
|
|
repo_ref = registry_model.lookup_repository(namespace, repository)
|
|
if repo_ref is None:
|
|
raise NotFound()
|
|
|
|
registry_model.delete_tag(repo_ref, tag)
|
|
|
|
username = get_authenticated_user().username
|
|
log_action('delete_tag', namespace,
|
|
{'username': username,
|
|
'repo': repository,
|
|
'namespace': namespace,
|
|
'tag': tag}, repo_name=repository)
|
|
|
|
return '', 204
|
|
|
|
|
|
@resource('/v1/repository/<apirepopath:repository>/tag/<tag>/images')
|
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
|
@path_param('tag', 'The name of the tag')
|
|
class RepositoryTagImages(RepositoryParamResource):
|
|
""" Resource for listing the images in a specific repository tag. """
|
|
|
|
@require_repo_read
|
|
@nickname('listTagImages')
|
|
@disallow_for_app_repositories
|
|
@parse_args()
|
|
@query_param('owned', 'If specified, only images wholely owned by this tag are returned.',
|
|
type=truthy_bool, default=False)
|
|
def get(self, namespace, repository, tag, parsed_args):
|
|
""" List the images for the specified repository tag. """
|
|
repo_ref = registry_model.lookup_repository(namespace, repository)
|
|
if repo_ref is None:
|
|
raise NotFound()
|
|
|
|
tag_ref = registry_model.get_repo_tag(repo_ref, tag, include_legacy_image=True)
|
|
if tag_ref is None:
|
|
raise NotFound()
|
|
|
|
if tag_ref.legacy_image_if_present is None:
|
|
return {'images': []}
|
|
|
|
image_id = tag_ref.legacy_image.docker_image_id
|
|
|
|
all_images = None
|
|
if parsed_args['owned']:
|
|
# TODO(jschorr): Remove the `owned` image concept once we are fully on V2_2.
|
|
all_images = registry_model.get_legacy_images_owned_by_tag(tag_ref)
|
|
else:
|
|
image_with_parents = registry_model.get_legacy_image(repo_ref, image_id, include_parents=True)
|
|
if image_with_parents is None:
|
|
raise NotFound()
|
|
|
|
all_images = [image_with_parents] + image_with_parents.parents
|
|
|
|
return {
|
|
'images': [image_dict(image) for image in all_images],
|
|
}
|
|
|
|
|
|
@resource('/v1/repository/<apirepopath:repository>/tag/<tag>/restore')
|
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
|
@path_param('tag', 'The name of the tag')
|
|
class RestoreTag(RepositoryParamResource):
|
|
""" Resource for restoring a repository tag back to a previous image. """
|
|
schemas = {
|
|
'RestoreTag': {
|
|
'type': 'object',
|
|
'description': 'Restores a tag to a specific image',
|
|
'required': ['image',],
|
|
'properties': {
|
|
'image': {
|
|
'type': 'string',
|
|
'description': 'Image identifier to which the tag should point',
|
|
},
|
|
'manifest_digest': {
|
|
'type': 'string',
|
|
'description': 'If specified, the manifest digest that should be used',
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
@require_repo_write
|
|
@disallow_for_app_repositories
|
|
@nickname('restoreTag')
|
|
@validate_json_request('RestoreTag')
|
|
def post(self, namespace, repository, tag):
|
|
""" Restores a repository tag back to a previous image in the repository. """
|
|
repo_ref = registry_model.lookup_repository(namespace, repository)
|
|
if repo_ref is None:
|
|
raise NotFound()
|
|
|
|
# Restore the tag back to the previous image.
|
|
image_id = request.get_json()['image']
|
|
manifest_digest = request.get_json().get('manifest_digest', None)
|
|
|
|
# Data for logging the reversion/restoration.
|
|
username = get_authenticated_user().username
|
|
log_data = {
|
|
'username': username,
|
|
'repo': repository,
|
|
'tag': tag,
|
|
'image': image_id,
|
|
}
|
|
|
|
manifest_or_legacy_image = None
|
|
if manifest_digest is not None:
|
|
manifest_or_legacy_image = registry_model.lookup_manifest_by_digest(repo_ref, manifest_digest,
|
|
allow_dead=True)
|
|
else:
|
|
manifest_or_legacy_image = registry_model.get_legacy_image(repo_ref, image_id)
|
|
|
|
if manifest_or_legacy_image is None:
|
|
raise NotFound()
|
|
|
|
if not registry_model.retarget_tag(repo_ref, tag, manifest_or_legacy_image, storage,
|
|
is_reversion=True):
|
|
raise InvalidRequest('Could not restore tag')
|
|
|
|
log_action('revert_tag', namespace, log_data, repo_name=repository)
|
|
|
|
return {
|
|
'image_id': image_id,
|
|
}
|