🏡 index : quine-global/quine-anaconda-webui.git

/*
 * 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) {
                    /* 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 = (
        <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 "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 (
        <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}
                />}
        </>
    );
};