* 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 }) => (
<Alert
isInline
title={cockpit.format(
cockpit.ngettext("$0 error", "$0 errors", messages.length),
messages.length
)}
variant="danger">
<List>
{messages.map((msg, i) => (
<ListItem key={"err-" + i}>{msg}</ListItem>
))}
</List>
</Alert>
);
const CheckStorageDialogWarningAlert = ({ messages }) => (
<Alert
isInline
title={cockpit.format(
cockpit.ngettext("$0 warning", "$0 warnings", messages.length),
messages.length
)}
variant="warning">
<List>
{messages.map((msg, i) => (
<ListItem key={"warn-" + i}>{msg}</ListItem>
))}
</List>
</Alert>
);
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) {
* 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;
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;
};
const mdArraysNotSupported = mdArrays.filter(device => {
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;
});
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)) {
const bootloaderDevice = bootloaderTypes.includes(devices[device].formatData.type.v);
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);
}
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");
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;
}
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 = (
<EmptyStatePanel
loading
title={_("Checking storage configuration")}
paragraph={_("This will take a few moments")} />
);
return (
<Modal
aria-label={_("Checking storage configuration")}
className={idPrefix + "-check-storage-dialog--loading"}
id={idPrefix + "-check-storage-dialog"}
onClose={() => {}}
position="top" variant="small" isOpen
>
<ModalBody>
{loadingDescription}
</ModalBody>
</Modal>
);
};
const CheckStorageDialogLoadingNewStorage = ({ dispatch, onCritFail, setLoadingNewStorage, setNotification }) => {
const loadingNewStorage = useStorageSetup({
dispatch,
onCritFail,
setNotification,
});
useEffect(() => {
setLoadingNewStorage(loadingNewStorage);
}, [loadingNewStorage, setLoadingNewStorage]);
return <CheckStorageDialogLoading />;
};
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 (!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 () => {
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 (
<CheckStorageDialogLoading />
);
};
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 (
<Modal
className={idPrefix + "-check-storage-dialog"}
id={idPrefix + "-check-storage-dialog"}
onClose={() => setShowDialog(false)}
position="top" variant="small" isOpen
>
<ModalHeader
title={title}
titleIconVariant={storageRequirementsNotMet && "warning"}
/>
<ModalBody>
<Stack hasGutter>
{notification?.errorMessages?.length > 0 &&
<CheckStorageDialogErrorAlert messages={notification.errorMessages} />}
{notification?.warningMessages?.length > 0 &&
<CheckStorageDialogWarningAlert messages={notification.warningMessages} />}
<HelperText>
{!storageRequirementsNotMet &&
<HelperTextItem variant="success">
{useConfiguredStorage
? (
<Stack hasGutter>
<span>{_("Detected valid storage layout:")}</span>
<StorageReview />
</Stack>
)
: (
useEntireSoftwareDisk
? _("Use the RAID device for automatic partitioning")
: _("Use free space")
)}
</HelperTextItem>}
</HelperText>
</Stack>
</ModalBody>
<ModalFooter>
<ActionList>
{!storageRequirementsNotMet &&
<>
<Button
id={idPrefix + "-check-storage-dialog-continue"}
variant="primary"
onClick={goBackToInstallation}>
{_("Continue")}
</Button>
<Button
id={idPrefix + "-check-storage-dialog-return"}
variant="link"
onClick={() => setShowDialog(false)}>
{_("Return to storage editor")}
</Button>
</>}
{storageRequirementsNotMet &&
<>
<Button
variant="warning"
id={idPrefix + "-check-storage-dialog-return"}
onClick={() => setShowDialog(false)}>
{_("Configure storage again")}
</Button>
<Button
id={idPrefix + "-check-storage-dialog-continue"}
variant="secondary"
onClick={() => setShowStorage(false)}>
{_("Proceed with installation")}
</Button>
</>}
</ActionList>
</ModalFooter>
</Modal>
);
};
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 &&
<CheckStorageDialogLoadingNewStorage
setLoadingNewStorage={setLoadingNewStorage} {...loadingCommonProps}
/>}
{!notification && !loadingNewStorage && needsNewPartitioning &&
<CheckStorageDialogLoadingNewPartitioning
newMountPoints={newMountPoints}
setNeedsNewPartitioning={setNeedsNewPartitioning}
{...loadingCommonProps}
/>}
{(notification || (!loadingNewStorage && !needsNewPartitioning)) &&
<CheckStorageDialogLoaded
newMountPoints={newMountPoints}
notification={notification}
setShowDialog={setShowDialog}
setShowStorage={setShowStorage}
/>}
</>
);
};