From 9d5607cd8d351efb42855c890ecd9747c8c5c4ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20Nagygy=C3=B6rgy?= Date: Thu, 12 Dec 2024 13:56:02 +0100 Subject: [PATCH] feat: merge docker-labels --- web/crux-ui/public/sort-alphabetical-asc.svg | 3 + web/crux-ui/public/sort-alphabetical-desc.svg | 1 + web/crux-ui/public/sort-date-asc.svg | 1 + web/crux-ui/public/sort-date-desc.svg | 1 + .../versions/images/edit-image-tags.tsx | 105 ++++++--- .../versions/images/tag-sort-toggle.tsx | 70 ++++++ .../projects/versions/use-version-state.ts | 11 +- .../projects/versions/version-view-list.tsx | 3 + web/crux-ui/src/elements/dyo-radio-button.tsx | 5 +- web/crux-ui/src/models/registry.ts | 6 +- web/crux-ui/src/validations/container.ts | 52 ++++- web/crux/package-lock.json | 54 ++++- web/crux/package.json | 2 + web/crux/src/app/image/image.module.ts | 3 +- .../guards/registry.auth.validation.guard.ts | 2 +- .../app/registry/registry-client.provider.ts | 13 +- .../registry-clients/cached-hub-api-client.ts | 29 ++- .../registry-clients/github-api-client.ts | 49 +++-- .../registry-clients/gitlab-api-client.ts | 42 +++- .../registry-clients/google-api-client.ts | 40 +++- .../registry-clients/hub-api-client.ts | 27 ++- .../private-hub-api-client.ts | 37 +++- .../registry-clients/registry-api-client.ts | 22 +- .../registry-clients/unchecked-api-client.ts | 6 +- .../{v2-labels.ts => v2-http-api-client.ts} | 206 +++++++++++++++--- ...pi-client.ts => v2-registry-api-client.ts} | 42 +++- web/crux/src/app/registry/registry.message.ts | 6 +- web/crux/src/app/registry/registry.module.ts | 3 +- 28 files changed, 709 insertions(+), 132 deletions(-) create mode 100644 web/crux-ui/public/sort-alphabetical-asc.svg create mode 100644 web/crux-ui/public/sort-alphabetical-desc.svg create mode 100644 web/crux-ui/public/sort-date-asc.svg create mode 100644 web/crux-ui/public/sort-date-desc.svg create mode 100644 web/crux-ui/src/components/projects/versions/images/tag-sort-toggle.tsx rename web/crux/src/app/registry/registry-clients/{v2-labels.ts => v2-http-api-client.ts} (52%) rename web/crux/src/app/registry/registry-clients/{v2-api-client.ts => v2-registry-api-client.ts} (75%) diff --git a/web/crux-ui/public/sort-alphabetical-asc.svg b/web/crux-ui/public/sort-alphabetical-asc.svg new file mode 100644 index 000000000..a5664c634 --- /dev/null +++ b/web/crux-ui/public/sort-alphabetical-asc.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/crux-ui/public/sort-alphabetical-desc.svg b/web/crux-ui/public/sort-alphabetical-desc.svg new file mode 100644 index 000000000..c3a288b32 --- /dev/null +++ b/web/crux-ui/public/sort-alphabetical-desc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/crux-ui/public/sort-date-asc.svg b/web/crux-ui/public/sort-date-asc.svg new file mode 100644 index 000000000..3d5f7c101 --- /dev/null +++ b/web/crux-ui/public/sort-date-asc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/crux-ui/public/sort-date-desc.svg b/web/crux-ui/public/sort-date-desc.svg new file mode 100644 index 000000000..27881960e --- /dev/null +++ b/web/crux-ui/public/sort-date-desc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/crux-ui/src/components/projects/versions/images/edit-image-tags.tsx b/web/crux-ui/src/components/projects/versions/images/edit-image-tags.tsx index 680e633c2..423aa3156 100644 --- a/web/crux-ui/src/components/projects/versions/images/edit-image-tags.tsx +++ b/web/crux-ui/src/components/projects/versions/images/edit-image-tags.tsx @@ -3,8 +3,13 @@ import { DyoInput } from '@app/elements/dyo-input' import DyoMessage from '@app/elements/dyo-message' import DyoRadioButton from '@app/elements/dyo-radio-button' import { TextFilter, textFilterFor, useFilters } from '@app/hooks/use-filters' +import { RegistryImageTag } from '@app/models' import useTranslation from 'next-translate/useTranslation' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import TagSortToggle, { SortState } from './tag-sort-toggle' +import LoadingIndicator from '@app/elements/loading-indicator' +import { DyoLabel } from '@app/elements/dyo-label' +import DyoIndicator from '@app/elements/dyo-indicator' interface ImageTagInputProps { disabled?: boolean @@ -13,25 +18,55 @@ interface ImageTagInputProps { } type ImageTagSelectListProps = ImageTagInputProps & { - tags: string[] + tags: Record + loadingTags: boolean } +type Entry = [string, RegistryImageTag] + const ImageTagSelectList = (props: ImageTagSelectListProps) => { - const { disabled, tags, selected: propsSelected, onTagSelected } = props + const { disabled, tags, selected: propsSelected, onTagSelected, loadingTags } = props const { t } = useTranslation('images') const [selected, setSelected] = useState(propsSelected) + const [sortState, setSortState] = useState({ + mode: 'date', + dir: 1, + }) - const filters = useFilters({ - filters: [textFilterFor(it => [it])], - initialData: tags, + const filters = useFilters({ + filters: [textFilterFor(it => [it[0]])], + initialData: Object.entries(tags), initialFilter: { text: '', }, }) - useEffect(() => filters.setItems(tags), [filters, tags]) + useEffect(() => filters.setItems(Object.entries(tags)), [tags]) + + const sortedItems = useMemo(() => { + const items = filters.filtered + switch (sortState.mode) { + case 'alphabetic': + return items.sort((a, b) => b[0].localeCompare(a[0]) * sortState.dir) + case 'date': + return items.sort((a, b) => { + const aDate = Date.parse(a[1].created) + const bDate = Date.parse(b[1].created) + + return Math.sign(bDate - aDate) * sortState.dir + }) + default: + return items + } + }, [sortState, filters.filtered]) + + const isTagNewer = (tagIndex: number, currentTagIndex: number) => + currentTagIndex >= 0 && + ((sortState.dir === -1 && currentTagIndex < tagIndex) || (sortState.dir === 1 && currentTagIndex > tagIndex)) + + const selectedTagIndex = selected ? sortedItems.findIndex(it => it[0] === selected) : -1 return (
@@ -45,27 +80,43 @@ const ImageTagSelectList = (props: ImageTagSelectListProps) => { } /> - - {t('availableTags')} - - - {selected ? null : } - -
- {filters.filtered.map((it, index) => ( - { - setSelected(it) - onTagSelected(it) - }} - qaLabel={`imageTag-${index}`} - /> - ))} +
+ + {t('availableTags')} + +
+ + {loadingTags ? ( + + ) : ( + <> + {selected ? null : } +
+ {sortedItems.map((it, index) => ( + { + setSelected(it[0]) + onTagSelected(it[0]) + }} + qaLabel={`imageTag-${index}`} + labelTemplate={label => ( + <> + {isTagNewer(index, selectedTagIndex) && ( + + )} + {label} + + )} + /> + ))} +
+ + )}
) } diff --git a/web/crux-ui/src/components/projects/versions/images/tag-sort-toggle.tsx b/web/crux-ui/src/components/projects/versions/images/tag-sort-toggle.tsx new file mode 100644 index 000000000..1b4ec7ba2 --- /dev/null +++ b/web/crux-ui/src/components/projects/versions/images/tag-sort-toggle.tsx @@ -0,0 +1,70 @@ +import clsx from 'clsx' +import useTranslation from 'next-translate/useTranslation' +import Image from 'next/image' + +export const SORT_MODES = ['alphabetic', 'date'] as const +export type SortModesEnum = (typeof SORT_MODES)[number] + +export type SortState = { + mode: SortModesEnum + dir: -1 | 1 +} + +type TagSortToggleProps = { + className?: string + state: SortState + onStateChange: (state: SortState) => void +} + +const SORT_ICONS: Record = { + alphabetic: { + '1': '/sort-alphabetical-asc.svg', + '-1': '/sort-alphabetical-desc.svg', + }, + date: { + '1': '/sort-date-asc.svg', + '-1': '/sort-date-desc.svg', + }, +} + +const TagSortToggle = (props: TagSortToggleProps) => { + const { className, state, onStateChange } = props + const { mode, dir } = state + + const { t } = useTranslation('common') + + const onToggleMode = (newMode: SortModesEnum) => { + if (mode === newMode) { + onStateChange({ + mode, + dir: dir == 1 ? -1 : 1, + }) + } else { + onStateChange({ + mode: newMode, + dir, + }) + } + } + + return ( +
+ {SORT_MODES.map(it => ( +
onToggleMode(it)} + > + {mode} +
+ ))} +
+ ) +} + +export default TagSortToggle diff --git a/web/crux-ui/src/components/projects/versions/use-version-state.ts b/web/crux-ui/src/components/projects/versions/use-version-state.ts index b93c0bc99..679f01c80 100644 --- a/web/crux-ui/src/components/projects/versions/use-version-state.ts +++ b/web/crux-ui/src/components/projects/versions/use-version-state.ts @@ -37,6 +37,7 @@ import { WS_TYPE_PATCH_RECEIVED, WS_TYPE_REGISTRY_FETCH_IMAGE_TAGS, WS_TYPE_REGISTRY_IMAGE_TAGS, + RegistryImageTag, } from '@app/models' import WebSocketClientEndpoint from '@app/websockets/websocket-client-endpoint' import useTranslation from 'next-translate/useTranslation' @@ -126,9 +127,15 @@ const refreshImageTags = (registriesSock: WebSocketClientEndpoint, images: Versi }) } -export const selectTagsOfImage = (state: VerionState, image: VersionImage): string[] => { +export const selectTagsOfImage = (state: VerionState, image: VersionImage): Record => { const regImgTags = state.tags[imageTagKey(image.registry.id, image.name)] - return regImgTags ? regImgTags.tags : image.tag ? [image.tag] : [] + return regImgTags + ? regImgTags.tags + : image.tag + ? { + [image.tag]: null, + } + : {} } export const useVersionState = (options: VersionStateOptions): [VerionState, VersionActions] => { diff --git a/web/crux-ui/src/components/projects/versions/version-view-list.tsx b/web/crux-ui/src/components/projects/versions/version-view-list.tsx index 6a8b19359..446240bb8 100644 --- a/web/crux-ui/src/components/projects/versions/version-view-list.tsx +++ b/web/crux-ui/src/components/projects/versions/version-view-list.tsx @@ -50,6 +50,8 @@ const VersionViewList = (props: VersionViewListProps) => { actions.fetchImageTags(it) } + const imageTags = tagsModalTarget ? selectTagsOfImage(state, tagsModalTarget) : null + return ( <> @@ -144,6 +146,7 @@ const VersionViewList = (props: VersionViewListProps) => { qaLabel={QA_MODAL_LABEL_IMAGE_TAGS} > actions.selectTagForImage(tagsModalTarget, it)} diff --git a/web/crux-ui/src/elements/dyo-radio-button.tsx b/web/crux-ui/src/elements/dyo-radio-button.tsx index 954cb4eb8..869d17ea0 100644 --- a/web/crux-ui/src/elements/dyo-radio-button.tsx +++ b/web/crux-ui/src/elements/dyo-radio-button.tsx @@ -9,10 +9,11 @@ type DyoRadioButtonProps = { checked?: boolean onSelect?: VoidFunction qaLabel: string + labelTemplate?: (label?: string) => React.ReactNode } const DyoRadioButton = (props: DyoRadioButtonProps) => { - const { className, disabled, label, checked, onSelect, qaLabel } = props + const { className, disabled, label, checked, onSelect, qaLabel, labelTemplate } = props const handleCheckedChange = () => { if (disabled) { @@ -30,7 +31,7 @@ const DyoRadioButton = (props: DyoRadioButtonProps) => { handleCheckedChange()} className="hidden" />
- {label} + {labelTemplate ? labelTemplate(label) : {label}} ) } diff --git a/web/crux-ui/src/models/registry.ts b/web/crux-ui/src/models/registry.ts index 059e3a750..39212549e 100644 --- a/web/crux-ui/src/models/registry.ts +++ b/web/crux-ui/src/models/registry.ts @@ -194,9 +194,13 @@ export type FindImageResultMessage = { export const WS_TYPE_REGISTRY_FETCH_IMAGE_TAGS = 'fetch-image-tags' export type FetchImageTagsMessage = RegistryImages +export type RegistryImageTag = { + created: string +} + export type RegistryImageTags = { name: string - tags: string[] + tags: Record } export const WS_TYPE_REGISTRY_IMAGE_TAGS = 'registry-image-tags' diff --git a/web/crux-ui/src/validations/container.ts b/web/crux-ui/src/validations/container.ts index 2ef79b925..0ba7e2b90 100644 --- a/web/crux-ui/src/validations/container.ts +++ b/web/crux-ui/src/validations/container.ts @@ -470,7 +470,7 @@ const testRules = ( fieldName: string, ) => { if (rules.length === 0) { - return true + return null } const requiredKeys = rules.map(([key]) => key) @@ -504,7 +504,55 @@ const testRules = ( return err } - return true + return null +} + +const testEnvironmentRules = (imageLabels: Record) => (envs: UniqueKeyValue[]) => { + const rules = parseDyrectorioEnvRules(imageLabels) + if (!rules) { + return null + } + + const requiredRules = Object.entries(rules).filter(([, rule]) => rule.required) + const envRules = requiredRules.filter(([_, rule]) => !rule.secret) + + return testRules(envRules, envs, 'environment') +} + +const testSecretRules = (imageLabels: Record) => (secrets: UniqueSecretKeyValue[]) => { + const rules = parseDyrectorioEnvRules(imageLabels) + if (!rules) { + return null + } + + const requiredRules = Object.entries(rules).filter(([, rule]) => rule.required) + const secretRules = requiredRules.filter(([_, rule]) => rule.secret) + + return testRules(secretRules, secrets, 'secret') +} + +const testEnvironmentRules = (imageLabels: Record) => (envs: UniqueKeyValue[]) => { + const rules = parseDyrectorioEnvRules(imageLabels) + if (!rules) { + return true + } + + const requiredRules = Object.entries(rules).filter(([, rule]) => rule.required) + const envRules = requiredRules.filter(([_, rule]) => !rule.secret) + + return testRules(envRules, envs, 'environment') +} + +const testSecretRules = (imageLabels: Record) => (secrets: UniqueSecretKeyValue[]) => { + const rules = parseDyrectorioEnvRules(imageLabels) + if (!rules) { + return true + } + + const requiredRules = Object.entries(rules).filter(([, rule]) => rule.required) + const secretRules = requiredRules.filter(([_, rule]) => rule.secret) + + return testRules(secretRules, secrets, 'secret') } const testEnvironmentRules = (imageLabels: Record) => (envs: UniqueKeyValue[]) => { diff --git a/web/crux/package-lock.json b/web/crux/package-lock.json index 2a82c190d..0c028fd85 100644 --- a/web/crux/package-lock.json +++ b/web/crux/package-lock.json @@ -1,18 +1,19 @@ { "name": "crux", - "version": "0.14.1", + "version": "0.15.0-rc", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "crux", - "version": "0.14.1", + "version": "0.15.0-rc", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.9.15", "@grpc/proto-loader": "^0.7.8", "@nestjs-modules/mailer": "^1.9.1", "@nestjs/axios": "^3.0.0", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/cli": "^10.4.5", "@nestjs/common": "^10.1.3", "@nestjs/config": "^3.0.0", @@ -32,6 +33,7 @@ "@types/ws": "^8.5.5", "@willsoto/nestjs-prometheus": "^5.2.1", "axios": "^1.7.4", + "cache-manager": "^5.7.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^16.3.1", @@ -1904,6 +1906,17 @@ "rxjs": "^6.0.0 || ^7.0.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.3.0.tgz", + "integrity": "sha512-pxeBp9w/s99HaW2+pezM1P3fLiWmUEnTUoUMLa9UYViCtjj0E0A19W/vaT5JFACCzFIeNrwH4/16jkpAhQ25Vw==", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -4077,6 +4090,25 @@ "node": ">= 0.8" } }, + "node_modules/cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -5846,6 +5878,11 @@ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==" }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -8687,6 +8724,11 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -10660,6 +10702,14 @@ "asap": "~2.0.3" } }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "engines": { + "node": ">=16" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/web/crux/package.json b/web/crux/package.json index 936f80181..fda15f37d 100644 --- a/web/crux/package.json +++ b/web/crux/package.json @@ -42,6 +42,7 @@ "@grpc/proto-loader": "^0.7.8", "@nestjs-modules/mailer": "^1.9.1", "@nestjs/axios": "^3.0.0", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/cli": "^10.4.5", "@nestjs/common": "^10.1.3", "@nestjs/config": "^3.0.0", @@ -61,6 +62,7 @@ "@types/ws": "^8.5.5", "@willsoto/nestjs-prometheus": "^5.2.1", "axios": "^1.7.4", + "cache-manager": "^5.7.6", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "dotenv": "^16.3.1", diff --git a/web/crux/src/app/image/image.module.ts b/web/crux/src/app/image/image.module.ts index 209f911ff..ae0f188b8 100644 --- a/web/crux/src/app/image/image.module.ts +++ b/web/crux/src/app/image/image.module.ts @@ -1,3 +1,4 @@ +import { CacheModule } from '@nestjs/cache-manager' import { forwardRef, Module } from '@nestjs/common' import EncryptionService from 'src/services/encryption.service' import KratosService from 'src/services/kratos.service' @@ -14,7 +15,7 @@ import ImageMapper from './image.mapper' import ImageService from './image.service' @Module({ - imports: [RegistryModule, EditorModule, forwardRef(() => ContainerModule), AuditLoggerModule], + imports: [RegistryModule, EditorModule, forwardRef(() => ContainerModule), AuditLoggerModule, CacheModule.register()], exports: [ImageService, ImageMapper], providers: [ PrismaService, diff --git a/web/crux/src/app/registry/guards/registry.auth.validation.guard.ts b/web/crux/src/app/registry/guards/registry.auth.validation.guard.ts index 094f08741..8c44ad8de 100644 --- a/web/crux/src/app/registry/guards/registry.auth.validation.guard.ts +++ b/web/crux/src/app/registry/guards/registry.auth.validation.guard.ts @@ -117,7 +117,7 @@ export default class RegistryAuthValidationGuard implements CanActivate { const creds = await this.getCredentialsForRegistry(registryId, req) - const hubClient = new PrivateHubApiClient(REGISTRY_HUB_URL, req.imageNamePrefix) + const hubClient = new PrivateHubApiClient(REGISTRY_HUB_URL, req.imageNamePrefix, null) return from(hubClient.login(creds.username, creds.password)).pipe( map(() => true), catchError(err => { diff --git a/web/crux/src/app/registry/registry-client.provider.ts b/web/crux/src/app/registry/registry-client.provider.ts index b9a4f5796..da9670f56 100644 --- a/web/crux/src/app/registry/registry-client.provider.ts +++ b/web/crux/src/app/registry/registry-client.provider.ts @@ -1,5 +1,7 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, forwardRef } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' +import { Cache } from 'cache-manager' import { REGISTRY_EVENT_UPDATE, RegistryUpdatedEvent } from 'src/domain/registry' import { CruxForbiddenException } from 'src/exception/crux-exception' import { REGISTRY_GITLAB_URLS, REGISTRY_HUB_URL } from 'src/shared/const' @@ -11,7 +13,7 @@ import { GoogleRegistryClient } from './registry-clients/google-api-client' import PrivateHubApiClient from './registry-clients/private-hub-api-client' import { RegistryApiClient } from './registry-clients/registry-api-client' import UncheckedApiClient from './registry-clients/unchecked-api-client' -import RegistryV2ApiClient from './registry-clients/v2-api-client' +import RegistryV2ApiClient from './registry-clients/v2-registry-api-client' import { REGISTRY_HUB_CACHE_EXPIRATION } from './registry.const' import { GithubNamespace, GitlabNamespace, RegistryType } from './registry.dto' import RegistryService from './registry.service' @@ -34,6 +36,8 @@ export default class RegistryClientProvider { constructor( @Inject(forwardRef(() => RegistryService)) private readonly service: RegistryService, + @Inject(CACHE_MANAGER) + private readonly cache: Cache, ) {} removeClientsByTeam(teamId: string) { @@ -59,6 +63,7 @@ export default class RegistryClientProvider { const createV2 = () => new RegistryV2ApiClient( connInfo.url, + this.cache, !connInfo.public ? { username: connInfo.user, @@ -74,10 +79,11 @@ export default class RegistryClientProvider { this.getHubCacheForImageNamePrefix(registryId, connInfo.imageNamePrefix), REGISTRY_HUB_URL, connInfo.imageNamePrefix, + this.cache, ) } - const hubClient = new PrivateHubApiClient(REGISTRY_HUB_URL, connInfo.imageNamePrefix) + const hubClient = new PrivateHubApiClient(REGISTRY_HUB_URL, connInfo.imageNamePrefix, this.cache) await hubClient.login(connInfo.user, connInfo.token) return hubClient @@ -91,6 +97,7 @@ export default class RegistryClientProvider { password: connInfo.token, }, connInfo.namespace as GithubNamespace, + this.cache, ) const createGitlab = () => @@ -107,12 +114,14 @@ export default class RegistryClientProvider { } : REGISTRY_GITLAB_URLS, connInfo.namespace as GitlabNamespace, + this.cache, ) const createGoogle = () => new GoogleRegistryClient( connInfo.url, connInfo.imageNamePrefix, + this.cache, !connInfo.public ? { username: connInfo.user, diff --git a/web/crux/src/app/registry/registry-clients/cached-hub-api-client.ts b/web/crux/src/app/registry/registry-clients/cached-hub-api-client.ts index 17c034ce7..5f93fa73b 100644 --- a/web/crux/src/app/registry/registry-clients/cached-hub-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/cached-hub-api-client.ts @@ -1,4 +1,5 @@ -import { RegistryImageTags } from '../registry.message' +import { Cache } from 'cache-manager' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' import HubApiCache from './caches/hub-api-cache' import HubApiClient from './hub-api-client' import { RegistryApiClient } from './registry-api-client' @@ -7,37 +8,49 @@ export default class CachedPublicHubApiClient extends HubApiClient implements Re private proxyToken?: string constructor( - private cache: HubApiCache, + private hubCache: HubApiCache, url: string, prefix: string, + manifestCache: Cache | null, ) { - super(process.env.HUB_PROXY_URL ?? `https://${url}`, prefix) + super(process.env.HUB_PROXY_URL ?? `https://${url}`, prefix, manifestCache) this.proxyToken = process.env.HUB_PROXY_TOKEN } async catalog(text: string): Promise { const endpoint = '' - let repositories: string[] = this.cache.get(endpoint) + let repositories: string[] = this.hubCache.get(endpoint) if (!repositories) { repositories = await super.fetchCatalog() - this.cache.upsert(endpoint, repositories) + this.hubCache.upsert(endpoint, repositories) } return repositories.filter(it => it.includes(text)) } async tags(image: string): Promise { - let tags: string[] = this.cache.get(image) + let tags: string[] = this.hubCache.get(image) if (!tags) { tags = await this.fetchTags(image) - this.cache.upsert(image, tags) + this.hubCache.upsert(image, tags) } + // NOTE(@robot9706): Docker ratelimits us so skip tag info for now + const tagsWithInfo = tags.reduce( + (map, it) => { + map[it] = { + created: null, + } + return map + }, + {} as Record, + ) + return { - tags, name: image, + tags: tagsWithInfo, } } diff --git a/web/crux/src/app/registry/registry-clients/github-api-client.ts b/web/crux/src/app/registry/registry-clients/github-api-client.ts index 05a995a08..df1b64d9e 100644 --- a/web/crux/src/app/registry/registry-clients/github-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/github-api-client.ts @@ -1,10 +1,19 @@ +import { Cache } from 'cache-manager' import { getRegistryApiException } from 'src/exception/registry-exception' import { REGISTRY_GITHUB_URL } from 'src/shared/const' import { GithubNamespace } from '../registry.dto' -import { RegistryImageTags } from '../registry.message' -import { RegistryApiClient } from './registry-api-client' -import RegistryV2ApiClient, { RegistryV2ApiClientOptions, registryCredentialsToBasicAuth } from './v2-api-client' -import V2Labels from './v2-labels' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' +import { fetchInfoForTags, RegistryApiClient } from './registry-api-client' +import V2HttpApiClient from './v2-http-api-client' +import RegistryV2ApiClient, { + RegistryV2ApiClientOptions, + registryCredentialsToBasicAuth, +} from './v2-registry-api-client' + +type TagsList = { + name: string + tags: string[] +} class GithubRegistryClient implements RegistryApiClient { private basicAuthHeaders: HeadersInit @@ -15,6 +24,7 @@ class GithubRegistryClient implements RegistryApiClient { private imageNamePrefix: string, options: RegistryV2ApiClientOptions, namespace: GithubNamespace, + private readonly cache: Cache | null, ) { this.basicAuthHeaders = { Authorization: registryCredentialsToBasicAuth(options), @@ -65,24 +75,37 @@ class GithubRegistryClient implements RegistryApiClient { throw getRegistryApiException(tokenRes, '`Github tags request') } - const json = (await res.json()) as RegistryImageTags[] + const json = (await res.json()) as TagsList[] + const tags = json.flatMap(it => it.tags) + const tagInfo = await fetchInfoForTags(image, tags, this) + return { name: image, - tags: json.flatMap(it => it.tags), + tags: tagInfo, } } - async labels(image: string, tag: string): Promise> { + private createApiClient(): V2HttpApiClient { // NOTE(@robot9706): ghcr.io expects the accept manifest to be "v1" but it responds with v2 manifests - const labelClient = new V2Labels( - REGISTRY_GITHUB_URL, - this.imageNamePrefix, + return new V2HttpApiClient( { - headers: this.basicAuthHeaders, + baseUrl: REGISTRY_GITHUB_URL, + imageNamePrefix: this.imageNamePrefix, + requestInit: { + headers: this.basicAuthHeaders, + }, + manifestMime: 'application/vnd.docker.distribution.manifest.v1+json', }, - 'application/vnd.docker.distribution.manifest.v1+json', + this.cache, ) - return labelClient.fetchLabels(image, tag) + } + + async labels(image: string, tag: string): Promise> { + return this.createApiClient().fetchLabels(image, tag) + } + + async tagInfo(image: string, tag: string): Promise { + return this.createApiClient().fetchTagInfo(image, tag) } } diff --git a/web/crux/src/app/registry/registry-clients/gitlab-api-client.ts b/web/crux/src/app/registry/registry-clients/gitlab-api-client.ts index 931e7f939..cd589ecca 100644 --- a/web/crux/src/app/registry/registry-clients/gitlab-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/gitlab-api-client.ts @@ -1,15 +1,21 @@ +import { Cache } from 'cache-manager' import { getRegistryApiException } from 'src/exception/registry-exception' import { GitlabNamespace } from '../registry.dto' -import { RegistryImageTags } from '../registry.message' -import { RegistryApiClient } from './registry-api-client' -import RegistryV2ApiClient, { RegistryV2ApiClientOptions } from './v2-api-client' -import V2Labels from './v2-labels' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' +import { fetchInfoForTags, RegistryApiClient } from './registry-api-client' +import V2HttpApiClient from './v2-http-api-client' +import RegistryV2ApiClient, { RegistryV2ApiClientOptions } from './v2-registry-api-client' export type GitlabRegistryClientUrls = { apiUrl: string registryUrl: string } +type TagsList = { + name: string + tags: string[] +} + export class GitlabRegistryClient implements RegistryApiClient { private basicAuthHeaders: HeadersInit @@ -22,6 +28,7 @@ export class GitlabRegistryClient implements RegistryApiClient { options: RegistryV2ApiClientOptions, private urls: GitlabRegistryClientUrls, namespace: GitlabNamespace, + private readonly cache: Cache | null, ) { this.basicAuthHeaders = { Authorization: `Basic ${Buffer.from(`${options.username}:${options.password}`).toString('base64')}`, @@ -76,18 +83,33 @@ export class GitlabRegistryClient implements RegistryApiClient { throw getRegistryApiException(res, `Gitlab tags for image ${image}`) } - const json = (await res.json()) as RegistryImageTags[] + const json = (await res.json()) as TagsList[] + const tags = json.flatMap(it => it.tags) + const tagInfo = await fetchInfoForTags(image, tags, this) + return { name: image, - tags: json.flatMap(it => it.tags), + tags: tagInfo, } } + private createApiClient(): V2HttpApiClient { + return new V2HttpApiClient( + { + baseUrl: this.urls.registryUrl, + tokenInit: { + headers: this.basicAuthHeaders, + }, + }, + this.cache, + ) + } + async labels(image: string, tag: string): Promise> { - const labelClient = new V2Labels(this.urls.registryUrl, null, null, null, { - headers: this.basicAuthHeaders, - }) + return this.createApiClient().fetchLabels(image, tag) + } - return labelClient.fetchLabels(image, tag) + async tagInfo(image: string, tag: string): Promise { + return this.createApiClient().fetchTagInfo(image, tag) } } diff --git a/web/crux/src/app/registry/registry-clients/google-api-client.ts b/web/crux/src/app/registry/registry-clients/google-api-client.ts index 0719e6b55..0e9e45a46 100644 --- a/web/crux/src/app/registry/registry-clients/google-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/google-api-client.ts @@ -1,10 +1,11 @@ +import { Cache } from 'cache-manager' import { JWT } from 'google-auth-library' import { GetAccessTokenResponse } from 'google-auth-library/build/src/auth/oauth2client' import { CruxUnauthorizedException } from 'src/exception/crux-exception' import { getRegistryApiException } from 'src/exception/registry-exception' -import { RegistryImageTags } from '../registry.message' -import { RegistryApiClient } from './registry-api-client' -import V2Labels from './v2-labels' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' +import { fetchInfoForTags, RegistryApiClient } from './registry-api-client' +import V2HttpApiClient from './v2-http-api-client' export type GoogleClientOptions = { username?: string @@ -19,6 +20,7 @@ export class GoogleRegistryClient implements RegistryApiClient { constructor( private url: string, private imageNamePrefix: string, + private readonly cache: Cache | null, options?: GoogleClientOptions, ) { if (options?.username) { @@ -90,23 +92,41 @@ export class GoogleRegistryClient implements RegistryApiClient { } const json = (await tagRes.json()) as { tags: string[] } + const tagInfo = await fetchInfoForTags(image, json.tags, this) + return { name: image, - tags: json.tags, + tags: tagInfo, } } - async labels(image: string, tag: string): Promise> { + private async createApiClient(): Promise { if (this.client) { await this.registryCredentialsToBearerAuth() } - const labelClient = new V2Labels(this.url, null, { - headers: { - Authorization: (this.headers as Record).Authorization, + return new V2HttpApiClient( + { + baseUrl: this.url, + requestInit: { + headers: { + Authorization: (this.headers as Record).Authorization, + }, + }, }, - }) + this.cache, + ) + } + + async labels(image: string, tag: string): Promise> { + const client = await this.createApiClient() + + return client.fetchLabels(image, tag) + } + + async tagInfo(image: string, tag: string): Promise { + const client = await this.createApiClient() - return labelClient.fetchLabels(image, tag) + return client.fetchTagInfo(image, tag) } } diff --git a/web/crux/src/app/registry/registry-clients/hub-api-client.ts b/web/crux/src/app/registry/registry-clients/hub-api-client.ts index 93c32eb9f..361021b6e 100644 --- a/web/crux/src/app/registry/registry-clients/hub-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/hub-api-client.ts @@ -1,5 +1,7 @@ +import { Cache } from 'cache-manager' import { getRegistryApiException } from 'src/exception/registry-exception' -import V2Labels from './v2-labels' +import { RegistryImageTag } from '../registry.message' +import V2HttpApiClient from './v2-http-api-client' type HubApiPaginatedResponse = { count: number @@ -16,6 +18,7 @@ export default abstract class HubApiClient { constructor( protected readonly url: string, protected readonly prefix: string, + protected readonly cache: Cache | null, ) {} protected async fetchCatalog(): Promise { @@ -79,9 +82,27 @@ export default abstract class HubApiClient { return result } + protected createApiClient(): V2HttpApiClient { + return new V2HttpApiClient( + { + baseUrl: DOCKER_HUB_REGISTRY_URL, + imageNamePrefix: this.prefix, + }, + this.cache, + ) + } + async labels(image: string, tag: string): Promise> { - const labelClient = new V2Labels(DOCKER_HUB_REGISTRY_URL) + // NOTE(@robot9706): Docker ratelimits us so skip this for now + // return this.createApiClient().fetchLabels(image, tag) + return {} + } - return labelClient.fetchLabels(this.prefix ? `${this.prefix}/${image}` : image, tag) + async tagInfo(image: string, tag: string): Promise { + // NOTE(@robot9706): Docker ratelimits us so skip this for now + // return this.createApiClient().fetchTagInfo(image, tag) + return { + created: null, + } } } diff --git a/web/crux/src/app/registry/registry-clients/private-hub-api-client.ts b/web/crux/src/app/registry/registry-clients/private-hub-api-client.ts index 92eb58798..0e27be647 100644 --- a/web/crux/src/app/registry/registry-clients/private-hub-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/private-hub-api-client.ts @@ -1,17 +1,18 @@ +import { Cache } from 'cache-manager' import { CruxUnauthorizedException } from 'src/exception/crux-exception' -import { RegistryImageTags } from '../registry.message' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' import HubApiClient, { DOCKER_HUB_REGISTRY_URL } from './hub-api-client' import { RegistryApiClient } from './registry-api-client' -import { registryCredentialsToBasicAuth } from './v2-api-client' -import V2Labels from './v2-labels' +import V2HttpApiClient from './v2-http-api-client' +import { registryCredentialsToBasicAuth } from './v2-registry-api-client' export default class PrivateHubApiClient extends HubApiClient implements RegistryApiClient { private jwt: string = null private labelsAuth: RequestInit = null - constructor(url: string, prefix: string) { - super(`https://${url}`, prefix) + constructor(url: string, prefix: string, cache: Cache | null) { + super(`https://${url}`, prefix, cache) } async login(user: string, token: string): Promise { @@ -59,9 +60,20 @@ export default class PrivateHubApiClient extends HubApiClient implements Registr async tags(image: string): Promise { const tags = await super.fetchTags(image) + // NOTE(@robot9706): Docker ratelimits us so skip tag info for now + const tagsWithInfo = tags.reduce( + (map, it) => { + map[it] = { + created: null, + } + return map + }, + {} as Record, + ) + return { - tags, name: image, + tags: tagsWithInfo, } } @@ -86,9 +98,14 @@ export default class PrivateHubApiClient extends HubApiClient implements Registr }) } - async labels(image: string, tag: string): Promise> { - const labelClient = new V2Labels(DOCKER_HUB_REGISTRY_URL, null, null, null, this.labelsAuth) - - return labelClient.fetchLabels(this.prefix ? `${this.prefix}/${image}` : image, tag) + protected createApiClient(): V2HttpApiClient { + return new V2HttpApiClient( + { + baseUrl: DOCKER_HUB_REGISTRY_URL, + imageNamePrefix: this.prefix, + tokenInit: this.labelsAuth, + }, + this.cache, + ) } } diff --git a/web/crux/src/app/registry/registry-clients/registry-api-client.ts b/web/crux/src/app/registry/registry-clients/registry-api-client.ts index 35a6d6b4c..8bea4553f 100644 --- a/web/crux/src/app/registry/registry-clients/registry-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/registry-api-client.ts @@ -1,7 +1,27 @@ -import { RegistryImageTags } from '../registry.message' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' export interface RegistryApiClient { catalog(text: string): Promise tags(image: string): Promise labels(image: string, tag: string): Promise> + tagInfo(image: string, tag: string): Promise } + +export const fetchInfoForTags = async (image: string, tags: string[], client: RegistryApiClient): Promise> => { + const tagsWithInfoPromise = tags.map(async it => { + const info = await client.tagInfo(image, it) + + return { + tag: it, + info, + } + }) + + return (await Promise.all(tagsWithInfoPromise)).reduce( + (map, it) => { + map[it.tag] = it.info + return map + }, + {} as Record, + ) +} \ No newline at end of file diff --git a/web/crux/src/app/registry/registry-clients/unchecked-api-client.ts b/web/crux/src/app/registry/registry-clients/unchecked-api-client.ts index 5d1241fa7..34cca40da 100644 --- a/web/crux/src/app/registry/registry-clients/unchecked-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/unchecked-api-client.ts @@ -1,5 +1,5 @@ import { CruxBadRequestException } from 'src/exception/crux-exception' -import { RegistryImageTags } from '../registry.message' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' import { RegistryApiClient } from './registry-api-client' class UncheckedApiClient implements RegistryApiClient { @@ -14,6 +14,10 @@ class UncheckedApiClient implements RegistryApiClient { async labels(): Promise> { return {} } + + async tagInfo(image: string, tag: string): Promise { + return null + } } export default UncheckedApiClient diff --git a/web/crux/src/app/registry/registry-clients/v2-labels.ts b/web/crux/src/app/registry/registry-clients/v2-http-api-client.ts similarity index 52% rename from web/crux/src/app/registry/registry-clients/v2-labels.ts rename to web/crux/src/app/registry/registry-clients/v2-http-api-client.ts index 6f57545aa..47d1ca851 100644 --- a/web/crux/src/app/registry/registry-clients/v2-labels.ts +++ b/web/crux/src/app/registry/registry-clients/v2-http-api-client.ts @@ -1,6 +1,8 @@ import { Logger } from '@nestjs/common' +import { Cache } from 'cache-manager' import { CruxInternalServerErrorException } from 'src/exception/crux-exception' import { USER_AGENT_CRUX } from 'src/shared/const' +import { RegistryImageTag } from '../registry.message' type V2Error = { code: string @@ -12,9 +14,14 @@ type BaseResponse = { errors?: V2Error[] } +type ManifestHistory = { + v1Compatibility: string // string of ConfigBlobResponse +} + type ManifestBaseResponse = BaseResponse & { schemaVersion: number - mediaType: string + mediaType: string // v2 + history: ManifestHistory[] // v1 } type ManifestResponse = ManifestBaseResponse & { @@ -30,10 +37,11 @@ type ManifestIndexResponse = ManifestBaseResponse & { }[] } -type BlobResponse = BaseResponse & { +type ConfigBlobResponse = BaseResponse & { config: { Labels: Record } + created: string } type TokenResponse = { @@ -45,6 +53,15 @@ type FetchResponse = { data: T } +export type V2Options = { + baseUrl: string + imageNamePrefix?: string + requestInit?: RequestInit + manifestMime?: string + tokenInit?: RequestInit + tryV1Manifest?: boolean +} + const ERROR_UNAUTHORIZED = 'UNAUTHORIZED' const ERROR_DENIED = 'DENINED' const ERROR_MANIFEST_UNKNOWN = 'MANIFEST_UNKNOWN' @@ -53,27 +70,39 @@ const HEADER_WWW_AUTHENTICATE = 'www-authenticate' const MEDIA_TYPE_INDEX = 'application/vnd.oci.image.index.v1+json' const MEDIA_TYPE_MANIFEST = 'application/vnd.oci.image.manifest.v1+json' +const MEDIA_TYPE_DISTRIBUTION_MANIFEST_V1 = 'application/vnd.docker.distribution.manifest.v1+json' const MEDIA_TYPE_DISTRIBUTION_MANIFEST_V2 = 'application/vnd.docker.distribution.manifest.v2+json' const MANIFEST_MAX_DEPTH = 5 -export default class V2Labels { - private readonly logger = new Logger(V2Labels.name) +export default class V2HttpApiClient { + private readonly logger = new Logger(V2HttpApiClient.name) + + private readonly baseUrl: string + + private readonly imageNamePrefix?: string + + private readonly tokenInit?: RequestInit private token?: string - private manifestMimeType: string + private manifestMimeType?: string private requestInit: RequestInit + private tryV1Manifest: boolean + constructor( - private readonly baseUrl: string, - private readonly imageNamePrefix?: string, - requestInit?: RequestInit, - manifestMime?: string, - private readonly tokenInit?: RequestInit, + options: V2Options, + private readonly cache: Cache | null, ) { - this.requestInit = requestInit ?? {} + this.baseUrl = options.baseUrl + this.imageNamePrefix = options.imageNamePrefix + this.tokenInit = options.tokenInit + this.manifestMimeType = options.manifestMime + this.tryV1Manifest = options.tryV1Manifest ?? false + + this.requestInit = options.requestInit ?? {} this.requestInit = { ...this.requestInit, headers: { @@ -83,8 +112,6 @@ export default class V2Labels { } this.token = null - - this.manifestMimeType = manifestMime ?? MEDIA_TYPE_DISTRIBUTION_MANIFEST_V2 } private getHeaders(): RequestInit { @@ -220,15 +247,48 @@ export default class V2Labels { return result.data } + private async fetchV2Cache( + cacheKey: string, + endpoint: string, + init?: RequestInit, + ): Promise { + if (!this.cache) { + return this.fetchV2(endpoint, init) + } + + const cached = await this.cache.get(cacheKey) + if (cached) { + this.logger.debug(`Cached ${cacheKey}`) + return cached + } + + const result = await this.fetchV2(endpoint, init) + this.cache.set(cacheKey, result, 0) + this.logger.debug(`Stored to cache ${cacheKey}`) + + return result + } + private async fetchLabelsByManifest( image: string, manifest: ManifestBaseResponse, depth: number, ): Promise> { + if (!manifest.mediaType && manifest.schemaVersion == 1) { + // NOTE(@robot9706): V1 manifests have 'v1Compatibility' history fields which have everything we need + const lastHistory = manifest.history[0] + const configBlob = JSON.parse(lastHistory.v1Compatibility) as ConfigBlobResponse + + return configBlob.config.Labels + } + if (manifest.mediaType === MEDIA_TYPE_MANIFEST || manifest.mediaType === MEDIA_TYPE_DISTRIBUTION_MANIFEST_V2) { const labelManifest = manifest as ManifestResponse - const configManifest = await this.fetchV2(`${image}/blobs/${labelManifest.config.digest}`) + const configManifest = await this.fetchV2Cache( + `manifest-${labelManifest.config.digest}`, + `${image}/blobs/${labelManifest.config.digest}`, + ) return configManifest.config.Labels } @@ -241,11 +301,15 @@ export default class V2Labels { const indexManifest = manifest as ManifestIndexResponse const subManifestPromises = indexManifest.manifests.map(async it => { - const subManifest = await this.fetchV2(`${image}/manifests/${it.digest}`, { - headers: { - Accept: it.mediaType, + const subManifest = await this.fetchV2Cache( + `manifest-${it.digest}`, + `${image}/manifests/${it.digest}`, + { + headers: { + Accept: it.mediaType, + }, }, - }) + ) return this.fetchLabelsByManifest(image, subManifest, depth + 1) }) @@ -264,21 +328,113 @@ export default class V2Labels { throw new Error(`Unknown manifest type: ${manifest.mediaType}`) } - async fetchLabels(image: string, tag: string): Promise> { + private async fetchConfigBlobByManifest( + image: string, + manifest: ManifestBaseResponse, + depth: number, + ): Promise { + if (!manifest.mediaType && manifest.schemaVersion == 1) { + // NOTE(@robot9706): V1 manifests have 'v1Compatibility' history fields which have everything we need + const lastHistory = manifest.history[0] + const configBlob = JSON.parse(lastHistory.v1Compatibility) as ConfigBlobResponse + + return configBlob + } + + if (manifest.mediaType === MEDIA_TYPE_MANIFEST || manifest.mediaType === MEDIA_TYPE_DISTRIBUTION_MANIFEST_V2) { + const labelManifest = manifest as ManifestResponse + + return this.fetchV2Cache( + `manifest-${labelManifest.config.digest}`, + `${image}/blobs/${labelManifest.config.digest}`, + ) + } + + if (manifest.mediaType === MEDIA_TYPE_INDEX) { + if (depth > MANIFEST_MAX_DEPTH) { + return null + } + + const indexManifest = manifest as ManifestIndexResponse + if (indexManifest.manifests.length <= 0) { + return null + } + + // TODO(@robot9706): Decide which manifest to use + const subManifestMeta = indexManifest.manifests[0] + + const subManifest = await this.fetchV2Cache( + `manifest-${subManifestMeta.digest}`, + `${image}/manifests/${subManifestMeta.digest}`, + { + headers: { + Accept: subManifestMeta.mediaType, + }, + }, + ) + + return this.fetchConfigBlobByManifest(image, subManifest, depth + 1) + } + + throw new Error(`Unknown manifest type: ${manifest.mediaType}`) + } + + private async fetchTagManifest(image: string, tag: string): Promise { if (this.imageNamePrefix) { image = `${this.imageNamePrefix}/${image}` } - const manifest = await this.fetchV2(`${image}/manifests/${tag ?? 'latest'}`, { - headers: { - Accept: this.manifestMimeType, + if (!this.manifestMimeType && this.tryV1Manifest) { + // NOTE(@robot9706): If the manifest mime type is not defined, try v1 first if we are allowed to do so + const manifest = await this.fetchV2Cache( + `image-${image}/${tag ?? 'latest'}`, + `${image}/manifests/${tag ?? 'latest'}`, + { + headers: { + Accept: MEDIA_TYPE_DISTRIBUTION_MANIFEST_V1, + }, + }, + ) + + if (!manifest) { + return null + } + + if (manifest.schemaVersion == 1) { + return manifest + } + } + + return this.fetchV2Cache( + `image-${image}/${tag ?? 'latest'}`, + `${image}/manifests/${tag ?? 'latest'}`, + { + headers: { + Accept: this.manifestMimeType ?? MEDIA_TYPE_DISTRIBUTION_MANIFEST_V2, + }, }, - }) + ) + } - if (!manifest) { + async fetchLabels(image: string, tag: string): Promise> { + const tagManifest = await this.fetchTagManifest(image, tag) + if (!tagManifest) { return {} } - return this.fetchLabelsByManifest(image, manifest, 0) + return this.fetchLabelsByManifest(image, tagManifest, 0) + } + + async fetchTagInfo(image: string, tag: string): Promise { + const tagManifest = await this.fetchTagManifest(image, tag) + if (!tagManifest) { + return null + } + + const configBlob = await this.fetchConfigBlobByManifest(image, tagManifest, 0) + + return { + created: configBlob.created, + } } } diff --git a/web/crux/src/app/registry/registry-clients/v2-api-client.ts b/web/crux/src/app/registry/registry-clients/v2-registry-api-client.ts similarity index 75% rename from web/crux/src/app/registry/registry-clients/v2-api-client.ts rename to web/crux/src/app/registry/registry-clients/v2-registry-api-client.ts index e305c7de8..3a0342b06 100644 --- a/web/crux/src/app/registry/registry-clients/v2-api-client.ts +++ b/web/crux/src/app/registry/registry-clients/v2-registry-api-client.ts @@ -1,8 +1,9 @@ +import { Cache } from 'cache-manager' import { CruxUnauthorizedException } from 'src/exception/crux-exception' import { getRegistryApiException } from 'src/exception/registry-exception' -import { RegistryImageTags } from '../registry.message' -import { RegistryApiClient } from './registry-api-client' -import V2Labels from './v2-labels' +import { RegistryImageTag, RegistryImageTags } from '../registry.message' +import { fetchInfoForTags, RegistryApiClient } from './registry-api-client' +import V2HttpApiClient from './v2-http-api-client' export type RegistryV2ApiClientOptions = { imageNamePrefix?: string @@ -13,6 +14,11 @@ export type RegistryV2ApiClientOptions = { export const registryCredentialsToBasicAuth = (options: RegistryV2ApiClientOptions) => `Basic ${Buffer.from(`${options.username}:${options.password}`).toString('base64')}` +type TagsList = { + name: string + tags: string[] +} + class RegistryV2ApiClient implements RegistryApiClient { private headers: HeadersInit @@ -24,6 +30,7 @@ class RegistryV2ApiClient implements RegistryApiClient { constructor( private url: string, + private readonly cache: Cache | null, options?: RegistryV2ApiClientOptions, ) { if (options?.username) { @@ -69,19 +76,36 @@ class RegistryV2ApiClient implements RegistryApiClient { throw getRegistryApiException(res, 'Tags request') } - const json = (await res.json()) as RegistryImageTags[] + const json = (await res.json()) as TagsList[] + const tags = json.flatMap(it => it.tags) + const tagInfo = await fetchInfoForTags(image, tags, this) + return { name: image, - tags: json.flatMap(it => it.tags), + tags: tagInfo, } } + private createApiClient(): V2HttpApiClient { + return new V2HttpApiClient( + { + baseUrl: this.url, + imageNamePrefix: this.imageNamePrefix, + requestInit: { + headers: this.headers, + }, + tryV1Manifest: true, // NOTE(@robot9706): Enable V1 manifest fetch if possible as it reduces API calls + }, + this.cache, + ) + } + async labels(image: string, tag: string): Promise> { - const labelClient = new V2Labels(`${this.url}`, this.imageNamePrefix, { - headers: this.headers, - }) + return this.createApiClient().fetchLabels(image, tag) + } - return labelClient.fetchLabels(image, tag) + async tagInfo(image: string, tag: string): Promise { + return this.createApiClient().fetchTagInfo(image, tag) } private async fetch(endpoint: string, init?: RequestInit): Promise { diff --git a/web/crux/src/app/registry/registry.message.ts b/web/crux/src/app/registry/registry.message.ts index 3ce0afc3e..498dd29c6 100644 --- a/web/crux/src/app/registry/registry.message.ts +++ b/web/crux/src/app/registry/registry.message.ts @@ -19,9 +19,13 @@ export type RegistryImages = { export type FetchImageTagsMessage = RegistryImages +export type RegistryImageTag = { + created: string +} + export type RegistryImageTags = { name: string - tags: string[] + tags: Record } export type RegistryImageTagsMessage = { diff --git a/web/crux/src/app/registry/registry.module.ts b/web/crux/src/app/registry/registry.module.ts index 93a03408c..4f2297b4c 100644 --- a/web/crux/src/app/registry/registry.module.ts +++ b/web/crux/src/app/registry/registry.module.ts @@ -1,4 +1,5 @@ import { HttpModule } from '@nestjs/axios' +import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' import NotificationTemplateBuilder from 'src/builders/notification.template.builder' import { CruxJwtModuleImports } from 'src/config/jwt.config' @@ -16,7 +17,7 @@ import RegistryService from './registry.service' import RegistryWebSocketGateway from './registry.ws.gateway' @Module({ - imports: [HttpModule, TeamModule, AuditLoggerModule, ...CruxJwtModuleImports], + imports: [HttpModule, TeamModule, AuditLoggerModule, CacheModule.register(), ...CruxJwtModuleImports], exports: [RegistryMapper, RegistryService], controllers: [RegistryHttpController], providers: [