diff --git a/app/forms/image-upload.tsx b/app/forms/image-upload.tsx index 087e852ec3..b3be957b9c 100644 --- a/app/forms/image-upload.tsx +++ b/app/forms/image-upload.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { skipToken, useQuery } from '@tanstack/react-query' import cn from 'classnames' import { filesize } from 'filesize' import pMap from 'p-map' @@ -22,6 +23,7 @@ import { } from '@oxide/api' import { Error12Icon, + OpenLink12Icon, Success12Icon, Unauthorized12Icon, } from '@oxide/design-system/icons/react' @@ -40,6 +42,7 @@ import { Spinner } from '~/ui/lib/Spinner' import { anySignal } from '~/util/abort' import { readBlobAsBase64 } from '~/util/file' import { invariant } from '~/util/invariant' +import { links } from '~/util/links' import { pb } from '~/util/path-builder' import { GiB, KiB } from '~/util/units' @@ -477,6 +480,12 @@ export function CreateImageSideModalForm() { const form = useForm({ defaultValues }) const file = form.watch('imageFile') + const blockSize = form.watch('blockSize') + + const { data: imageValidation } = useQuery({ + queryKey: ['validateImage', ...(file ? [file.name, file.size, file.lastModified] : [])], + queryFn: file ? () => validateImage(file) : skipToken, + }) return ( - parseInt(val, 10) as BlockSize} - items={[ - { label: '512', value: 512 }, - { label: '2048', value: 2048 }, - { label: '4096', value: 4096 }, - ]} - /> - +
+ parseInt(val, 10) as BlockSize} + items={[ + { label: '512', value: 512 }, + { label: '2048', value: 2048 }, + { label: '4096', value: 4096 }, + ]} + /> + {imageValidation && } +
+
+ + {imageValidation && } +
{file && modalOpen && ( @@ -640,3 +655,139 @@ export function CreateImageSideModalForm() {
) } + +function BlockSizeNotice({ + blockSize, + efiPartOffset, + isBootableCd, +}: { + blockSize: number + efiPartOffset: number + isBootableCd: boolean +}) { + const isEfi = efiPartOffset !== -1 + + // If the image doesn't look bootable, return null (`BootableNotice` does the work). + if (!isEfi && !isBootableCd) return null + // If we detect `EFI BOOT` and the block size is set correctly return null. + // (This includes hybrid GPT+ISO.) + if (isEfi && blockSize === efiPartOffset) return null + // If we detect only `CD001` and the block size is set correctly return null. + if (!isEfi && isBootableCd && blockSize === 2048) return null + + // Block size is set incorrectly. If we detect `EFI BOOT`, always show that warning. + const content = isEfi + ? `Detected “EFI PART” marker at offset ${efiPartOffset}, but block size is set to ${blockSize}.` + : 'Bootable CDs typically use a block size of 2048.' + + return ( + + ) +} + +function BootableNotice({ + efiPartOffset, + isBootableCd, + isCompressed, +}: { + efiPartOffset: number + isBootableCd: boolean + isCompressed: boolean +}) { + // this message should only appear if the image doesn't have a header + // marker we are looking for and does not appear to be compressed + const efiPartOrBootable = efiPartOffset !== -1 || isBootableCd + if (efiPartOrBootable && !isCompressed) return null + + const content = ( +
+ +
+ Learn more about{' '} + + preparing images for import + + +
+
+ ) + + return ( + + ) +} + +async function readAtOffset(file: File, offset: number, length: number) { + const reader = new FileReader() + + const promise = new Promise((resolve, reject) => { + reader.onloadend = (e) => { + if ( + e.target?.readyState === FileReader.DONE && + // should always be true because we're using readAsArrayBuffer + e.target.result instanceof ArrayBuffer + ) { + resolve(String.fromCharCode(...new Uint8Array(e.target.result))) + return + } + resolve(undefined) + } + + reader.onerror = (error) => { + console.error(`Error reading file at offset ${offset}:`, error) + reject(error) + } + }) + + reader.readAsArrayBuffer(file.slice(offset, offset + length)) + return promise +} + +async function getEfiPartOffset(file: File) { + const offsets = [512, 2048, 4096] + for (const offset of offsets) { + const isMatch = (await readAtOffset(file, offset, 8)) === 'EFI PART' + if (isMatch) return offset + } + return -1 +} + +const compressedExts = ['.gz', '.7z', '.qcow2', '.vmdk'] + +const validateImage = async (file: File) => { + const lowerFileName = file.name.toLowerCase() + return { + efiPartOffset: await getEfiPartOffset(file), + isBootableCd: (await readAtOffset(file, 0x8001, 5)) === 'CD001', + isCompressed: compressedExts.some((ext) => lowerFileName.endsWith(ext)), + } +} diff --git a/app/util/links.ts b/app/util/links.ts index e1af292667..9400242fa7 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -16,6 +16,8 @@ export const links: Record = { 'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules', floatingIpsDocs: 'https://docs.oxide.computer/guides/managing-floating-ips', imagesDocs: 'https://docs.oxide.computer/guides/creating-and-sharing-images', + preparingImagesDocs: + 'https://docs.oxide.computer/guides/creating-and-sharing-images#_preparing_images_for_import', instancesDocs: 'https://docs.oxide.computer/guides/managing-instances', keyConceptsIamPolicyDocs: 'https://docs.oxide.computer/guides/key-entities-and-concepts#iam-policy',