-
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
11 changed files
with
745 additions
and
139 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,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; |
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
332 changes: 332 additions & 0 deletions
332
src/pages/instances/forms/UploadExternalFormatFileForm.tsx
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,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; |
Oops, something went wrong.