From 10014ae17134f41df9ae25ccc7964310bc2fe888 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 10 Jul 2023 18:56:12 +0300 Subject: [PATCH] refactor(ui/image-config): create react component [EE-5342] (#8856) --- app/docker/helpers/imageHelper.js | 61 +---- app/docker/services/imageService.js | 13 +- app/kubernetes/converters/daemonSet.js | 2 +- app/kubernetes/converters/deployment.js | 2 +- app/kubernetes/converters/statefulSet.js | 2 +- .../ImageConfigFieldset/AdvancedForm.tsx | 41 +++ .../ImageConfigFieldset.tsx | 78 ++++++ .../ImageConfigFieldset/InputSearch.tsx | 52 ++++ .../ImageConfigFieldset/RateLimits.tsx | 240 ++++++++++++++++ .../ImageConfigFieldset/SimpleForm.tsx | 257 ++++++++++++++++++ .../components/ImageConfigFieldset/index.ts | 3 + .../components/ImageConfigFieldset/types.ts | 7 + .../components/ImageConfigFieldset/utils.ts | 12 + .../ImageConfigFieldset/validation.ts | 11 + .../queries/encodeRegistryCredentials.ts | 15 + app/react/docker/images/queries/queryKeys.ts | 9 + app/react/docker/images/queries/useImages.ts | 124 +++++++++ app/react/docker/images/types.ts | 22 ++ app/react/docker/images/types/response.ts | 12 + app/react/docker/images/utils.ts | 101 +++++++ app/react/docker/proxy/queries/build-url.ts | 15 + .../environments/queries/query-keys.ts | 2 + .../queries/useEnvironmentRegistries.ts | 30 ++ .../portainer/registries/queries/build-url.ts | 11 + .../registries/queries/query-keys.ts | 6 + .../registries/queries/useRegistries.ts | 82 +++++- .../registries/queries/useRegistry.ts | 27 ++ .../portainer/registries/registry.service.ts | 32 +++ .../portainer/registries/types/registry.ts | 79 ++++++ .../utils/findRegistryMatch.test.ts | 83 ++++++ .../registries/utils/findRegistryMatch.ts | 40 +++ .../registries/utils/getImageConfig.ts | 43 +++ .../portainer/registries/utils/getUrl.ts | 31 +++ app/react/portainer/settings/types.ts | 3 +- 34 files changed, 1464 insertions(+), 84 deletions(-) create mode 100644 app/react/components/ImageConfigFieldset/AdvancedForm.tsx create mode 100644 app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx create mode 100644 app/react/components/ImageConfigFieldset/InputSearch.tsx create mode 100644 app/react/components/ImageConfigFieldset/RateLimits.tsx create mode 100644 app/react/components/ImageConfigFieldset/SimpleForm.tsx create mode 100644 app/react/components/ImageConfigFieldset/index.ts create mode 100644 app/react/components/ImageConfigFieldset/types.ts create mode 100644 app/react/components/ImageConfigFieldset/utils.ts create mode 100644 app/react/components/ImageConfigFieldset/validation.ts create mode 100644 app/react/docker/images/queries/encodeRegistryCredentials.ts create mode 100644 app/react/docker/images/queries/queryKeys.ts create mode 100644 app/react/docker/images/queries/useImages.ts create mode 100644 app/react/docker/images/types.ts create mode 100644 app/react/docker/images/types/response.ts create mode 100644 app/react/docker/images/utils.ts create mode 100644 app/react/docker/proxy/queries/build-url.ts create mode 100644 app/react/portainer/environments/queries/useEnvironmentRegistries.ts create mode 100644 app/react/portainer/registries/queries/build-url.ts create mode 100644 app/react/portainer/registries/queries/query-keys.ts create mode 100644 app/react/portainer/registries/queries/useRegistry.ts create mode 100644 app/react/portainer/registries/registry.service.ts create mode 100644 app/react/portainer/registries/types/registry.ts create mode 100644 app/react/portainer/registries/utils/findRegistryMatch.test.ts create mode 100644 app/react/portainer/registries/utils/findRegistryMatch.ts create mode 100644 app/react/portainer/registries/utils/getImageConfig.ts create mode 100644 app/react/portainer/registries/utils/getUrl.ts diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js index b5226534f..715d31a05 100644 --- a/app/docker/helpers/imageHelper.js +++ b/app/docker/helpers/imageHelper.js @@ -1,5 +1,4 @@ -import _ from 'lodash-es'; -import { RegistryTypes } from 'Portainer/models/registryTypes'; +import { buildImageFullURI, imageContainsURL } from '@/react/docker/images/utils'; angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory); function ImageHelperFactory() { @@ -29,67 +28,13 @@ function ImageHelperFactory() { * @param {PorImageRegistryModel} registry */ function createImageConfigForContainer(imageModel) { + const registry = imageModel.UseRegistry ? imageModel.Registry : undefined; return { - fromImage: buildImageFullURI(imageModel), + fromImage: buildImageFullURI(imageModel.Image, registry), }; } - function imageContainsURL(image) { - const split = _.split(image, '/'); - const url = split[0]; - if (split.length > 1) { - return _.includes(url, '.') || _.includes(url, ':'); - } - return false; - } - function removeDigestFromRepository(repository) { return repository.split('@sha')[0]; } } -/** - * builds the complete uri for an image based on its registry - * @param {PorImageRegistryModel} imageModel - */ -export function buildImageFullURI(imageModel) { - if (!imageModel.UseRegistry) { - return ensureTag(imageModel.Image); - } - - const imageName = buildImageFullURIWithRegistry(imageModel); - - return ensureTag(imageName); - - function ensureTag(image, defaultTag = 'latest') { - return image.includes(':') ? image : `${image}:${defaultTag}`; - } -} - -function buildImageFullURIWithRegistry(imageModel) { - switch (imageModel.Registry.Type) { - case RegistryTypes.GITLAB: - return buildImageURIForGitLab(imageModel); - case RegistryTypes.QUAY: - return buildImageURIForQuay(imageModel); - case RegistryTypes.ANONYMOUS: - return imageModel.Image; - default: - return buildImageURIForOtherRegistry(imageModel); - } - - function buildImageURIForGitLab(imageModel) { - const slash = imageModel.Image.startsWith(':') ? '' : '/'; - return `${imageModel.Registry.URL}/${imageModel.Registry.Gitlab.ProjectPath}${slash}${imageModel.Image}`; - } - - function buildImageURIForQuay(imageModel) { - const name = imageModel.Registry.Quay.UseOrganisation ? imageModel.Registry.Quay.OrganisationName : imageModel.Registry.Username; - const url = imageModel.Registry.URL ? imageModel.Registry.URL + '/' : ''; - return `${url}${name}/${imageModel.Image}`; - } - - function buildImageURIForOtherRegistry(imageModel) { - const url = imageModel.Registry.URL ? imageModel.Registry.URL + '/' : ''; - return url + imageModel.Image; - } -} diff --git a/app/docker/services/imageService.js b/app/docker/services/imageService.js index e22f0a3af..5f2c7290c 100644 --- a/app/docker/services/imageService.js +++ b/app/docker/services/imageService.js @@ -1,4 +1,4 @@ -import _ from 'lodash-es'; +import { getUniqueTagListFromImages } from '@/react/docker/images/utils'; import { ImageViewModel } from '../models/image'; import { ImageDetailsViewModel } from '../models/imageDetails'; import { ImageLayerViewModel } from '../models/imageLayer'; @@ -200,16 +200,7 @@ angular.module('portainer.docker').factory('ImageService', [ return deferred.promise; }; - service.getUniqueTagListFromImages = function (availableImages) { - return _.uniq( - _.flatMap(availableImages, function (image) { - _.remove(image.RepoTags, function (item) { - return item.indexOf('') !== -1; - }); - return image.RepoTags ? _.uniqWith(image.RepoTags, _.isEqual) : []; - }) - ); - }; + service.getUniqueTagListFromImages = getUniqueTagListFromImages; return service; }, diff --git a/app/kubernetes/converters/daemonSet.js b/app/kubernetes/converters/daemonSet.js index a8d7828c2..abcedc542 100644 --- a/app/kubernetes/converters/daemonSet.js +++ b/app/kubernetes/converters/daemonSet.js @@ -10,7 +10,7 @@ import { import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper'; -import { buildImageFullURI } from 'Docker/helpers/imageHelper'; +import { buildImageFullURI } from '@/react/docker/images/utils'; class KubernetesDaemonSetConverter { /** diff --git a/app/kubernetes/converters/deployment.js b/app/kubernetes/converters/deployment.js index 0be5f98c7..950a01e4f 100644 --- a/app/kubernetes/converters/deployment.js +++ b/app/kubernetes/converters/deployment.js @@ -11,7 +11,7 @@ import { import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper'; -import { buildImageFullURI } from 'Docker/helpers/imageHelper'; +import { buildImageFullURI } from '@/react/docker/images/utils'; class KubernetesDeploymentConverter { /** diff --git a/app/kubernetes/converters/statefulSet.js b/app/kubernetes/converters/statefulSet.js index b418ae6f6..dda0a2345 100644 --- a/app/kubernetes/converters/statefulSet.js +++ b/app/kubernetes/converters/statefulSet.js @@ -12,7 +12,7 @@ import { import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper'; -import { buildImageFullURI } from 'Docker/helpers/imageHelper'; +import { buildImageFullURI } from '@/react/docker/images/utils'; import KubernetesPersistentVolumeClaimConverter from './persistentVolumeClaim'; class KubernetesStatefulSetConverter { diff --git a/app/react/components/ImageConfigFieldset/AdvancedForm.tsx b/app/react/components/ImageConfigFieldset/AdvancedForm.tsx new file mode 100644 index 000000000..29998a1fd --- /dev/null +++ b/app/react/components/ImageConfigFieldset/AdvancedForm.tsx @@ -0,0 +1,41 @@ +import { FormikErrors, useFormikContext } from 'formik'; + +import { FormControl } from '@@/form-components/FormControl'; +import { Input } from '@@/form-components/Input'; +import { TextTip } from '@@/Tip/TextTip'; + +import { Values } from './types'; + +export function AdvancedForm({ + values, + errors, + fieldNamespace, +}: { + values: Values; + errors?: FormikErrors; + fieldNamespace?: string; +}) { + const { setFieldValue } = useFormikContext(); + + return ( + <> + + When using advanced mode, image and repository must be publicly + available. + + + setFieldValue(namespaced('image'), e.target.value)} + placeholder="e.g. registry:port/my-image:my-tag" + required + /> + + + ); + + function namespaced(field: string) { + return fieldNamespace ? `${fieldNamespace}.${field}` : field; + } +} diff --git a/app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx b/app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx new file mode 100644 index 000000000..8f48fce5c --- /dev/null +++ b/app/react/components/ImageConfigFieldset/ImageConfigFieldset.tsx @@ -0,0 +1,78 @@ +import { Database, Globe } from 'lucide-react'; +import { FormikErrors, useFormikContext } from 'formik'; +import { PropsWithChildren } from 'react'; + +import { Button } from '@@/buttons'; + +import { SimpleForm } from './SimpleForm'; +import { Values } from './types'; +import { AdvancedForm } from './AdvancedForm'; +import { RateLimits } from './RateLimits'; + +export function ImageConfigFieldset({ + checkRateLimits, + children, + autoComplete, + setValidity, + fieldNamespace, + values, + errors, +}: PropsWithChildren<{ + values: Values; + errors?: FormikErrors; + fieldNamespace?: string; + checkRateLimits?: boolean; + autoComplete?: boolean; + setValidity: (error?: string) => void; +}>) { + const { setFieldValue } = useFormikContext(); + + const Component = values.useRegistry ? SimpleForm : AdvancedForm; + + return ( +
+ + +
+
+ {values.useRegistry ? ( + + ) : ( + + )} +
+
+ + {children} + + {checkRateLimits && values.useRegistry && ( + + )} +
+ ); + + function namespaced(field: string) { + return fieldNamespace ? `${fieldNamespace}.${field}` : field; + } +} diff --git a/app/react/components/ImageConfigFieldset/InputSearch.tsx b/app/react/components/ImageConfigFieldset/InputSearch.tsx new file mode 100644 index 000000000..38d1321ab --- /dev/null +++ b/app/react/components/ImageConfigFieldset/InputSearch.tsx @@ -0,0 +1,52 @@ +import { AutomationTestingProps } from '@/types'; + +import { Option } from '@@/form-components/PortainerSelect'; +import { Select } from '@@/form-components/ReactSelect'; + +export function InputSearch({ + value, + onChange, + options, + placeholder, + 'data-cy': dataCy, + inputId, +}: { + value: string; + onChange: (value: string) => void; + options: Option[]; + placeholder?: string; + inputId?: string; +} & AutomationTestingProps) { + const selectValue = options.find((option) => option.value === value) || { + value: '', + label: value, + }; + + return ( + onChange(e.target.value)} + id={inputId} + /> + ); +} + +function ImageFieldAutoComplete({ + value, + onChange, + registry, + inputId, +}: { + value: string; + onChange: (value: string) => void; + registry?: Registry; + inputId?: string; +}) { + const environmentId = useEnvironmentId(); + + const registriesQuery = useEnvironmentRegistries(environmentId); + + const imagesQuery = useImages(environmentId, { + select: (images) => getUniqueTagListFromImages(images), + }); + + const imageOptions = useMemo(() => { + const images = getImagesForRegistry( + imagesQuery.data || [], + registriesQuery.data || [], + registry + ); + return images.map((image) => ({ + label: image, + value: image, + })); + }, [registry, imagesQuery.data, registriesQuery.data]); + + return ( + onChange(value)} + data-cy="component-imageInput" + placeholder="e.g. my-image:my-tag" + options={imageOptions} + inputId={inputId} + /> + ); +} + +function isKnownRegistry(registry?: Registry) { + return registry && registry.Type !== RegistryTypes.ANONYMOUS && registry.URL; +} + +function getRegistryURL(registry?: Registry) { + if (!registry) { + return ''; + } + + if ( + registry.Type !== RegistryTypes.GITLAB && + registry.Type !== RegistryTypes.GITHUB + ) { + return registry.URL; + } + + if (registry.Type === RegistryTypes.GITLAB) { + return `${registry.URL}/${registry.Gitlab?.ProjectPath}`; + } + + if (registry.Type === RegistryTypes.GITHUB) { + const namespace = registry.Github?.UseOrganisation + ? registry.Github?.OrganisationName + : registry.Username; + return `${registry.URL}/${namespace}`; + } + + return ''; +} diff --git a/app/react/components/ImageConfigFieldset/index.ts b/app/react/components/ImageConfigFieldset/index.ts new file mode 100644 index 000000000..5d13e2d8b --- /dev/null +++ b/app/react/components/ImageConfigFieldset/index.ts @@ -0,0 +1,3 @@ +export { ImageConfigFieldset } from './ImageConfigFieldset'; +export { type Values as ImageConfigValues } from './types'; +export { validation as imageConfigValidation } from './validation'; diff --git a/app/react/components/ImageConfigFieldset/types.ts b/app/react/components/ImageConfigFieldset/types.ts new file mode 100644 index 000000000..5fed9dbf5 --- /dev/null +++ b/app/react/components/ImageConfigFieldset/types.ts @@ -0,0 +1,7 @@ +import { Registry } from '@/react/portainer/registries/types/registry'; + +export interface Values { + useRegistry: boolean; + registryId?: Registry['Id']; + image: string; +} diff --git a/app/react/components/ImageConfigFieldset/utils.ts b/app/react/components/ImageConfigFieldset/utils.ts new file mode 100644 index 000000000..8e056ec4c --- /dev/null +++ b/app/react/components/ImageConfigFieldset/utils.ts @@ -0,0 +1,12 @@ +import { + Registry, + RegistryTypes, +} from '@/react/portainer/registries/types/registry'; + +export function getIsDockerHubRegistry(registry?: Registry | null) { + return ( + !registry || + registry.Type === RegistryTypes.DOCKERHUB || + registry.Type === RegistryTypes.ANONYMOUS + ); +} diff --git a/app/react/components/ImageConfigFieldset/validation.ts b/app/react/components/ImageConfigFieldset/validation.ts new file mode 100644 index 000000000..550e4ef8d --- /dev/null +++ b/app/react/components/ImageConfigFieldset/validation.ts @@ -0,0 +1,11 @@ +import { bool, number, object, SchemaOf, string } from 'yup'; + +import { Values } from './types'; + +export function validation(): SchemaOf { + return object({ + image: string().required('Image is required'), + registryId: number().default(0), + useRegistry: bool().default(false), + }); +} diff --git a/app/react/docker/images/queries/encodeRegistryCredentials.ts b/app/react/docker/images/queries/encodeRegistryCredentials.ts new file mode 100644 index 000000000..2708962a5 --- /dev/null +++ b/app/react/docker/images/queries/encodeRegistryCredentials.ts @@ -0,0 +1,15 @@ +import { Registry } from '@/react/portainer/registries/types/registry'; + +/** + * Encodes the registry credentials in base64 + * @param registryId + * @returns + */ +export function encodeRegistryCredentials(registryId: Registry['Id']) { + const credentials = { + registryId, + }; + + const buf = Buffer.from(JSON.stringify(credentials)); + return buf.toString('base64url'); +} diff --git a/app/react/docker/images/queries/queryKeys.ts b/app/react/docker/images/queries/queryKeys.ts new file mode 100644 index 000000000..158cd6191 --- /dev/null +++ b/app/react/docker/images/queries/queryKeys.ts @@ -0,0 +1,9 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { queryKeys as dockerQueryKeys } from '../../queries/utils'; + +export const queryKeys = { + base: (environmentId: EnvironmentId) => + [dockerQueryKeys.root(environmentId), 'images'] as const, + list: (environmentId: EnvironmentId) => queryKeys.base(environmentId), +}; diff --git a/app/react/docker/images/queries/useImages.ts b/app/react/docker/images/queries/useImages.ts new file mode 100644 index 000000000..9580f78be --- /dev/null +++ b/app/react/docker/images/queries/useImages.ts @@ -0,0 +1,124 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildUrl } from '../../proxy/queries/build-url'; + +import { queryKeys } from './queryKeys'; + +interface ImageSummary { + /** + * Number of containers using this image. Includes both stopped and running containers. + * + * This size is not calculated by default, and depends on which API endpoint is used. + * `-1` indicates that the value has not been set / calculated. + * + * Required: true + */ + Containers: number; + + /** + * Date and time at which the image was created as a Unix timestamp + * (number of seconds sinds EPOCH). + * + * Required: true + */ + Created: number; + + /** + * ID is the content-addressable ID of an image. + * + * This identifier is a content-addressable digest calculated from the + * image's configuration (which includes the digests of layers used by + * the image). + * + * Note that this digest differs from the `RepoDigests` below, which + * holds digests of image manifests that reference the image. + * + * Required: true + */ + Id: string; + + /** + * User-defined key/value metadata. + * Required: true + */ + Labels: { [key: string]: string }; + + /** + * ID of the parent image. + * + * Depending on how the image was created, this field may be empty and + * is only set for images that were built/created locally. This field + * is empty if the image was pulled from an image registry. + * + * Required: true + */ + ParentId: string; + + /** + * List of content-addressable digests of locally available image manifests + * that the image is referenced from. Multiple manifests can refer to the + * same image. + * + * These digests are usually only available if the image was either pulled + * from a registry, or if the image was pushed to a registry, which is when + * the manifest is generated and its digest calculated. + * + * Required: true + */ + RepoDigests: string[]; + + /** + * List of image names/tags in the local image cache that reference this + * image. + * + * Multiple image tags can refer to the same image, and this list may be + * empty if no tags reference the image, in which case the image is + * "untagged", in which case it can still be referenced by its ID. + * + * Required: true + */ + RepoTags: string[]; + + /** + * Total size of image layers that are shared between this image and other + * images. + * + * This size is not calculated by default. `-1` indicates that the value + * has not been set / calculated. + * + * Required: true + */ + SharedSize: number; + Size: number; + VirtualSize: number; +} + +type ImagesListResponse = ImageSummary[]; + +export function useImages( + environmentId: EnvironmentId, + { + select, + enabled, + }: { select?(data: ImagesListResponse): T; enabled?: boolean } = {} +) { + return useQuery( + queryKeys.list(environmentId), + () => getImages(environmentId), + { select, enabled } + ); +} + +async function getImages(environmentId: EnvironmentId) { + try { + const { data } = await axios.get( + buildUrl(environmentId, 'images', 'json') + ); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve images'); + } +} diff --git a/app/react/docker/images/types.ts b/app/react/docker/images/types.ts new file mode 100644 index 000000000..adcd9ed74 --- /dev/null +++ b/app/react/docker/images/types.ts @@ -0,0 +1,22 @@ +import { DockerImageResponse } from './types/response'; + +type Status = 'outdated' | 'updated' | 'inprocess' | string; + +export enum ResourceType { + CONTAINER, + SERVICE, +} + +export interface ImageStatus { + Status: Status; + Message: string; +} + +export type ResourceID = string; + +type DecoratedDockerImage = { + Used: boolean; +}; + +export type DockerImage = DecoratedDockerImage & + Omit; diff --git a/app/react/docker/images/types/response.ts b/app/react/docker/images/types/response.ts new file mode 100644 index 000000000..7c8b19f54 --- /dev/null +++ b/app/react/docker/images/types/response.ts @@ -0,0 +1,12 @@ +export type DockerImageResponse = { + Containers: number; + Created: number; + Id: string; + Labels: { [key: string]: string }; + ParentId: string; + RepoDigests: string[]; + RepoTags: string[]; + SharedSize: number; + Size: number; + VirtualSize: number; +}; diff --git a/app/react/docker/images/utils.ts b/app/react/docker/images/utils.ts new file mode 100644 index 000000000..f49bebe8c --- /dev/null +++ b/app/react/docker/images/utils.ts @@ -0,0 +1,101 @@ +import _ from 'lodash'; + +import { trimSHA } from '@/docker/filters/utils'; +import { + Registry, + RegistryTypes, +} from '@/react/portainer/registries/types/registry'; + +import { DockerImage } from './types'; +import { DockerImageResponse } from './types/response'; + +export function parseViewModel(response: DockerImageResponse): DockerImage { + return { + ...response, + Used: false, + RepoTags: + response.RepoTags ?? + response.RepoDigests.map((digest) => `${trimSHA(digest)}:`), + }; +} + +export function getUniqueTagListFromImages( + images: Array<{ RepoTags?: string[] }> +) { + return _.uniq( + images.flatMap((image) => + image.RepoTags + ? image.RepoTags.filter((item) => !item.includes('')) + : [] + ) + ); +} + +export function imageContainsURL(image: string) { + const split = image.split('/'); + const url = split[0]; + if (split.length > 1) { + return url.includes('.') || url.includes(':'); + } + return false; +} + +/** + * builds the complete uri for an image based on its registry + * @param {PorImageRegistryModel} imageModel + */ +export function buildImageFullURI(image: string, registry?: Registry) { + if (!registry) { + return ensureTag(image); + } + + const imageName = buildImageFullURIWithRegistry(image, registry); + + return ensureTag(imageName); + + function ensureTag(image: string, defaultTag = 'latest') { + return image.includes(':') ? image : `${image}:${defaultTag}`; + } +} + +function buildImageFullURIWithRegistry(image: string, registry: Registry) { + switch (registry.Type) { + case RegistryTypes.GITHUB: + return buildImageURIForGithub(image, registry); + case RegistryTypes.GITLAB: + return buildImageURIForGitLab(image, registry); + case RegistryTypes.QUAY: + return buildImageURIForQuay(image, registry); + case RegistryTypes.ANONYMOUS: + return image; + default: + return buildImageURIForOtherRegistry(image, registry); + } + + function buildImageURIForGithub(image: string, registry: Registry) { + const imageName = image.split('/').pop(); + + const namespace = registry.Github.UseOrganisation + ? registry.Github.OrganisationName + : registry.Username; + return `${registry.URL}/${namespace}/${imageName}`; + } + + function buildImageURIForGitLab(image: string, registry: Registry) { + const slash = image.startsWith(':') ? '' : '/'; + return `${registry.URL}/${registry.Gitlab.ProjectPath}${slash}${image}`; + } + + function buildImageURIForQuay(image: string, registry: Registry) { + const name = registry.Quay.UseOrganisation + ? registry.Quay.OrganisationName + : registry.Username; + const url = registry.URL ? `${registry.URL}/` : ''; + return `${url}${name}/${image}`; + } + + function buildImageURIForOtherRegistry(image: string, registry: Registry) { + const url = registry.URL ? `${registry.URL}/` : ''; + return url + image; + } +} diff --git a/app/react/docker/proxy/queries/build-url.ts b/app/react/docker/proxy/queries/build-url.ts new file mode 100644 index 000000000..e4bb13900 --- /dev/null +++ b/app/react/docker/proxy/queries/build-url.ts @@ -0,0 +1,15 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export function buildUrl( + environmentId: EnvironmentId, + action: string, + subAction = '' +) { + let url = `/endpoints/${environmentId}/docker/${action}`; + + if (subAction) { + url += `/${subAction}`; + } + + return url; +} diff --git a/app/react/portainer/environments/queries/query-keys.ts b/app/react/portainer/environments/queries/query-keys.ts index 79f7b3cef..4dd971478 100644 --- a/app/react/portainer/environments/queries/query-keys.ts +++ b/app/react/portainer/environments/queries/query-keys.ts @@ -3,4 +3,6 @@ import { EnvironmentId } from '../types'; export const queryKeys = { base: () => ['environments'] as const, item: (id: EnvironmentId) => [...queryKeys.base(), id] as const, + registries: (environmentId: EnvironmentId) => + [...queryKeys.base(), environmentId, 'registries'] as const, }; diff --git a/app/react/portainer/environments/queries/useEnvironmentRegistries.ts b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts new file mode 100644 index 000000000..9fc535cc6 --- /dev/null +++ b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts @@ -0,0 +1,30 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { buildUrl } from '../environment.service/utils'; +import { EnvironmentId } from '../types'; +import { Registry } from '../../registries/types/registry'; +import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries'; + +import { queryKeys } from './query-keys'; + +export function useEnvironmentRegistries>( + environmentId: EnvironmentId, + queryOptions: { select?(data: Array): T; enabled?: boolean } = {} +) { + return useGenericRegistriesQuery( + queryKeys.registries(environmentId), + () => getEnvironmentRegistries(environmentId), + queryOptions + ); +} + +async function getEnvironmentRegistries(environmentId: EnvironmentId) { + try { + const { data } = await axios.get>( + buildUrl(environmentId, 'registries') + ); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve registries'); + } +} diff --git a/app/react/portainer/registries/queries/build-url.ts b/app/react/portainer/registries/queries/build-url.ts new file mode 100644 index 000000000..266c865d5 --- /dev/null +++ b/app/react/portainer/registries/queries/build-url.ts @@ -0,0 +1,11 @@ +import { RegistryId } from '../types/registry'; + +export function buildUrl(registryId: RegistryId) { + const base = '/registries'; + + if (registryId) { + return `${base}/${registryId}`; + } + + return base; +} diff --git a/app/react/portainer/registries/queries/query-keys.ts b/app/react/portainer/registries/queries/query-keys.ts new file mode 100644 index 000000000..5afa6c0fc --- /dev/null +++ b/app/react/portainer/registries/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { RegistryId } from '../types/registry'; + +export const queryKeys = { + base: () => ['registries'] as const, + item: (registryId: RegistryId) => [...queryKeys.base(), registryId] as const, +}; diff --git a/app/react/portainer/registries/queries/useRegistries.ts b/app/react/portainer/registries/queries/useRegistries.ts index 4419903c2..e693ae129 100644 --- a/app/react/portainer/registries/queries/useRegistries.ts +++ b/app/react/portainer/registries/queries/useRegistries.ts @@ -1,20 +1,82 @@ -import { useQuery } from 'react-query'; +import { QueryKey, useQuery } from 'react-query'; +import { withError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { Registry } from '../types'; +import { Registry, RegistryTypes } from '../types/registry'; +import { usePublicSettings } from '../../settings/queries'; -import { queryKeys } from './queryKeys'; +import { queryKeys } from './query-keys'; -export function useRegistries() { - return useQuery(queryKeys.registries(), getRegistries); +export function useRegistries( + queryOptions: { + enabled?: boolean; + select?: (registries: Registry[]) => T; + onSuccess?: (data: T) => void; + } = {} +) { + return useGenericRegistriesQuery( + queryKeys.base(), + getRegistries, + queryOptions + ); } -async function getRegistries() { +export function useGenericRegistriesQuery( + queryKey: QueryKey, + fetcher: () => Promise>, + { + enabled, + select, + onSuccess, + }: { + enabled?: boolean; + select?: (registries: Registry[]) => T; + onSuccess?: (data: T) => void; + } = {} +) { + const hideDefaultRegistryQuery = usePublicSettings({ + select: (settings) => settings.DefaultRegistry?.Hide, + enabled, + }); + + const hideDefault = !!hideDefaultRegistryQuery.data; + + return useQuery( + queryKey, + async () => { + const registries = await fetcher(); + + if ( + hideDefault || + registries.some((r) => r.Type === RegistryTypes.DOCKERHUB) + ) { + return registries; + } + + return [ + { + Name: 'Docker Hub (anonymous)', + Id: 0, + Type: RegistryTypes.DOCKERHUB, + } as Registry, + ...registries, + ]; + }, + { + select, + ...withError('Unable to retrieve registries'), + enabled: hideDefaultRegistryQuery.isSuccess && enabled, + onSuccess, + } + ); +} + +export async function getRegistries() { try { - const response = await axios.get>('/registries'); - return response.data; - } catch (err) { - throw parseAxiosError(err as Error, 'Unable to retrieve registries'); + const { data } = await axios.get('/registries'); + return data; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve registries'); } } diff --git a/app/react/portainer/registries/queries/useRegistry.ts b/app/react/portainer/registries/queries/useRegistry.ts new file mode 100644 index 000000000..df44444ec --- /dev/null +++ b/app/react/portainer/registries/queries/useRegistry.ts @@ -0,0 +1,27 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { Registry } from '../types/registry'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +export function useRegistry(registryId?: Registry['Id']) { + return useQuery( + registryId ? queryKeys.item(registryId) : [], + () => (registryId ? getRegistry(registryId) : undefined), + { + enabled: !!registryId, + } + ); +} + +async function getRegistry(registryId: Registry['Id']) { + try { + const { data } = await axios.get(buildUrl(registryId)); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve registry'); + } +} diff --git a/app/react/portainer/registries/registry.service.ts b/app/react/portainer/registries/registry.service.ts new file mode 100644 index 000000000..36cd84838 --- /dev/null +++ b/app/react/portainer/registries/registry.service.ts @@ -0,0 +1,32 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { Catalog, Repository } from './types/registry'; + +export async function listRegistryCatalogs(registryId: number) { + try { + const { data } = await axios.get( + `/registries/${registryId}/v2/_catalog` + ); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Failed to get catalog of registry'); + } +} + +export async function listRegistryCatalogsRepository( + registryId: number, + repositoryName: string +) { + try { + const { data } = await axios.get( + `/registries/${registryId}/v2/${repositoryName}/tags/list`, + {} + ); + return data; + } catch (err) { + throw parseAxiosError( + err as Error, + 'Failed to get catelog repository of regisry' + ); + } +} diff --git a/app/react/portainer/registries/types/registry.ts b/app/react/portainer/registries/types/registry.ts new file mode 100644 index 000000000..c068c559c --- /dev/null +++ b/app/react/portainer/registries/types/registry.ts @@ -0,0 +1,79 @@ +import { TeamId } from '@/react/portainer/users/teams/types'; +import { UserId } from '@/portainer/users/types'; + +export type Catalog = { + repositories: string[]; +}; + +export type Repository = { + name: string; + tags: string[]; +}; + +export enum RegistryTypes { + ANONYMOUS, + QUAY, + AZURE, + CUSTOM, + GITLAB, + PROGET, + DOCKERHUB, + ECR, + GITHUB, +} + +export type RoleId = number; +interface AccessPolicy { + RoleId: RoleId; +} + +type UserAccessPolicies = Record; // map[UserID]AccessPolicy +type TeamAccessPolicies = Record; + +export interface RegistryAccess { + UserAccessPolicies: UserAccessPolicies; + TeamAccessPolicies: TeamAccessPolicies; + Namespaces: string[]; +} + +export interface RegistryAccesses { + [key: string]: RegistryAccess; +} + +export interface Gitlab { + ProjectId: number; + InstanceURL: string; + ProjectPath: string; +} + +export interface Quay { + UseOrganisation: boolean; + OrganisationName: string; +} + +export interface Github { + UseOrganisation: boolean; + OrganisationName: string; +} + +export interface Ecr { + Region: string; +} + +export type RegistryId = number; +export interface Registry { + Id: RegistryId; + Type: number; + Name: string; + URL: string; + BaseURL: string; + Authentication: boolean; + Username: string; + Password: string; + RegistryAccesses: RegistryAccesses; + Checked: boolean; + Gitlab: Gitlab; + Quay: Quay; + Github: Github; + Ecr: Ecr; +} diff --git a/app/react/portainer/registries/utils/findRegistryMatch.test.ts b/app/react/portainer/registries/utils/findRegistryMatch.test.ts new file mode 100644 index 000000000..992509b49 --- /dev/null +++ b/app/react/portainer/registries/utils/findRegistryMatch.test.ts @@ -0,0 +1,83 @@ +import { Registry, RegistryId, RegistryTypes } from '../types/registry'; + +import { findBestMatchRegistry } from './findRegistryMatch'; + +function buildTestRegistry( + id: RegistryId, + type: RegistryTypes, + name: string, + url: string +): Registry { + return { + Id: id, + Type: type, + URL: url, + Name: name, + Username: '', + Authentication: false, + Password: '', + BaseURL: '', + Checked: false, + Ecr: { Region: '' }, + Github: { OrganisationName: '', UseOrganisation: false }, + Quay: { OrganisationName: '', UseOrganisation: false }, + Gitlab: { InstanceURL: '', ProjectId: 0, ProjectPath: '' }, + RegistryAccesses: {}, + }; +} + +describe('findBestMatchRegistry', () => { + const registries: Array = [ + buildTestRegistry( + 1, + RegistryTypes.DOCKERHUB, + 'DockerHub', + 'hub.docker.com' + ), + buildTestRegistry( + 2, + RegistryTypes.DOCKERHUB, + 'DockerHub2', + 'https://registry2.com' + ), + buildTestRegistry( + 3, + RegistryTypes.GITHUB, + 'GitHub', + 'https://registry3.com' + ), + ]; + + it('should return the registry with the given ID', () => { + const registryId = 2; + const result = findBestMatchRegistry('repository', registries, registryId); + expect(result).toEqual(registries[1]); + }); + + it('should return the DockerHub registry with matching username and URL', () => { + const repository = 'user1/repository'; + const result = findBestMatchRegistry(repository, registries); + expect(result).toEqual(registries[0]); + }); + + it('should return the registry with a matching URL', () => { + const repository = 'https://registry2.com/repository'; + const result = findBestMatchRegistry(repository, registries); + expect(result).toEqual(registries[1]); + }); + + it('should return the default DockerHub registry if no matches are found', () => { + const repository = 'repository'; + const result = findBestMatchRegistry(repository, registries); + expect(result).toEqual(registries[0]); + }); + + it('when using something:latest, shouldn\'t choose "tes" docker', () => { + const repository = 'something:latest'; + const result = findBestMatchRegistry(repository, [ + ...registries, + buildTestRegistry(4, RegistryTypes.CUSTOM, 'Test', 'tes'), + ]); + expect(result).toEqual(registries[0]); + }); +}); diff --git a/app/react/portainer/registries/utils/findRegistryMatch.ts b/app/react/portainer/registries/utils/findRegistryMatch.ts new file mode 100644 index 000000000..e32fe6e52 --- /dev/null +++ b/app/react/portainer/registries/utils/findRegistryMatch.ts @@ -0,0 +1,40 @@ +import { Registry, RegistryId, RegistryTypes } from '../types/registry'; + +import { getURL } from './getUrl'; + +/** + * findBestMatchRegistry finds out the best match registry for repository + * matching precedence: + * 1. registryId matched + * 2. both domain name and username matched (for dockerhub only) + * 3. only URL matched + * 4. pick up the first dockerhub registry + */ +export function findBestMatchRegistry( + repository: string, + registries: Array, + registryId?: RegistryId +) { + if (registryId) { + return registries.find((r) => r.Id === registryId); + } + + const matchDockerByUserAndUrl = registries.find( + (r) => + r.Type === RegistryTypes.DOCKERHUB && + (repository.startsWith(`${r.Username}/`) || + repository.startsWith(`${getURL(r)}/${r.Username}/`)) + ); + + if (matchDockerByUserAndUrl) { + return matchDockerByUserAndUrl; + } + + const matchByUrl = registries.find((r) => repository.startsWith(getURL(r))); + + if (matchByUrl) { + return matchByUrl; + } + + return registries.find((r) => r.Type === RegistryTypes.DOCKERHUB); +} diff --git a/app/react/portainer/registries/utils/getImageConfig.ts b/app/react/portainer/registries/utils/getImageConfig.ts new file mode 100644 index 000000000..3beeab9ea --- /dev/null +++ b/app/react/portainer/registries/utils/getImageConfig.ts @@ -0,0 +1,43 @@ +import { imageContainsURL } from '@/react/docker/images/utils'; + +import { ImageConfigValues } from '@@/ImageConfigFieldset'; + +import { Registry, RegistryId } from '../types/registry'; + +import { findBestMatchRegistry } from './findRegistryMatch'; +import { getURL } from './getUrl'; + +export function getDefaultImageConfig(): ImageConfigValues { + return { + registryId: 0, + image: '', + useRegistry: true, + }; +} + +export function getImageConfig( + repository: string, + registries: Registry[], + registryId?: RegistryId +): ImageConfigValues { + const registry = findBestMatchRegistry(repository, registries, registryId); + if (registry) { + const url = getURL(registry); + let lastIndex = repository.lastIndexOf(url); + lastIndex = lastIndex === -1 ? 0 : lastIndex + url.length; + let image = repository.substring(lastIndex); + if (image.startsWith('/')) { + image = image.substring(1); + } + + return { + useRegistry: true, + image, + registryId: registry.Id, + }; + } + return { + image: repository, + useRegistry: imageContainsURL(repository), + }; +} diff --git a/app/react/portainer/registries/utils/getUrl.ts b/app/react/portainer/registries/utils/getUrl.ts new file mode 100644 index 000000000..8841f050f --- /dev/null +++ b/app/react/portainer/registries/utils/getUrl.ts @@ -0,0 +1,31 @@ +import { Registry, RegistryTypes } from '../types/registry'; + +export function getURL(registry: Registry) { + switch (registry.Type) { + case RegistryTypes.GITLAB: + return `${registry.URL}/${registry.Gitlab.ProjectPath}`; + + case RegistryTypes.QUAY: + return getQuayUrl(registry); + + case RegistryTypes.GITHUB: + return getGithubUrl(registry); + + default: + return registry.URL; + } + + function getGithubUrl(registry: Registry) { + const name = registry.Github.UseOrganisation + ? registry.Github.OrganisationName + : registry.Username; + return `${registry.URL}/${name}`; + } + + function getQuayUrl(registry: Registry) { + const name = registry.Quay.UseOrganisation + ? registry.Quay.OrganisationName + : registry.Username; + return `${registry.URL}/${name}`; + } +} diff --git a/app/react/portainer/settings/types.ts b/app/react/portainer/settings/types.ts index 61b17c3c9..e8f186a6c 100644 --- a/app/react/portainer/settings/types.ts +++ b/app/react/portainer/settings/types.ts @@ -185,9 +185,8 @@ export interface PublicSettingsResponse { IsFDOEnabled: boolean; /** Whether AMT is enabled */ IsAMTEnabled: boolean; - /** Whether to hide default registry (only on BE) */ - DefaultRegistry: { + DefaultRegistry?: { Hide: boolean; }; Edge: {