1
0
mirror of https://github.com/quay/quay.git synced 2025-04-16 23:03:13 +03:00

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>
This commit is contained in:
Kenny Lee Sin Cheong 2025-03-05 14:14:22 -05:00 committed by GitHub
parent ad3423e223
commit 5f8ca041e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1394 additions and 15 deletions

View File

@ -843,6 +843,12 @@ class DefaultConfig(ImmutableConfig):
# Feature Flag: Enables user to try the beta UI Environment
FEATURE_UI_V2 = False
FEATURE_UI_MODELCARD = True
UI_MODELCARD_ARTIFACT_TYPE = "application/x-mlmodel"
UI_MODELCARD_ANNOTATION: Optional[Dict[str, str]] = {}
UI_MODELCARD_LAYER_ANNOTATION: Optional[Dict[str, str]] = {
"org.opencontainers.image.title": "README.md"
}
# User feedback form for UI-V2
UI_V2_FEEDBACK_FORM = "https://7qdvkuo9rkj.typeform.com/to/XH5YE79P"

View File

@ -8,8 +8,10 @@ from typing import List, Optional
from flask import request
from app import label_validator, storage
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 (
@ -31,6 +33,7 @@ from endpoints.api import (
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>'
@ -96,6 +99,30 @@ def _manifest_dict(manifest):
}
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")
@ -107,7 +134,14 @@ class RepositoryManifest(RepositoryParamResource):
@require_repo_read(allow_for_superuser=True)
@nickname("getRepoManifest")
@disallow_for_app_repositories
def get(self, namespace_name, repository_name, manifestref):
@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()
@ -120,7 +154,18 @@ class RepositoryManifest(RepositoryParamResource):
if manifest is None or manifest.internal_manifest_bytes.as_unicode() == "":
raise NotFound()
return _manifest_dict(manifest)
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")

View File

@ -1,9 +1,14 @@
from test.fixtures import *
from mock import patch
from app import app as realapp
from data.registry_model import registry_model
from endpoints.api.manifest import RepositoryManifest
from endpoints.api.manifest import RepositoryManifest, _get_modelcard_layer_digest
from endpoints.api.test.shared import conduct_api_call
from endpoints.test.shared import client_with_identity
from features import FeatureNameValue
from image.oci.manifest import OCIManifest
from test.fixtures import *
from util.bytes import Bytes
def test_repository_manifest(app):
@ -22,3 +27,85 @@ def test_repository_manifest(app):
result = conduct_api_call(cl, RepositoryManifest, "GET", params, None, 200).json
assert result["digest"] == manifest_digest
assert result["manifest_data"]
ARTIFACT_MANIFEST = """{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"artifactType": "application/vnd.example+type",
"config": {
"mediaType": "application/vnd.oci.empty.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:d2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27458ed8229da804a26",
"size": 12,
"annotations": {
"org.opencontainers.image.title": "hello.txt"
}
}
],
"annotations": {
"org.opencontainers.image.created": "2023-08-03T00:21:51Z"
}
}"""
IMAGE_MANIFEST = """{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"size": 2,
"annotations": {
"hello": "world"
}
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:22af0898315a239117308d39acd80636326c4987510b0ec6848e58eb584ba82e",
"size": 6,
"annotations": {
"fun": "more cream",
"org.opencontainers.image.title": "cake.txt"
}
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar",
"digest": "sha256:be6fe11876282442bead98e8b24aca07f8972a763cd366c56b4b5f7bcdd23eac",
"size": 7,
"annotations": {
"org.opencontainers.image.title": "juice.txt"
}
}
],
"annotations": {
"foo": "bar"
}
}"""
def test_modelcar_layer(app):
manifest1 = OCIManifest(Bytes.for_string_or_unicode(ARTIFACT_MANIFEST))
manifest2 = OCIManifest(Bytes.for_string_or_unicode(IMAGE_MANIFEST))
realapp.config["UI_MODELCARD_ANNOTATION"] = {"foo": "bar"}
realapp.config["UI_MODELCARD_LAYER_ANNOTATION"] = {"org.opencontainers.image.title": "cake.txt"}
realapp.config["UI_MODELCARD_ARTIFACT_TYPE"] = "application/vnd.example+type"
with patch("features.UI_MODELCARD", FeatureNameValue("UI_MODELCARD", True)):
layer_digest1 = _get_modelcard_layer_digest(manifest1)
assert (
layer_digest1
== "sha256:d2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27458ed8229da804a26"
)
layer_digest2 = _get_modelcard_layer_digest(manifest2)
assert (
layer_digest2
== "sha256:22af0898315a239117308d39acd80636326c4987510b0ec6848e58eb584ba82e"
)

View File

@ -222,3 +222,5 @@ ASSIGN_OAUTH_TOKEN: FeatureNameValue
# Feature Flag: If set to true, supports setting notifications on image expiry
IMAGE_EXPIRY_TRIGGER: FeatureNameValue
UI_MODELCARD: FeatureNameValue

View File

@ -76,13 +76,13 @@ OCI_MANIFEST_URLS_KEY = "urls"
OCI_MANIFEST_ANNOTATIONS_KEY = "annotations"
OCI_MANIFEST_SUBJECT_KEY = "subject"
OCI_MANIFEST_ARTIFACT_TYPE_KEY = "artifactType"
OCI_MANIFEST_ANNOTATIONS_KEY = "annotations"
# Named tuples.
OCIManifestConfig = namedtuple("OCIManifestConfig", ["size", "digest"])
OCIManifestDescriptor = namedtuple("OCIManifestDescriptor", ["mediatype", "size", "digest"])
OCIManifestLayer = namedtuple(
"OCIManifestLayer", ["index", "digest", "is_remote", "urls", "compressed_size"]
"OCIManifestLayer",
["index", "digest", "is_remote", "urls", "compressed_size", "mediatype", "annotations"],
)
OCIManifestImageLayer = namedtuple(
@ -506,6 +506,7 @@ class OCIManifest(ManifestInterface):
for index, layer in enumerate(self._parsed[OCI_MANIFEST_LAYERS_KEY]):
content_type = layer[OCI_MANIFEST_MEDIATYPE_KEY]
is_remote = content_type in OCI_IMAGE_NON_DISTRIBUTABLE_LAYER_CONTENT_TYPES
layer_annotations = layer.get(OCI_MANIFEST_ANNOTATIONS_KEY, {})
try:
digest = digest_tools.Digest.parse_digest(layer[OCI_MANIFEST_DIGEST_KEY])
@ -520,6 +521,8 @@ class OCIManifest(ManifestInterface):
digest=digest,
is_remote=is_remote,
urls=layer.get(OCI_MANIFEST_URLS_KEY),
mediatype=content_type,
annotations=layer_annotations,
)
@ -561,7 +564,7 @@ class OCIManifestBuilder(object):
"""
self.annotations[key] = value
def add_layer(self, digest, size, urls=None):
def add_layer(self, digest, size, urls=None, annotations=None, mediatype=None):
"""
Adds a filesystem layer to the manifest.
"""
@ -572,6 +575,8 @@ class OCIManifestBuilder(object):
compressed_size=size,
urls=urls,
is_remote=bool(urls),
annotations=None,
mediatype=None,
)
)

View File

@ -38,6 +38,39 @@ SAMPLE_MANIFEST = """{
}
}"""
SAMPLE_MANIFEST2 = """{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"size": 7023,
"digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 32654,
"digest": "sha256:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09af107ee8f0"
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 16724,
"digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b"
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"size": 73109,
"digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736",
"annotations": {
"com.example.layerkey1": "value1",
"com.example.layerkey2": "value2"
}
}
],
"annotations": {
"com.example.key1": "value1",
"com.example.key2": "value2"
}
}"""
SAMPLE_REMOTE_MANIFEST = """{
"schemaVersion": 2,
@ -267,3 +300,16 @@ def test_validate_helm_oci_manifest():
HELM_CHART_LAYER_TYPES = ["application/tar+gzip"]
register_artifact_type(HELM_CHART_CONFIG_TYPE, HELM_CHART_LAYER_TYPES)
manifest = OCIManifest(Bytes.for_string_or_unicode(manifest_bytes))
def test_manifest_layer_annotations():
manifest = OCIManifest(Bytes.for_string_or_unicode(SAMPLE_MANIFEST2))
assert manifest.annotations == {"com.example.key1": "value1", "com.example.key2": "value2"}
for layer in manifest.filesystem_layers:
assert hasattr(layer, "annotations")
if layer.annotations:
assert layer.annotations == {
"com.example.layerkey1": "value1",
"com.example.layerkey2": "value2",
}

View File

@ -1425,6 +1425,26 @@ CONFIG_SCHEMA = {
"description": "Enables user to try the beta UI Environment",
"x-example": False,
},
"FEATURE_UI_MODELCARD": {
"type": "boolean",
"description": "Enables modelcard image tab in UI",
"x-example": False,
},
"UI_MODELCARD_ARTIFACT_TYPE": {
"type": "str",
"description": "Defines the modelcard artifact type",
"x-example": "application/x-mlmodel",
},
"UI_MODELCARD_ANNOTATION": {
"type": "object",
"description": "Defines the layer annotation of the modelcard stored in an OCI image",
"x-example": {},
},
"UI_MODELCARD_LAYER_ANNOTATION": {
"type": "object",
"description": "Defines the layer annotation of the modelcard stored in an OCI image",
"x-example": {"org.opencontainers.image.title": "README.md"},
},
"EXPORT_COMPLIANCE_ENDPOINT": {
"type": "string",
"description": "The Red Hat Export Compliance Service Endpoint (only used in Quay.io)",

1128
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,10 +27,12 @@
"null-loader": "^4.0.1",
"process": "^0.11.10",
"react": "^17.0.2",
"react-remark": "^2.1.0",
"react-router-dom": "^6.15.0",
"react-scripts": "5.0.1",
"recoil": "^0.7.7",
"recoil-persist": "^4.2.0",
"remark-gemoji": "^8.0.0",
"ts-jest": "^29.1.1",
"typescript": "^4.6.3",
"use-react-router-breadcrumbs": "^4.0.1",

View File

@ -24,6 +24,7 @@ export interface Tag {
manifest_list: ManifestList;
expiration?: string;
end_ts?: number;
modelcard?: string;
}
export interface ManifestList {
@ -70,6 +71,7 @@ export interface ManifestByDigestResponse {
manifest_data: string;
config_media_type?: any;
layers?: any;
modelcard?: string;
}
export interface SecurityDetailsResponse {
@ -326,9 +328,10 @@ export async function getManifestByDigest(
org: string,
repo: string,
digest: string,
include_modelcard: boolean,
) {
const response: AxiosResponse<ManifestByDigestResponse> = await axios.get(
`/api/v1/repository/${org}/${repo}/manifest/${digest}`,
`/api/v1/repository/${org}/${repo}/manifest/${digest}?include_modelcard=true`,
);
assertHttpCode(response.status, 200);
return response.data;

View File

@ -0,0 +1,20 @@
import {PageSection, Divider, TextContent} from '@patternfly/react-core';
import {Remark} from 'react-remark';
export function ModelCard(props: ModelCardProps) {
return (
<>
<Divider />
<PageSection>
<TextContent>
<Remark>{props.modelCard}</Remark>
</TextContent>
</PageSection>
</>
);
}
interface ModelCardProps {
modelCard: string;
}

View File

@ -22,6 +22,7 @@ import {
parseRepoNameFromUrl,
parseTagNameFromUrl,
} from '../../libs/utils';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
import TagArchSelect from './TagDetailsArchSelect';
import TagTabs from './TagDetailsTabs';
@ -46,6 +47,8 @@ export default function TagDetails() {
},
});
const quayConfig = useQuayConfig();
// TODO: refactor, need more checks when parsing path
const location = useLocation();
@ -71,10 +74,15 @@ export default function TagDetails() {
}
const tagResp: Tag = resp.tags[0];
if (tagResp.is_manifest_list) {
if (tagResp.is_manifest_list || quayConfig.features.UI_MODELCARD) {
const manifestResp: ManifestByDigestResponse =
await getManifestByDigest(org, repo, tagResp.manifest_digest);
tagResp.manifest_list = JSON.parse(manifestResp.manifest_data);
await getManifestByDigest(org, repo, tagResp.manifest_digest, true);
if (tagResp.is_manifest_list) {
tagResp.manifest_list = JSON.parse(manifestResp.manifest_data);
}
if (manifestResp.modelcard) {
tagResp.modelcard = manifestResp.modelcard;
}
}
// Confirm requested digest exists for this tag

View File

@ -3,12 +3,14 @@ import {useSearchParams, useNavigate, useLocation} from 'react-router-dom';
import {useState} from 'react';
import Details from './Details/Details';
import SecurityReport from './SecurityReport/SecurityReport';
import {ModelCard} from './ModelCard/ModelCard';
import {Tag} from 'src/resources/TagResource';
import {TabIndex} from './Types';
import {Packages} from './Packages/Packages';
import ErrorBoundary from 'src/components/errors/ErrorBoundary';
import {isErrorString} from 'src/resources/ErrorHandling';
import RequestError from 'src/components/errors/RequestError';
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
// Return the tab as an enum or null if it does not exist
function getTabIndex(tab: string) {
@ -18,6 +20,8 @@ function getTabIndex(tab: string) {
}
export default function TagTabs(props: TagTabsProps) {
const quayConfig = useQuayConfig();
const [activeTabKey, setActiveTabKey] = useState<TabIndex>(TabIndex.Details);
const navigate = useNavigate();
const location = useLocation();
@ -28,6 +32,7 @@ export default function TagTabs(props: TagTabsProps) {
if (requestedTabIndex && requestedTabIndex !== activeTabKey) {
setActiveTabKey(requestedTabIndex);
}
return (
<Tabs
activeKey={activeTabKey}
@ -59,6 +64,13 @@ export default function TagTabs(props: TagTabsProps) {
>
<Packages />
</Tab>
<Tab
eventKey={TabIndex.ModelCard}
title={<TabTitleText>ModelCard</TabTitleText>}
isHidden={!quayConfig?.features?.UI_MODELCARD || !props.tag.modelcard}
>
<ModelCard modelCard={props.tag.modelcard} />
</Tab>
</Tabs>
);
}

View File

@ -3,4 +3,5 @@ export enum TabIndex {
Layers = 'layers',
SecurityReport = 'securityreport',
Packages = 'packages',
ModelCard = 'modelcard',
}