Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/api/image-registries.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,3 +34,19 @@ export const fetchImageRegistry = async (
return data.metadata;
});
};

export const fetchRegistryImages = async (
name: string,
isFineGrained: boolean | null,
): Promise<LxdImage[]> => {
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<LxdImage[]>) => {
return data.metadata;
});
};
85 changes: 28 additions & 57 deletions src/pages/images/ImageSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -61,55 +57,25 @@ const ImageSelector: FC<Props> = ({ onSelect, onClose }) => {
const [variant, setVariant] = useState<string>(ANY);
const [hideRemote, setHideRemote] = useState(false);
const { project } = useParams<{ project: string }>();

const loadImages = async (
file: string,
server: string,
): Promise<RemoteImage[]> => {
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 ?? [],
Expand All @@ -119,9 +85,7 @@ const ImageSelector: FC<Props> = ({ 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))]
Expand Down Expand Up @@ -231,13 +195,19 @@ const ImageSelector: FC<Props> = ({ 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;
Expand Down Expand Up @@ -280,6 +250,7 @@ const ImageSelector: FC<Props> = ({ 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,
Expand Down
4 changes: 4 additions & 0 deletions src/types/image.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface LxdImage {
description?: string;
os: string;
release: string;
type?: string;
variant?: string;
version?: string;
};
Expand Down Expand Up @@ -80,6 +81,9 @@ export interface RemoteImage {
type?: LxdImageType;
fingerprint?: string;
cached?: boolean;
isLts?: boolean;
registryName?: string;
title?: string;
}

export interface RemoteImageList {
Expand Down
47 changes: 47 additions & 0 deletions src/util/imageLegacy.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteImage[]> => {
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);
});
};
11 changes: 11 additions & 0 deletions src/util/imageLoader.ts
Original file line number Diff line number Diff line change
@@ -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();
};
97 changes: 97 additions & 0 deletions src/util/imageRegistry.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteImage[]> => {
const registries = await fetchImageRegistries(isFineGrained);

const imagesByRegistry: Record<string, RemoteImage[]> = {};
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;
};
4 changes: 2 additions & 2 deletions src/util/images.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading