diff --git a/src/api/image-registries.tsx b/src/api/image-registries.tsx index 8236f46eab..9f41a53bfb 100644 --- a/src/api/image-registries.tsx +++ b/src/api/image-registries.tsx @@ -1,5 +1,5 @@ import { handleResponse } from "util/helpers"; -import type { LxdImageRegistry } from "types/image"; +import type { LxdImage, LxdImageRegistry } from "types/image"; import type { LxdApiResponse } from "types/apiResponse"; import { addEntitlements } from "util/entitlements/api"; import { ROOT_PATH } from "util/rootPath"; @@ -34,3 +34,19 @@ export const fetchImageRegistry = async ( return data.metadata; }); }; + +export const fetchRegistryImages = async ( + name: string, + isFineGrained: boolean | null, +): Promise => { + const params = new URLSearchParams(); + params.set("recursion", "1"); + addEntitlements(params, isFineGrained, imageRegistryEntitlements); + return fetch( + `${ROOT_PATH}/1.0/image-registries/${encodeURIComponent(name)}/images?${params.toString()}`, + ) + .then(handleResponse) + .then((data: LxdApiResponse) => { + return data.metadata; + }); +}; diff --git a/src/pages/images/ImageSelector.tsx b/src/pages/images/ImageSelector.tsx index 66a4f28cee..9061425f89 100644 --- a/src/pages/images/ImageSelector.tsx +++ b/src/pages/images/ImageSelector.tsx @@ -12,13 +12,12 @@ import { Select, Spinner, } from "@canonical/react-components"; -import type { LxdImageType, RemoteImage, RemoteImageList } from "types/image"; -import { capitalizeFirstLetter, handleResponse } from "util/helpers"; +import type { LxdImageType, RemoteImage } from "types/image"; +import { capitalizeFirstLetter } from "util/helpers"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import type { MainTableRow } from "@canonical/react-components/dist/components/MainTable/MainTable"; import { - byLtsFirst, byOSRelease, localLxdToRemoteImage, isContainerOnlyImage, @@ -31,23 +30,20 @@ import { instanceCreationTypes } from "util/instanceOptions"; import { useSettings } from "context/useSettings"; import { useParams } from "react-router-dom"; import { useLocalImagesInProject } from "context/useImages"; +import { useSupportedFeatures } from "context/useSupportedFeatures"; +import { useAuth } from "context/auth"; +import { + canonicalServer, + imagesLxdServer, + minimalServer, +} from "util/imageLegacy"; +import { loadImages } from "util/imageLoader"; interface Props { onSelect: (image: RemoteImage, type?: LxdImageType) => void; onClose: () => void; } -const canonicalJson = - "https://cloud-images.ubuntu.com/releases/streams/v1/com.ubuntu.cloud:released:download.json"; -const canonicalServer = "https://cloud-images.ubuntu.com/releases"; - -const minimalJson = - "https://cloud-images.ubuntu.com/minimal/releases/streams/v1/com.ubuntu.cloud:released:download.json"; -const minimalServer = "https://cloud-images.ubuntu.com/minimal/releases/"; - -const imagesLxdJson = "https://images.lxd.canonical.com/streams/v1/images.json"; -const imagesLxdServer = "https://images.lxd.canonical.com/"; - const ANY = "any"; const CONTAINER = "container"; const VM = "virtual-machine"; @@ -61,55 +57,25 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { const [variant, setVariant] = useState(ANY); const [hideRemote, setHideRemote] = useState(false); const { project } = useParams<{ project: string }>(); - - const loadImages = async ( - file: string, - server: string, - ): Promise => { - return new Promise((resolve, reject) => { - fetch(file) - .then(handleResponse) - .then((data: RemoteImageList) => { - const images = Object.entries(data.products).map((product) => { - const { os, ...image } = product[1]; - const formattedOs = capitalizeFirstLetter(os); - return { ...image, os: formattedOs, server: server }; - }); - resolve(images); - }) - .catch(reject); - }); - }; + const { hasImageRegistries } = useSupportedFeatures(); + const { isFineGrained } = useAuth(); const { data: settings, isLoading: isSettingsLoading } = useSettings(); - const { data: canonicalImages = [], isLoading: isCiLoading } = useQuery({ - queryKey: [queryKeys.images, canonicalServer], - queryFn: async () => loadImages(canonicalJson, canonicalServer), - retry: false, // avoid retry to ease experience in airgapped deployments - }); - - const { data: minimalImages = [], isLoading: isMinimalLoading } = useQuery({ - queryKey: [queryKeys.images, minimalServer], - queryFn: async () => loadImages(minimalJson, minimalServer), - retry: false, // avoid retry to ease experience in airgapped deployments - }); - - const { data: imagesLxdImages = [], isLoading: isImagesLxdLoading } = + const { data: remoteImages = [], isLoading: isRemoteImagesLoading } = useQuery({ - queryKey: [queryKeys.images, imagesLxdServer], - queryFn: async () => loadImages(imagesLxdJson, imagesLxdServer), - retry: false, // avoid retry to ease experience in airgapped deployments + queryKey: [queryKeys.images, "selector", hasImageRegistries], + queryFn: async () => loadImages(hasImageRegistries, !!isFineGrained), + retry: false, // avoid retry to ease experience in air gapped deployments + enabled: isFineGrained !== null, }); const { data: localImages = [], isLoading: isLocalImageLoading } = useLocalImagesInProject(project ?? "default"); - const isRemoteImagesLoading = - !hideRemote && (isCiLoading || isMinimalLoading || isImagesLxdLoading); - + const isWaitingForRemoteImages = !hideRemote && isRemoteImagesLoading; const isLoading = - isRemoteImagesLoading || isLocalImageLoading || isSettingsLoading; + isWaitingForRemoteImages || isLocalImageLoading || isSettingsLoading; const archSupported = getArchitectureAliases( settings?.environment?.architectures ?? [], @@ -119,9 +85,7 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { : localImages .map(localLxdToRemoteImage) .sort(byOSRelease) - .concat([...canonicalImages].reverse().sort(byLtsFirst)) - .concat([...minimalImages].reverse().sort(byLtsFirst)) - .concat([...imagesLxdImages]) + .concat(remoteImages) .filter((image) => archSupported.includes(image.arch)); const archAll = [...new Set(images.map((item) => item.arch))] @@ -231,13 +195,19 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { if (!item.cached && item.created_at) { source = "Local"; } + if (item.registryName) { + source = item.registryName + .split("-") + .map(capitalizeFirstLetter) + .join(" "); + } if (item.server === canonicalServer) { source = "Ubuntu"; } if (item.server === minimalServer) { source = "Ubuntu Minimal"; } - if (item.server === imagesLxdServer) { + if (item.server === imagesLxdServer || item.registryName === "images") { source = "LXD Images"; } return source; @@ -280,6 +250,7 @@ const ImageSelector: FC = ({ onSelect, onClose }) => { { className: "u-hide--small u-hide--medium", content: item.aliases.split(",").pop(), + title: item.aliases.split(",").pop(), role: "cell", "aria-label": "Alias", onClick: selectImage, diff --git a/src/types/image.d.ts b/src/types/image.d.ts index f3d9893f12..770f977b08 100644 --- a/src/types/image.d.ts +++ b/src/types/image.d.ts @@ -15,6 +15,7 @@ export interface LxdImage { description?: string; os: string; release: string; + type?: string; variant?: string; version?: string; }; @@ -80,6 +81,9 @@ export interface RemoteImage { type?: LxdImageType; fingerprint?: string; cached?: boolean; + isLts?: boolean; + registryName?: string; + title?: string; } export interface RemoteImageList { diff --git a/src/util/imageLegacy.ts b/src/util/imageLegacy.ts new file mode 100644 index 0000000000..59a4624d5b --- /dev/null +++ b/src/util/imageLegacy.ts @@ -0,0 +1,47 @@ +import type { RemoteImage, RemoteImageList } from "types/image"; +import { capitalizeFirstLetter, handleResponse } from "util/helpers"; +import { byLtsFirst } from "util/images"; + +const canonicalJson = + "https://cloud-images.ubuntu.com/releases/streams/v1/com.ubuntu.cloud:released:download.json"; +export const canonicalServer = "https://cloud-images.ubuntu.com/releases"; + +const minimalJson = + "https://cloud-images.ubuntu.com/minimal/releases/streams/v1/com.ubuntu.cloud:released:download.json"; +export const minimalServer = + "https://cloud-images.ubuntu.com/minimal/releases/"; + +const imagesLxdJson = "https://images.lxd.canonical.com/streams/v1/images.json"; +export const imagesLxdServer = "https://images.lxd.canonical.com/"; + +// fetching directly from hard coded indexes and servers +export const loadImagesLegacy = async () => { + const canonicalImages = await loadImageJson(canonicalJson, canonicalServer); + const minimalImages = await loadImageJson(minimalJson, minimalServer); + const imagesLxdImages = await loadImageJson(imagesLxdJson, imagesLxdServer); + + return [...canonicalImages] + .reverse() + .sort(byLtsFirst) + .concat([...minimalImages].reverse().sort(byLtsFirst)) + .concat([...imagesLxdImages]); +}; + +const loadImageJson = async ( + file: string, + server: string, +): Promise => { + return new Promise((resolve, reject) => { + fetch(file) + .then(handleResponse) + .then((data: RemoteImageList) => { + const images = Object.entries(data.products).map((product) => { + const { os, ...image } = product[1]; + const formattedOs = capitalizeFirstLetter(os); + return { ...image, os: formattedOs, server: server }; + }); + resolve(images); + }) + .catch(reject); + }); +}; diff --git a/src/util/imageLoader.ts b/src/util/imageLoader.ts new file mode 100644 index 0000000000..b2e535552e --- /dev/null +++ b/src/util/imageLoader.ts @@ -0,0 +1,11 @@ +import { loadImagesFromAllRegistries } from "util/imageRegistry"; +import { loadImagesLegacy } from "util/imageLegacy"; + +export const loadImages = async ( + hasImageRegistries: boolean, + isFineGrained: boolean, +) => { + return hasImageRegistries + ? loadImagesFromAllRegistries(isFineGrained) + : loadImagesLegacy(); +}; diff --git a/src/util/imageRegistry.ts b/src/util/imageRegistry.ts new file mode 100644 index 0000000000..3dc720dd98 --- /dev/null +++ b/src/util/imageRegistry.ts @@ -0,0 +1,97 @@ +import { byLtsFirst, byOSRelease, localLxdToRemoteImage } from "util/images"; +import type { RemoteImage } from "types/image"; +import { + fetchImageRegistries, + fetchRegistryImages, +} from "api/image-registries"; + +// fetch image registries and images from all configured registries +export const loadImagesFromAllRegistries = async ( + isFineGrained: boolean, +): Promise => { + const registries = await fetchImageRegistries(isFineGrained); + + const imagesByRegistry: Record = {}; + const imageRequests = await Promise.allSettled( + registries.map(async (registry) => { + const registryImages = await fetchRegistryImages( + registry.name, + isFineGrained, + ); + + imagesByRegistry[registry.name] = registryImages + .filter((image) => image.aliases !== null) + .map((image) => { + const item = localLxdToRemoteImage(image); + const ltsAlias = image.aliases.find((a) => a.name === "lts"); + + return { + ...item, + isLts: ltsAlias !== undefined, + registryName: registry.name, + server: registry.config?.url, + title: item.os + item.release_title + item.release + item.server, + }; + }) + .sort(byOSRelease); + }), + ); + + // if any image request failed, throw + imageRequests.forEach((res) => { + if (res.status !== "fulfilled") { + throw new Error(res.reason as string); + } + }); + + const uniqueTitle = ( + item: RemoteImage, + index: number, + self: RemoteImage[], + ) => { + return index === self.findIndex((t) => t.title === item.title); + }; + + const markVmAndContainerImagesAsOne = (item: RemoteImage) => { + delete item.type; + item.versions = { + vm: { + items: { + "disk1.img": { ftype: "disk1.img" }, + }, + }, + }; + }; + + const ubuntuRegistries = [ + "ubuntu", + "ubuntu-minimal", + "ubuntu-daily", + "ubuntu-minimal-daily", + ]; + + ubuntuRegistries.forEach((name) => { + const images = name in imagesByRegistry ? imagesByRegistry[name] : []; + imagesByRegistry[name] = images.filter(uniqueTitle); + imagesByRegistry[name].map(markVmAndContainerImagesAsOne); + imagesByRegistry[name].reverse().sort(byLtsFirst); + }); + + // order for builtin registries + const result: RemoteImage[] = [ + ...imagesByRegistry["ubuntu"], + ...imagesByRegistry["ubuntu-minimal"], + ...("images" in imagesByRegistry ? imagesByRegistry["images"] : []), + ...imagesByRegistry["ubuntu-daily"], + ...imagesByRegistry["ubuntu-minimal-daily"], + ]; + + // add images from custom registries + Object.entries(imagesByRegistry).forEach(([registry, images]) => { + if (!ubuntuRegistries.includes(registry) && registry !== "images") { + result.push(...images); + } + }); + + return result; +}; diff --git a/src/util/images.tsx b/src/util/images.tsx index 4a5ab061fc..c6247c6086 100644 --- a/src/util/images.tsx +++ b/src/util/images.tsx @@ -64,10 +64,10 @@ export const localLxdToRemoteImage = (image: LxdImage): RemoteImage => { }; export const byLtsFirst = (a: RemoteImage, b: RemoteImage): number => { - if (a.aliases.includes("lts")) { + if (a.aliases.includes("lts") || a.isLts) { return -1; } - if (b.aliases.includes("lts")) { + if (b.aliases.includes("lts") || b.isLts) { return 1; } return 0;