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 Sep 10, 2024
1 parent 94a7510 commit 226e3e4
Show file tree
Hide file tree
Showing 11 changed files with 745 additions and 139 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",
),
};
};
23 changes: 23 additions & 0 deletions src/pages/instances/actions/UploadInstanceFileBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { FC } from "react";
import { Button } from "@canonical/react-components";
import usePortal from "react-useportal";
import UploadInstanceFileModal from "../forms/UploadInstanceFileModal";

const UploadInstanceFileBtn: FC = () => {
const { openPortal, closePortal, isOpen, Portal } = usePortal();

return (
<>
<Button onClick={openPortal} type="button">
<span>Upload instance file</span>
</Button>
{isOpen && (
<Portal>
<UploadInstanceFileModal close={closePortal} />
</Portal>
)}
</>
);
};

export default UploadInstanceFileBtn;
4 changes: 2 additions & 2 deletions src/pages/instances/forms/InstanceCreateDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import UseCustomIsoBtn from "pages/images/actions/UseCustomIsoBtn";
import AutoExpandingTextArea from "components/AutoExpandingTextArea";
import ScrollableForm from "components/ScrollableForm";
import { useSupportedFeatures } from "context/useSupportedFeatures";
import UploadInstanceBtn from "pages/instances/actions/UploadInstanceBtn";
import UploadInstanceFileBtn from "../actions/UploadInstanceFileBtn";

export interface InstanceDetailsFormValues {
name?: string;
Expand Down Expand Up @@ -143,7 +143,7 @@ const InstanceCreateDetailsForm: FC<Props> = ({
{hasCustomVolumeIso && (
<UseCustomIsoBtn onSelect={onSelectImage} />
)}
<UploadInstanceBtn />
<UploadInstanceFileBtn />
</>
)}
</div>
Expand Down
332 changes: 332 additions & 0 deletions src/pages/instances/forms/UploadExternalFormatFileForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,332 @@
import {
ActionButton,
Button,
Form,
Icon,
Input,
Select,
} from "@canonical/react-components";
import { useProject } from "context/project";
import StoragePoolSelector from "pages/storage/StoragePoolSelector";
import { ChangeEvent, FC, useCallback, useState } from "react";
import {
instanceNameValidation,
sanitizeInstanceName,
truncateInstanceName,
} from "util/instances";
import { UploadState } from "types/storage";
import { useEventQueue } from "context/eventQueue";
import { useToastNotification } from "context/toastNotificationProvider";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { queryKeys } from "util/queryKeys";
import { createInstance } from "api/instances";
import { useFormik } from "formik";
import * as Yup from "yup";
import { useSettings } from "context/useSettings";
import InstanceLink from "../InstanceLink";
import {
UploadExternalFormatFileFormValues,
uploadExternalFormatFilePayload,
FILE_CHUNK_SIZE,
isImageTypeRaw,
sendFileByWebSocket,
supportedVMArchOptions,
} from "util/uploadExternalFormatFile";
import { getInstanceName } from "util/operations";
import { getFileExtension } from "util/helpers";
import ScrollableContainer from "components/ScrollableContainer";
import classnames from "classnames";

interface Props {
close: () => void;
uploadState: UploadState | null;
setUploadState: (state: UploadState | null) => void;
fixedFormHeight: boolean;
}

const UploadExternalFormatFileForm: FC<Props> = ({
close,
uploadState,
setUploadState,
fixedFormHeight,
}) => {
const { project, isLoading } = useProject();
const instanceNameAbort = useState<AbortController | null>(null);
const toastNotify = useToastNotification();
const navigate = useNavigate();
const eventQueue = useEventQueue();
const queryClient = useQueryClient();
const { data: settings } = useSettings();
const [isRawImage, setIsRawImage] = useState(false);

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

if (current === total) {
close();
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{" "}
<InstanceLink
instance={{ project: project?.name || "", name: instanceName }}
/>
.
</>
);

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: UploadExternalFormatFileFormValues) => {
if (!values.imageFile) {
return;
}

// start create instance operation
createInstance(uploadExternalFormatFilePayload(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) => {
close();
toastNotify.failure("Instance creation failed.", e);
});
};

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

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 = "-import";
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);
}

// If the image is already in raw format, remove the format conversion option
// this will optimise the conversion process since raw vm images do not need to be converted
const isRawImage = await isImageTypeRaw(file);
setIsRawImage(isRawImage);
if (isRawImage) {
await formik.setFieldValue("formatConversion", false);
}
}
};

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

const archOptions = supportedVMArchOptions(
settings?.environment?.architectures || [],
);

return (
<>
<ScrollableContainer
className={classnames({
"fixed-height": fixedFormHeight,
"u-hide": uploadState,
})}
dependencies={[]}
belowIds={["modal-footer"]}
>
<Form onSubmit={formik.handleSubmit}>
<Input
id="image-file"
name="imageFile"
type="file"
label="VM 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?.name || ""}
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={archOptions}
disabled={!formik.values.imageFile || archOptions.length < 2}
/>
<label htmlFor="">Conversion options</label>
<Input
{...formik.getFieldProps("formatConversion")}
type="checkbox"
label={
<>
Convert to raw format{" "}
<Icon
name="information"
title="Can be skipped if the image is already in raw format to speed up the import."
/>
</>
}
disabled={!formik.values.imageFile || isRawImage}
checked={formik.values.formatConversion}
/>
<Input
{...formik.getFieldProps("virtioConversion")}
type="checkbox"
label={
<>
Add Virtio drivers{" "}
<Icon
name="information"
title="Mandatory, if the image does not have Virtio drivers installed."
/>
</>
}
disabled={!formik.values.imageFile}
checked={formik.values.virtioConversion}
/>
</Form>
</ScrollableContainer>
<footer className="p-modal__footer" id="modal-footer">
<Button
appearance="base"
className="u-no-margin--bottom"
type="button"
onClick={handleCloseModal}
>
Cancel
</Button>
<ActionButton
appearance="positive"
className="u-no-margin--bottom"
loading={formik.isSubmitting || !!uploadState}
disabled={!formik.isValid || isLoading || !formik.values.imageFile}
onClick={() => void formik.submitForm()}
>
Upload and create
</ActionButton>
</footer>
</>
);
};

export default UploadExternalFormatFileForm;
Loading

0 comments on commit 226e3e4

Please sign in to comment.