-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support vmdk import for vm instance creation
Signed-off-by: Mason Hu <[email protected]>
- Loading branch information
Showing
8 changed files
with
502 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.