Skip to content

Commit

Permalink
feat: support vmdk import for vm instance creation
Browse files Browse the repository at this point in the history
Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Aug 29, 2024
1 parent 2ed2678 commit 1581612
Show file tree
Hide file tree
Showing 8 changed files with 502 additions and 2 deletions.
3 changes: 3 additions & 0 deletions src/context/useSupportedFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,8 @@ export const useSupportedFeatures = () => {
hasAccessManagement: apiExtensions.has("access_management"),
hasExplicitTrustToken: apiExtensions.has("explicit_trust_token"),
hasInstanceCreateStart: apiExtensions.has("instance_create_start"),
hasInstanceImportConversion: apiExtensions.has(
"instance_import_conversion",
),
};
};
180 changes: 180 additions & 0 deletions src/pages/instances/actions/ConvertToInstanceBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { FC, useState } from "react";
import { Button } from "@canonical/react-components";
import usePortal from "react-useportal";
import ConvertToInstanceForm from "../forms/ConvertToInstanceForm";
import { useToastNotification } from "context/toastNotificationProvider";
import { useNavigate } from "react-router-dom";
import { useEventQueue } from "context/eventQueue";
import { useQueryClient } from "@tanstack/react-query";
import { UploadState } from "types/storage";
import { useFormik } from "formik";
import { createInstance } from "api/instances";
import { useProject } from "context/project";
import { queryKeys } from "util/queryKeys";
import {
ConvertToInstanceFormValues,
convertToInstancePayload,
FILE_CHUNK_SIZE,
sendFileByWebSocket,
supportedVMArchOptions,
} from "util/convertToInstance";
import { getInstanceName } from "util/operations";
import * as Yup from "yup";
import { instanceNameValidation } from "util/instances";

const ConvertToInstanceBtn: FC = () => {
const { openPortal, closePortal, isOpen, Portal } = usePortal();
const { project, isLoading: isProjectLoading } = useProject();
const instanceNameAbort = useState<AbortController | null>(null);
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const toastNotify = useToastNotification();
const navigate = useNavigate();
const eventQueue = useEventQueue();
const queryClient = useQueryClient();

const handleUploadProgress = (
instanceName: string,
current: number,
total: number,
) => {
setUploadState({
percentage: Math.floor((current / total) * 100),
loaded: current,
total: total,
});

if (current === total) {
closePortal();
toastNotify.info(
<>
Upload completed. Now creating instance{" "}
<strong>{instanceName}</strong>.
</>,
);
navigate(`/ui/project/${project?.name}/instances`);
}
};

const handleUploadError = (error: Error) => {
toastNotify.failure("Image upload failed.", error);
};

const handleSuccess = (instanceName: string) => {
const message = (
<>
Created instance <strong>{instanceName}</strong>.
</>
);

const actions = [
{
label: "Configure",
onClick: () =>
navigate(
`/ui/project/${project?.name}/instance/${instanceName}/configuration`,
),
},
];

toastNotify.success(message, actions);
};

const handleFailure = (msg: string) => {
toastNotify.failure("Instance creation failed.", new Error(msg));
};

const invalidateCache = () => {
void queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === queryKeys.instances;
},
});
};

const handleConfirmUpload = (values: ConvertToInstanceFormValues) => {
if (!values.imageFile) {
return;
}

// start create instance operation
createInstance(convertToInstancePayload(values), project?.name || "")
.then((operation) => {
const operationId = operation.metadata.id;
const operationSecret = operation.metadata?.metadata?.fs;
const instanceName = getInstanceName(operation.metadata);

// establish websocket connection based on the instance creation operation
let wsUrl = `wss://${location.host}/1.0/operations/${operationId}/websocket`;
if (operationSecret) {
wsUrl += `?secret=${operationSecret}`;
}

const ws = new WebSocket(wsUrl);

ws.onopen = () => {
sendFileByWebSocket(
ws,
formik.values.imageFile,
FILE_CHUNK_SIZE,
handleUploadProgress.bind(null, instanceName),
handleUploadError,
);
};

// set up event queue for the operation
eventQueue.set(
operationId,
() => handleSuccess(getInstanceName(operation.metadata)),
handleFailure,
invalidateCache,
);
})
.catch((e) => {
closePortal();
toastNotify.failure("Instance creation failed.", e);
});
};

const formik = useFormik<ConvertToInstanceFormValues>({
initialValues: {
name: "",
pool: "",
imageFile: null,
formatConversion: true,
virtioConversion: false,
architecture: supportedVMArchOptions[0].value,
},
validateOnMount: true,
validationSchema: Yup.object().shape({
name: instanceNameValidation(
project?.name || "",
instanceNameAbort,
).optional(),
}),
onSubmit: handleConfirmUpload,
});

return (
<>
<Button onClick={openPortal} type="button">
<span>Convert from image</span>
</Button>
{isOpen && (
<Portal>
<ConvertToInstanceForm
close={closePortal}
formik={formik}
isLoading={formik.isSubmitting || !!uploadState}
disableSubmit={
!formik.isValid || isProjectLoading || !formik.values.imageFile
}
project={project?.name || ""}
uploadState={uploadState}
/>
</Portal>
)}
</>
);
};

export default ConvertToInstanceBtn;
181 changes: 181 additions & 0 deletions src/pages/instances/forms/ConvertToInstanceForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { ChangeEvent, FC, useCallback } from "react";
import {
ActionButton,
Button,
Icon,
Input,
Modal,
Select,
} from "@canonical/react-components";
import { Form } from "react-router-dom";
import { FormikProps } from "formik";
import { UploadState } from "types/storage";
import ProgressBar from "components/ProgressBar";
import { getFileExtension, humanFileSize } from "util/helpers";
import StoragePoolSelector from "pages/storage/StoragePoolSelector";
import {
ConvertToInstanceFormValues,
supportedVMArchOptions,
} from "util/convertToInstance";
import { sanitizeInstanceName, truncateInstanceName } from "util/instances";

interface Props {
close: () => void;
formik: FormikProps<ConvertToInstanceFormValues>;
isLoading: boolean;
disableSubmit: boolean;
project: string;
uploadState: UploadState | null;
}

const ConvertToInstanceForm: FC<Props> = ({
close,
formik,
isLoading,
disableSubmit,
project,
uploadState,
}) => {
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
const { onChange } = formik.getFieldProps("imageFile");
onChange(e);

if (e.currentTarget.files) {
const file = e.currentTarget.files[0];
const suffix = "-instance";
const fileExtension = getFileExtension(file.name);
const sanitisedFileName = sanitizeInstanceName(
file.name.split(fileExtension)[0],
);
const instanceName = truncateInstanceName(sanitisedFileName, suffix);
await formik.setFieldValue("imageFile", file);
await formik.setFieldValue("name", instanceName);

// validate instance name
await formik.validateField("name");
void formik.setFieldTouched("name", true, true);
if (!formik.errors.name) {
formik.setFieldError("name", undefined);
}
}
};

const handleCloseModal = useCallback(() => {
formik.resetForm();
close();
}, [formik.resetForm, close]);

return (
<Modal
close={handleCloseModal}
className="convert-to-instance-modal"
title={
uploadState ? "Uploading image to LXD" : "Upload virtual machine image"
}
buttonRow={
<>
<Button
appearance="base"
className="u-no-margin--bottom"
type="button"
onClick={handleCloseModal}
>
Cancel
</Button>
<ActionButton
appearance="positive"
className="u-no-margin--bottom"
loading={isLoading}
disabled={disableSubmit}
onClick={() => void formik.submitForm()}
>
Upload and create
</ActionButton>
</>
}
>
<Form
onSubmit={formik.handleSubmit}
className={uploadState ? "u-hide" : ""}
>
<Input
id="image-file"
name="imageFile"
type="file"
label="Image file"
onChange={(e) => void handleFileChange(e)}
/>
<Input
{...formik.getFieldProps("name")}
id="name"
type="text"
label="New instance name"
placeholder="Enter name"
error={formik.touched.name ? formik.errors.name : null}
disabled={!formik.values.imageFile}
/>
<StoragePoolSelector
project={project}
value={formik.values.pool}
setValue={(value) => void formik.setFieldValue("pool", value)}
selectProps={{
id: "pool",
label: "Root storage pool",
disabled: !project || !formik.values.imageFile,
}}
/>
<Select
{...formik.getFieldProps("architecture")}
id="architecture"
label="Image architecture"
options={supportedVMArchOptions}
disabled={!formik.values.imageFile}
/>
<label htmlFor="">Conversion options</label>
<div className="conversion-options">
<Input
{...formik.getFieldProps("formatConversion")}
type="checkbox"
label={
<>
Format{" "}
<Icon
name="information"
title="Check this option to convert the image into raw format"
/>
</>
}
disabled={!formik.values.imageFile}
checked={formik.values.formatConversion}
/>
<Input
{...formik.getFieldProps("virtioConversion")}
type="checkbox"
label={
<>
Virtio{" "}
<Icon
name="information"
title="Check this option if the image requires Virtio drivers for conversion"
/>
</>
}
disabled={!formik.values.imageFile}
checked={formik.values.virtioConversion}
/>
</div>
</Form>
{uploadState && (
<>
<ProgressBar percentage={Math.floor(uploadState.percentage)} />
<p>
{humanFileSize(uploadState.loaded)} loaded of{" "}
{humanFileSize(uploadState.total ?? 0)}
</p>
</>
)}
</Modal>
);
};

export default ConvertToInstanceForm;
5 changes: 4 additions & 1 deletion src/pages/instances/forms/InstanceCreateDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import AutoExpandingTextArea from "components/AutoExpandingTextArea";
import ScrollableForm from "components/ScrollableForm";
import { useSupportedFeatures } from "context/useSupportedFeatures";
import UploadInstanceBtn from "pages/instances/actions/UploadInstanceBtn";
import ConvertToInstanceBtn from "../actions/ConvertToInstanceBtn";

export interface InstanceDetailsFormValues {
name?: string;
Expand Down Expand Up @@ -83,7 +84,8 @@ const InstanceCreateDetailsForm: FC<Props> = ({
onSelectImage,
project,
}) => {
const { hasCustomVolumeIso } = useSupportedFeatures();
const { hasCustomVolumeIso, hasInstanceImportConversion } =
useSupportedFeatures();

function figureBaseImageName() {
const image = formik.values.image;
Expand Down Expand Up @@ -145,6 +147,7 @@ const InstanceCreateDetailsForm: FC<Props> = ({
<UseCustomIsoBtn onSelect={onSelectImage} />
)}
<UploadInstanceBtn />
{hasInstanceImportConversion && <ConvertToInstanceBtn />}
</>
)}
</div>
Expand Down
Loading

0 comments on commit 1581612

Please sign in to comment.