1
0
mirror of https://github.com/quay/quay.git synced 2025-04-18 10:44:06 +03:00
quay/endpoints/api/manifest.py
Kenny Lee Sin Cheong 5f8ca041e7
ui: implement change to render modelcard stored in layers (PROJQUAY-8642) (#3692)
* ui: implement change to render modelcard stored in layers (PROJQUAY-8412)

When a manifest has certain annotations or artifactTypes, render the
applicable modelcard markdown in a new tags detail tab.

* removing untar when fetching model card

* removing extra api calls

* Add modelcar check tests

---------

Co-authored-by: bcaton <bcaton@redhat.com>
2025-03-05 19:14:22 +00:00

362 lines
12 KiB
Python

"""
Manage the manifests of a repository.
"""
import json
import logging
from datetime import datetime
from typing import List, Optional
from flask import request
import features
from app import app, label_validator, storage
from data.model import InvalidLabelKeyException, InvalidMediaTypeException
from data.model.oci.retriever import RepositoryContentRetriever
from data.registry_model import registry_model
from digest import digest_tools
from endpoints.api import (
RepositoryParamResource,
abort,
api,
disallow_for_app_repositories,
disallow_for_non_normal_repositories,
disallow_for_user_namespace,
format_date,
log_action,
nickname,
parse_args,
path_param,
query_param,
require_repo_read,
require_repo_write,
resource,
validate_json_request,
)
from endpoints.exception import NotFound
from util.parsing import truthy_bool
from util.validation import VALID_LABEL_KEY_REGEX
BASE_MANIFEST_ROUTE = '/v1/repository/<apirepopath:repository>/manifest/<regex("{0}"):manifestref>'
MANIFEST_DIGEST_ROUTE = BASE_MANIFEST_ROUTE.format(digest_tools.DIGEST_PATTERN)
ALLOWED_LABEL_MEDIA_TYPES: List[Optional[str]] = ["text/plain", "application/json"]
logger = logging.getLogger(__name__)
def _label_dict(label):
return {
"id": label.uuid,
"key": label.key,
"value": label.value,
"source_type": label.source_type_name,
"media_type": label.media_type_name,
}
def _layer_dict(manifest_layer, index):
# NOTE: The `command` in the layer is either a JSON string of an array (schema 1) or
# a single string (schema 2). The block below normalizes it to have the same format.
command = None
if manifest_layer.command:
try:
command = json.loads(manifest_layer.command)
except (TypeError, ValueError):
command = [manifest_layer.command]
return {
"index": index,
"compressed_size": manifest_layer.compressed_size,
"is_remote": manifest_layer.is_remote,
"urls": manifest_layer.urls,
"command": command,
"comment": manifest_layer.comment,
"author": manifest_layer.author,
"blob_digest": str(manifest_layer.blob_digest),
"created_datetime": format_date(manifest_layer.created_datetime),
}
def _manifest_dict(manifest):
layers = None
if not manifest.is_manifest_list:
layers = registry_model.list_manifest_layers(manifest, storage)
if layers is None:
logger.debug("Missing layers for manifest `%s`", manifest.digest)
abort(404)
return {
"digest": manifest.digest,
"is_manifest_list": manifest.is_manifest_list,
"manifest_data": manifest.internal_manifest_bytes.as_unicode(),
"config_media_type": manifest.config_media_type,
"layers_compressed_size": manifest.layers_compressed_size
if not manifest.is_manifest_list
else 0,
"layers": (
[_layer_dict(lyr.layer_info, idx) for idx, lyr in enumerate(layers)] if layers else None
),
}
def _get_modelcard_layer_digest(parsed):
manifest_modelcard_annotation = app.config["UI_MODELCARD_ANNOTATION"]
manifest_layer_modelcard_annotation = app.config["UI_MODELCARD_LAYER_ANNOTATION"]
layer_digest = None
if parsed.artifact_type and parsed.artifact_type == app.config["UI_MODELCARD_ARTIFACT_TYPE"]:
layer_digest = str(parsed.filesystem_layers[-1].digest)
elif (
manifest_modelcard_annotation
and hasattr(parsed, "annotations")
and manifest_modelcard_annotation.items() <= parsed.annotations.items()
):
for layer in parsed.filesystem_layers:
if (
hasattr(layer, "annotations")
and manifest_layer_modelcard_annotation.items() <= layer.annotations.items()
):
layer_digest = str(layer.digest)
break
return layer_digest
@resource(MANIFEST_DIGEST_ROUTE)
@path_param("repository", "The full path of the repository. e.g. namespace/name")
@path_param("manifestref", "The digest of the manifest")
class RepositoryManifest(RepositoryParamResource):
"""
Resource for retrieving a specific repository manifest.
"""
@require_repo_read(allow_for_superuser=True)
@nickname("getRepoManifest")
@disallow_for_app_repositories
@parse_args()
@query_param(
"include_modelcard",
"If specified, include modelcard markdown from image, if any",
type=truthy_bool,
default=False,
)
def get(self, namespace_name, repository_name, manifestref, parsed_args):
repo_ref = registry_model.lookup_repository(namespace_name, repository_name)
if repo_ref is None:
raise NotFound()
manifest = registry_model.lookup_manifest_by_digest(repo_ref, manifestref)
# sub-manifests created via pull-thru proxy cache as part of a manifest list
# pull will not contain the manifest bytes unless individually pulled.
# to avoid a parsing error from `_manifest_dict`, we return a 404 either
# when the manifest doesn't exist or if the manifest bytes are empty.
if manifest is None or manifest.internal_manifest_bytes.as_unicode() == "":
raise NotFound()
manifest_dict = _manifest_dict(manifest)
if features.UI_MODELCARD and parsed_args["include_modelcard"]:
parsed = manifest.get_parsed_manifest()
layer_digest = _get_modelcard_layer_digest(parsed)
if layer_digest:
retriever = RepositoryContentRetriever(repo_ref._db_id, storage)
content = retriever.get_blob_bytes_with_digest(layer_digest)
manifest_dict["modelcard"] = content.decode("utf-8")
return manifest_dict
@resource(MANIFEST_DIGEST_ROUTE + "/labels")
@path_param("repository", "The full path of the repository. e.g. namespace/name")
@path_param("manifestref", "The digest of the manifest")
class RepositoryManifestLabels(RepositoryParamResource):
"""
Resource for listing the labels on a specific repository manifest.
"""
schemas = {
"AddLabel": {
"type": "object",
"description": "Adds a label to a manifest",
"required": [
"key",
"value",
"media_type",
],
"properties": {
"key": {
"type": "string",
"description": "The key for the label",
},
"value": {
"type": "string",
"description": "The value for the label",
},
"media_type": {
"type": ["string", "null"],
"description": "The media type for this label",
"enum": ALLOWED_LABEL_MEDIA_TYPES + [None],
},
},
},
}
@require_repo_read(allow_for_superuser=True)
@nickname("listManifestLabels")
@disallow_for_app_repositories
@parse_args()
@query_param(
"filter",
"If specified, only labels matching the given prefix will be returned",
type=str,
default=None,
)
def get(self, namespace_name, repository_name, manifestref, parsed_args):
repo_ref = registry_model.lookup_repository(namespace_name, repository_name)
if repo_ref is None:
raise NotFound()
manifest = registry_model.lookup_manifest_by_digest(repo_ref, manifestref)
if manifest is None:
raise NotFound()
labels = registry_model.list_manifest_labels(manifest, parsed_args["filter"])
if labels is None:
raise NotFound()
return {"labels": [_label_dict(label) for label in labels]}
@require_repo_write(allow_for_superuser=True)
@nickname("addManifestLabel")
@disallow_for_app_repositories
@disallow_for_non_normal_repositories
@disallow_for_user_namespace
@validate_json_request("AddLabel")
def post(self, namespace_name, repository_name, manifestref):
"""
Adds a new label into the tag manifest.
"""
label_data = request.get_json()
# Check for any reserved prefixes.
if label_validator.has_reserved_prefix(label_data["key"]):
abort(400, message="Label has a reserved prefix")
repo_ref = registry_model.lookup_repository(namespace_name, repository_name)
if repo_ref is None:
raise NotFound()
manifest = registry_model.lookup_manifest_by_digest(repo_ref, manifestref)
if manifest is None:
raise NotFound()
label = None
try:
label = registry_model.create_manifest_label(
manifest, label_data["key"], label_data["value"], "api", label_data["media_type"]
)
except InvalidLabelKeyException:
message = (
"Label is of an invalid format or missing please "
+ "use %s format for labels" % VALID_LABEL_KEY_REGEX
)
abort(400, message=message)
except InvalidMediaTypeException:
message = (
"Media type is invalid please use a valid media type: text/plain, application/json"
)
abort(400, message=message)
if label is None:
raise NotFound()
metadata = {
"id": label.uuid,
"key": label.key,
"value": label.value,
"manifest_digest": manifestref,
"media_type": label.media_type_name,
"namespace": namespace_name,
"repo": repository_name,
}
log_action("manifest_label_add", namespace_name, metadata, repo_name=repository_name)
resp = {"label": _label_dict(label)}
repo_string = "%s/%s" % (namespace_name, repository_name)
headers = {
"Location": api.url_for(
ManageRepositoryManifestLabel,
repository=repo_string,
manifestref=manifestref,
labelid=label.uuid,
),
}
return resp, 201, headers
@resource(MANIFEST_DIGEST_ROUTE + "/labels/<labelid>")
@path_param("repository", "The full path of the repository. e.g. namespace/name")
@path_param("manifestref", "The digest of the manifest")
@path_param("labelid", "The ID of the label")
class ManageRepositoryManifestLabel(RepositoryParamResource):
"""
Resource for managing the labels on a specific repository manifest.
"""
@require_repo_read(allow_for_superuser=True)
@nickname("getManifestLabel")
@disallow_for_app_repositories
def get(self, namespace_name, repository_name, manifestref, labelid):
"""
Retrieves the label with the specific ID under the manifest.
"""
repo_ref = registry_model.lookup_repository(namespace_name, repository_name)
if repo_ref is None:
raise NotFound()
manifest = registry_model.lookup_manifest_by_digest(repo_ref, manifestref)
if manifest is None:
raise NotFound()
label = registry_model.get_manifest_label(manifest, labelid)
if label is None:
raise NotFound()
return _label_dict(label)
@require_repo_write(allow_for_superuser=True)
@nickname("deleteManifestLabel")
@disallow_for_app_repositories
@disallow_for_non_normal_repositories
@disallow_for_user_namespace
def delete(self, namespace_name, repository_name, manifestref, labelid):
"""
Deletes an existing label from a manifest.
"""
repo_ref = registry_model.lookup_repository(namespace_name, repository_name)
if repo_ref is None:
raise NotFound()
manifest = registry_model.lookup_manifest_by_digest(repo_ref, manifestref)
if manifest is None:
raise NotFound()
deleted = registry_model.delete_manifest_label(manifest, labelid)
if deleted is None:
raise NotFound()
metadata = {
"id": labelid,
"key": deleted.key,
"value": deleted.value,
"manifest_digest": manifestref,
"namespace": namespace_name,
"repo": repository_name,
}
log_action("manifest_label_delete", namespace_name, metadata, repo_name=repository_name)
return "", 204