/* * Copyright (C) 2025 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import cockpit from "cockpit"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import { ActionList } from "@patternfly/react-core/dist/esm/components/ActionList/index.js"; import { Alert } from "@patternfly/react-core/dist/esm/components/Alert/index.js"; import { Button } from "@patternfly/react-core/dist/esm/components/Button/index.js"; import { HelperText, HelperTextItem } from "@patternfly/react-core/dist/esm/components/HelperText/index.js"; import { List, ListItem } from "@patternfly/react-core/dist/esm/components/List/index.js"; import { Modal, ModalBody, ModalFooter, ModalHeader } from "@patternfly/react-core/dist/esm/components/Modal/index.js"; import { Stack } from "@patternfly/react-core/dist/esm/layouts/Stack/index.js"; import { runStorageTask, scanDevicesWithTask, } from "../../../apis/storage.js"; import { unlockDevice, } from "../../../apis/storage_devicetree.js"; import { setInitializationMode, } from "../../../apis/storage_disk_initialization.js"; import { getSelectedDisks, setSelectedDisks } from "../../../apis/storage_disks_selection.js"; import { applyStorage, createPartitioning, gatherRequests, resetPartitioning, setManualPartitioningRequests } from "../../../apis/storage_partitioning.js"; import { getDevicesAction, setStorageScenarioAction } from "../../../actions/storage-actions.js"; import { debug as loggerDebug } from "../../../helpers/log.js"; import { bootloaderTypes, deviceHasEncryptionKey, getDeviceAncestors, getDeviceByName, getDeviceByPath, getDeviceChildren, getUsableDevicesManualPartitioning, isDeviceEncrypted, } from "../../../helpers/storage.js"; import { checkIfArraysAreEqual } from "../../../helpers/utils.js"; import { StorageContext } from "../../../contexts/Common.jsx"; import { useOriginalDevices, } from "../../../hooks/Storage.jsx"; import { EmptyStatePanel } from "cockpit-components-empty-state"; import { useAvailabilityConfiguredStorage } from "../scenarios/use-configured-storage/UseConfiguredStorage.jsx"; import { useAvailabilityUseFreeSpace } from "../scenarios/use-free-space/UseFreeSpace.jsx"; import { StorageReview } from "../StorageReview.jsx"; const _ = cockpit.gettext; const idPrefix = "cockpit-storage-integration"; const debug = loggerDebug.bind(null, idPrefix + ":"); const CheckStorageDialogErrorAlert = ({ messages }) => ( {messages.map((msg, i) => ( {msg} ))} ); const CheckStorageDialogWarningAlert = ({ messages }) => ( {messages.map((msg, i) => ( {msg} ))} ); const preparePartitioning = async ({ devices, newMountPoints, onFail }) => { try { const selectedDisks = await getSelectedDisks(); const partitioning = await createPartitioning({ method: "MANUAL" }); const requests = await gatherRequests({ partitioning }); const usableDevices = getUsableDevicesManualPartitioning({ devices, selectedDisks }); const addRequest = (device, object, isSubVolume = false, parent = undefined) => { const { content, dir, subvolumes, type } = object; let deviceSpec; if (!isSubVolume) { deviceSpec = getDeviceByPath(devices, device); } else { if (device === "/" && parent) { /* It's possible that the user mounts the top-level volume * Example newMountPoints object from Cockpit Storage: * { * ... * "/dev/vda3": { * "type": "filesystem", * "subvolumes": { * "/": { * "dir": "/" * } * } * }, * ... * } */ deviceSpec = getDeviceChildren({ device: parent, deviceData: devices })[0]; } else { deviceSpec = getDeviceByName(devices, device); } } if (!deviceSpec) { return; } if (usableDevices.includes(deviceSpec) && (dir || type === "swap")) { const existingRequestIndex = ( requests.findIndex(request => request["device-spec"].v === deviceSpec) ); if (existingRequestIndex !== -1) { requests[existingRequestIndex] = { ...requests[existingRequestIndex], "device-spec": cockpit.variant("s", deviceSpec), "mount-point": cockpit.variant("s", dir || type), }; } else { requests.push({ "device-spec": cockpit.variant("s", deviceSpec), "mount-point": cockpit.variant("s", dir || type), }); } } else if (subvolumes) { Object.keys(subvolumes).forEach(subvolume => addRequest(subvolume, subvolumes[subvolume], true, deviceSpec)); } else if (type === "crypto") { const clearTextDevice = devices[deviceSpec].children.v[0]; const clearTextDevicePath = devices[clearTextDevice].path.v; addRequest(clearTextDevicePath, content); } }; Object.keys(newMountPoints).forEach(usedDevice => { addRequest(usedDevice, newMountPoints[usedDevice]); }); await setManualPartitioningRequests({ partitioning, requests }); return partitioning; } catch (error) { onFail(error); } }; const handleMDRAID = ({ devices, onFail, refDevices, setNextCheckStep }) => { debug("mdarray step started"); const mdArrays = Object.keys(devices).filter(device => devices[device].type.v === "mdarray"); let ret; // In blivet we recognize two "types" of MD array: // * The array is directly on top of disks: in this case we consider the array to be a disk // (similar to a hardware RAID) and create the partition table on the array // * The array is on top of partitions: from our pov this is a device and we allow only a single // filesystem (or other format like lvmpv) on top of it and if it has partitions they are ignored // // For the first scenario, we need to re-set 'SelectedDisks' in backend, // for the new mdarrays to be handled as such. const setNewSelectedDisks = async () => { const selectedDisks = await getSelectedDisks(); const newSelectedDisks = selectedDisks .reduce((acc, disk) => { if (!devices[disk]) { if (refDevices.current[disk]?.parents.v) { debug("re-scan finished: Device got removed, adding parent disks to selected disks", disk); return [...acc, ...refDevices.current[disk].parents.v]; } else { debug("re-scan finished: Device got removed, removing from selected disks", disk); return acc; } } return [...acc, disk]; }, []) .reduce((acc, disk) => { const mdArray = devices[disk].children.v.filter(child => mdArrays.includes(child)); if (mdArray.length > 0) { debug("re-scan finished: MD array found, replacing disk with mdarray", disk, mdArray); return [...acc, mdArray[0]]; } else { debug("re-scan finished: Keeping disk", disk); return [...acc, disk]; } }, []) .filter((disk, index, disks) => disks.indexOf(disk) === index); if (!checkIfArraysAreEqual(selectedDisks, newSelectedDisks)) { setSelectedDisks({ drives: newSelectedDisks }); ret = newSelectedDisks; } setNextCheckStep(); return ret; }; // Check if we have mdarrays that are not fitting in the above two scenarios // and show an error message const mdArraysNotSupported = mdArrays.filter(device => { // The user created a plain mdarray without any format to be used possible with 'Use entire disk' if (devices[device].formatData.type.v === "") { return false; } if ( devices[device].parents.v.every(parent => devices[parent].type.v === "disk") && devices[device].formatData.type.v === "disklabel" ) { return false; } if ( devices[device].parents.v.every(parent => devices[parent].type.v === "partition") && devices[device].formatData.type.v !== "disklabel" ) { return false; } return true; }); // TODO: Consider moving this logic to the backend if (mdArraysNotSupported.length > 0) { onFail({ message: cockpit.format( _("Invalid RAID configuration detected. If your RAID array is created directly on top of disks, a partition table must be created on the array. If your RAID array is created on top of partitions, it must contain a single filesystem or format (e.g., LVM PV). Any existing partitions on this array will be ignored.") ) }); return; } for (const device of Object.keys(devices)) { // FIXME: Do not allow stage1 device to be mdarray when this was created in Cockpit Storage // Cockpit Storage creates MDRAID with metadata 1.2, which is not supported by bootloaders // See more: https://bugzilla.redhat.com/show_bug.cgi?id=2355346 const bootloaderDevice = bootloaderTypes.includes(devices[device].formatData.type.v); // PMBR does not have a bootloader necessarily const bootloaderDriveMDRAID = bootloaderDevice && getDeviceAncestors(devices, device).find(device => mdArrays.includes(device)); if (bootloaderDriveMDRAID) { onFail({ message: cockpit.format( _("'$0' partition on MDRAID device $1 found. Bootloader partitions on MDRAID devices are not supported."), devices[device].formatData.type.v, devices[bootloaderDriveMDRAID].name.v ) }); return; } } return setNewSelectedDisks(); }; const getDevicesToUnlock = ({ cockpitPassphrases, devices }) => { const devicesToUnlock = ( Object.keys(cockpitPassphrases) .map(dev => { let device = getDeviceByName(devices, dev); if (!device) { device = getDeviceByPath(devices, dev); } // Cockpit stores passphrases keyed by backing block device name, // but for encrypted Stratis pools the encrypted entity is the pool // (a child of the block device), not the block device itself. if (device && !isDeviceEncrypted(devices[device])) { const encryptedChild = getDeviceChildren({ device, deviceData: devices }) .find(child => isDeviceEncrypted(devices[child]) && !deviceHasEncryptionKey(devices[child])); if (encryptedChild) { device = encryptedChild; } } return ({ device, passphrase: cockpitPassphrases[dev] }); })) .filter(({ device }) => { if (!device) { return false; } return ( isDeviceEncrypted(devices[device]) && !deviceHasEncryptionKey(devices[device]) ); }); return devicesToUnlock; }; const unlockDevices = ({ devices, dispatch, onCritFail, onFail, setNextCheckStep }) => { const cockpitPassphrases = JSON.parse(window.sessionStorage.getItem("cockpit_passphrases") || "{}"); const devicesToUnlock = getDevicesToUnlock({ cockpitPassphrases, devices }); if (devicesToUnlock.some(dev => !dev.passphrase)) { onCritFail()({ message: _("Cockpit storage did not provide the passphrase to unlock encrypted device.") }); } debug("luks step started"); Promise.all(devicesToUnlock.map(unlockDevice)) .catch(onFail) .then(() => { setNextCheckStep(); dispatch(getDevicesAction()); }); }; const waitForUnlockedDevices = ({ devices, setNextCheckStep }) => { const cockpitPassphrases = JSON.parse(window.sessionStorage.getItem("cockpit_passphrases") || "{}"); const devicesToUnlock = getDevicesToUnlock({ cockpitPassphrases, devices }); if (devicesToUnlock.length === 0) { setNextCheckStep(); } }; const waitForNewSelectedDisks = ({ newSelectedDisks, selectedDisks, setNextCheckStep }) => { if (!newSelectedDisks || checkIfArraysAreEqual(newSelectedDisks, selectedDisks)) { setNextCheckStep(); } }; const scanDevices = ({ dispatch, onFail, setNextCheckStep }) => { debug("rescan step started"); // When the dialog is shown rescan to get latest configured storage // and check if we need to prepare manual partitioning scanDevicesWithTask() .then(task => { return runStorageTask({ onFail, onSuccess: () => resetPartitioning() .then(() => dispatch(getDevicesAction())) .then(setNextCheckStep) .catch(onFail), task }); }); }; const useStorageSetup = ({ dispatch, onCritFail, setNotification }) => { const [checkStep, setCheckStep] = useState("rescan"); const refCheckStep = useRef(); const devices = useOriginalDevices(); const refDevices = useRef(devices); const { isFetching } = useContext(StorageContext); const { diskSelection } = useContext(StorageContext); const selectedDisks = diskSelection.selectedDisks; const [newSelectedDisks, setNewSelectedDisks] = useState(); useEffect(() => { if (refDevices.current !== undefined) { return; } refDevices.current = devices; }, [devices]); useEffect(() => { if (isFetching) { return; } // Avoid re-running a step if it's already running if (checkStep && refCheckStep.current === checkStep && !checkStep.startsWith("waitingFor")) { return; } refCheckStep.current = checkStep; debug("useStorageSetup: running step", checkStep); const onFail = exc => { setCheckStep(); setNotification({ errorMessages: [exc.message], }); }; const runStep = async () => { switch (checkStep) { case "rescan": await scanDevices({ dispatch, onFail, setNextCheckStep: () => setCheckStep("luks"), }); break; case "luks": await unlockDevices({ devices, dispatch, onCritFail, onFail, setNextCheckStep: () => setCheckStep("waitingForLuks"), }); break; case "waitingForLuks": await waitForUnlockedDevices({ devices, setNextCheckStep: () => setCheckStep("mdarray"), }); break; case "mdarray": { const _newSelectedDisks = await handleMDRAID({ devices, onFail, refDevices, setNextCheckStep: () => setCheckStep("waitingForNewSelectedDisks"), }); setNewSelectedDisks(_newSelectedDisks); break; } case "waitingForNewSelectedDisks": await waitForNewSelectedDisks({ newSelectedDisks, selectedDisks, setNextCheckStep: () => setCheckStep(), }); break; } }; runStep(); }, [ checkStep, devices, dispatch, isFetching, newSelectedDisks, onCritFail, selectedDisks, setCheckStep, setNotification, ]); return checkStep !== undefined; }; const CheckStorageDialogLoading = () => { const loadingDescription = ( ); return ( {}} position="top" variant="small" isOpen > {loadingDescription} ); }; const CheckStorageDialogLoadingNewStorage = ({ dispatch, onCritFail, setLoadingNewStorage, setNotification }) => { const loadingNewStorage = useStorageSetup({ dispatch, onCritFail, setNotification, }); useEffect(() => { setLoadingNewStorage(loadingNewStorage); }, [loadingNewStorage, setLoadingNewStorage]); return ; }; const CheckStorageDialogLoadingNewPartitioning = ({ dispatch, newMountPoints, setNeedsNewPartitioning, setNotification, }) => { const devices = useOriginalDevices(); const useConfiguredStorage = useAvailabilityConfiguredStorage({ newMountPoints })?.available; const useFreeSpace = useAvailabilityUseFreeSpace({ allowReclaim: false })?.available; const mounted = useRef(false); useEffect(() => { if (mounted.current || useConfiguredStorage === undefined || useFreeSpace === undefined) { return; } mounted.current = true; // If "Use configured storage" is not available and no mount points were // specified, skip Manual partitioning creation if (!useConfiguredStorage && Object.keys(newMountPoints).length === 0) { if (useFreeSpace) { dispatch(setStorageScenarioAction("use-free-space")); } else { dispatch(setStorageScenarioAction("")); } setNotification(null); setNeedsNewPartitioning(false); return; } else if (useConfiguredStorage) { dispatch(setStorageScenarioAction("use-configured-storage")); } else { dispatch(setStorageScenarioAction("")); } const onFail = (exc) => { setNotification({ errorMessages: [exc.message], }); setNeedsNewPartitioning(false); }; debug("prepare partitioning step started"); const applyNewPartitioning = async () => { // CLEAR_PARTITIONS_NONE = 0 try { await setInitializationMode({ mode: 0 }); const partitioning = await preparePartitioning({ devices, newMountPoints, onFail }); if (!partitioning) { return; } const { errors, warnings } = await applyStorage({ devices, partitioning }); if (errors.length > 0) { setNotification({ errorMessages: errors, ...(warnings.length > 0 && { warningMessages: warnings }), }); } else if (warnings.length > 0) { setNotification({ warningMessages: warnings, }); } else { setNotification(null); } setNeedsNewPartitioning(false); } catch (exc) { onFail(exc); } }; applyNewPartitioning(); }, [devices, dispatch, newMountPoints, setNeedsNewPartitioning, setNotification, useConfiguredStorage, useFreeSpace]); return ( ); }; const CheckStorageDialogLoaded = ({ newMountPoints, notification, setShowDialog, setShowStorage, }) => { const { diskSelection } = useContext(StorageContext); const devices = useOriginalDevices(); const selectedDisks = diskSelection.selectedDisks; const useConfiguredStorage = useAvailabilityConfiguredStorage({ newMountPoints })?.available; const useFreeSpace = useAvailabilityUseFreeSpace({ allowReclaim: false })?.available; const mdArrays = useMemo(() => { return Object.keys(devices).filter(device => devices[device].type.v === "mdarray"); }, [devices]); const useEntireSoftwareDisk = useMemo(() => { return selectedDisks.every(disk => ( mdArrays.includes(disk) && getDeviceChildren({ device: disk, deviceData: devices }).every(child => devices[child].type.v !== "partition") )); }, [devices, mdArrays, selectedDisks]); const layoutBlocked = !useConfiguredStorage && !useFreeSpace && !useEntireSoftwareDisk; const storageRequirementsNotMet = layoutBlocked || (notification?.errorMessages?.length > 0); const goBackToInstallation = () => { setShowStorage(false); }; let title; if (storageRequirementsNotMet) { title = _("Storage requirements not met"); } else { title = _("Continue with installation"); } return ( setShowDialog(false)} position="top" variant="small" isOpen > {notification?.errorMessages?.length > 0 && } {notification?.warningMessages?.length > 0 && } {!storageRequirementsNotMet && {useConfiguredStorage ? ( {_("Detected valid storage layout:")} ) : ( useEntireSoftwareDisk ? _("Use the RAID device for automatic partitioning") : _("Use free space") )} } {!storageRequirementsNotMet && <> } {storageRequirementsNotMet && <> } ); }; export const CheckStorageDialog = ({ dispatch, onCritFail, setShowDialog, setShowStorage }) => { const [notification, setNotification] = useState(null); const [loadingNewStorage, setLoadingNewStorage] = useState(true); const [needsNewPartitioning, setNeedsNewPartitioning] = useState(true); const loadingCommonProps = { dispatch, onCritFail, setNotification, }; const newMountPoints = useMemo(() => JSON.parse(window.sessionStorage.getItem("cockpit_mount_points") || "{}"), []); return ( <> {!notification && loadingNewStorage && } {!notification && !loadingNewStorage && needsNewPartitioning && } {(notification || (!loadingNewStorage && !needsNewPartitioning)) && } ); };