/*
* 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)) &&
}
>
);
};