diff --git a/packaging/anaconda-webui.spec.in b/packaging/anaconda-webui.spec.in index 1c80df3cbc..0eb43295cf 100644 --- a/packaging/anaconda-webui.spec.in +++ b/packaging/anaconda-webui.spec.in @@ -13,7 +13,7 @@ BuildRequires: gettext %global anacondacorever 40.20 %global cockpitver 275 -%global cockpitstorver 310 +%global cockpitstorver 311 Requires: cockpit-storaged >= %{cockpitstorver} Requires: cockpit-bridge >= %{cockpitver} diff --git a/src/components/AnacondaWizard.jsx b/src/components/AnacondaWizard.jsx index 136e409915..bd2891a873 100644 --- a/src/components/AnacondaWizard.jsx +++ b/src/components/AnacondaWizard.jsx @@ -65,6 +65,13 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim const osRelease = useContext(OsReleaseContext); const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO"; const selectedDisks = storageData.diskSelection.selectedDisks; + const [scenarioPartitioningMapping, setScenarioPartitioningMapping] = useState({}); + + useEffect(() => { + if (storageScenarioId && storageData.partitioning.path) { + setScenarioPartitioningMapping({ [storageScenarioId]: storageData.partitioning.path }); + } + }, [storageData.partitioning.path, storageScenarioId]); const availableDevices = useMemo(() => { return Object.keys(storageData.devices); @@ -108,6 +115,8 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim deviceNames: storageData.deviceNames, diskSelection: storageData.diskSelection, dispatch, + partitioning: storageData.partitioning.path, + scenarioPartitioningMapping, storageScenarioId, setStorageScenarioId: (scenarioId) => { window.sessionStorage.setItem("storage-scenario-id", scenarioId); @@ -275,6 +284,7 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim deviceData={storageData.devices} dispatch={dispatch} onCritFail={onCritFail} + scenarioPartitioningMapping={scenarioPartitioningMapping} selectedDisks={selectedDisks} setShowStorage={setShowStorage} setStorageScenarioId={setStorageScenarioId} diff --git a/src/components/review/ReviewConfiguration.jsx b/src/components/review/ReviewConfiguration.jsx index a7f51b37c0..4f58a65cd1 100644 --- a/src/components/review/ReviewConfiguration.jsx +++ b/src/components/review/ReviewConfiguration.jsx @@ -177,7 +177,14 @@ export const ReviewConfiguration = ({ deviceData, diskSelection, language, local {diskSelection.selectedDisks.map(disk => { - return ; + return ( + + ); })} diff --git a/src/components/storage/CockpitStorageIntegration.jsx b/src/components/storage/CockpitStorageIntegration.jsx index 4175afed42..771678358f 100644 --- a/src/components/storage/CockpitStorageIntegration.jsx +++ b/src/components/storage/CockpitStorageIntegration.jsx @@ -16,17 +16,21 @@ * along with This program; If not, see . */ import cockpit from "cockpit"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { + ActionList, Alert, Button, Card, CardBody, Flex, FlexItem, + HelperText, + HelperTextItem, List, ListItem, + Modal, PageSection, PageSectionVariants, Text, @@ -35,61 +39,68 @@ import { } from "@patternfly/react-core"; import { ArrowLeftIcon } from "@patternfly/react-icons"; -import { useRequiredSize, useMountPointConstraints } from "./Common.jsx"; +import { EmptyStatePanel } from "cockpit-components-empty-state"; +import { checkConfiguredStorage, checkUseFreeSpace } from "./InstallationScenario.jsx"; +import { useDiskTotalSpace, useDiskFreeSpace, useRequiredSize, useMountPointConstraints } from "./Common.jsx"; import { runStorageTask, scanDevicesWithTask, } from "../../apis/storage.js"; +import { + unlockDevice, +} from "../../apis/storage_devicetree.js"; +import { + setBootloaderDrive, +} from "../../apis/storage_bootloader.js"; +import { + setInitializationMode, +} from "../../apis/storage_disk_initialization.js"; +import { + applyStorage, + createPartitioning, + gatherRequests, + resetPartitioning, + setManualPartitioningRequests +} from "../../apis/storage_partitioning.js"; + import { getDevicesAction } from "../../actions/storage-actions.js"; +import { getDeviceNameByPath } from "../../helpers/storage.js"; import "./CockpitStorageIntegration.scss"; const _ = cockpit.gettext; const idPrefix = "cockpit-storage-integration"; -const ReturnToInstallationButton = ({ dispatch, setShowStorage, onCritFail }) => { - const [isInProgress, setIsInProgress] = useState(false); - - const rescanStorage = () => { - setIsInProgress(true); - - return scanDevicesWithTask() - .then(task => { - return runStorageTask({ - task, - onSuccess: () => dispatch(getDevicesAction()) - .then(() => { - setIsInProgress(false); - setShowStorage(false); - }), - onFail: exc => { - setIsInProgress(false); - onCritFail()(exc); - } - }); - }); - }; - - return ( - - ); -}; +const ReturnToInstallationButton = ({ isDisabled, onAction }) => ( + +); export const CockpitStorageIntegration = ({ + scenarioAvailability, + scenarioPartitioningMapping, + selectedDisks, + setStorageScenarioId, + deviceData, dispatch, onCritFail, setShowStorage, }) => { + const [showDialog, setShowDialog] = useState(false); + const [needsResetPartitioning, setNeedsResetPartitioning] = useState(true); + + useEffect(() => { + resetPartitioning().then(() => setNeedsResetPartitioning(false), onCritFail); + }, [onCritFail]); + return ( <> + isDisabled={needsResetPartitioning} + onAction={() => setShowDialog(true)} /> + {showDialog && + } ); }; +export const preparePartitioning = async ({ deviceData, newMountPoints }) => { + try { + await setBootloaderDrive({ drive: "" }); + + const partitioning = await createPartitioning({ method: "MANUAL" }); + const requests = await gatherRequests({ partitioning }); + + const addRequest = (devicePath, object, isSubVolume = false) => { + const { dir, type, subvolumes, content } = object; + let deviceSpec; + if (!isSubVolume) { + deviceSpec = getDeviceNameByPath(deviceData, devicePath); + } else if (deviceData[devicePath]) { + deviceSpec = devicePath; + } else { + return; + } + + if (deviceSpec && (dir || type === "swap")) { + const existingRequestIndex = ( + requests.findIndex(request => request["device-spec"].v === deviceSpec) + ); + + if (existingRequestIndex !== -1) { + requests[existingRequestIndex] = { + "mount-point": cockpit.variant("s", dir || type), + "device-spec": cockpit.variant("s", deviceSpec), + }; + } else { + requests.push({ + "mount-point": cockpit.variant("s", dir || type), + "device-spec": cockpit.variant("s", deviceSpec), + }); + } + } else if (subvolumes) { + Object.keys(subvolumes).forEach(subvolume => addRequest(subvolume, subvolumes[subvolume], true)); + } else if (type === "crypto") { + const clearTextDevice = deviceData[deviceSpec].children.v[0]; + const clearTextDevicePath = deviceData[clearTextDevice].path.v; + + addRequest(clearTextDevicePath, content); + } + }; + + Object.keys(newMountPoints).forEach(usedDevice => { + addRequest(usedDevice, newMountPoints[usedDevice]); + }); + + await setManualPartitioningRequests({ partitioning, requests }); + return partitioning; + } catch (error) { + console.error("Failed to prepare partitioning", error); + } +}; + +const CheckStorageDialog = ({ + deviceData, + dispatch, + onCritFail, + scenarioPartitioningMapping, + selectedDisks, + setShowDialog, + setShowStorage, + setStorageScenarioId, +}) => { + const [error, setError] = useState(); + const [checkStep, setCheckStep] = useState("rescan"); + const diskTotalSpace = useDiskTotalSpace({ selectedDisks, devices: deviceData }); + const diskFreeSpace = useDiskFreeSpace({ selectedDisks, devices: deviceData }); + const mountPointConstraints = useMountPointConstraints(); + const requiredSize = useRequiredSize(); + + const newMountPoints = useMemo(() => JSON.parse(window.sessionStorage.getItem("cockpit_mount_points") || "{}"), []); + const cockpitPassphrases = useMemo(() => JSON.parse(window.sessionStorage.getItem("cockpit_passphrases") || "{}"), []); + + const useConfiguredStorage = useMemo(() => { + const availability = checkConfiguredStorage({ + deviceData, + mountPointConstraints, + scenarioPartitioningMapping, + newMountPoints, + }); + + return availability.available; + }, [deviceData, mountPointConstraints, newMountPoints, scenarioPartitioningMapping]); + + const useFreeSpace = useMemo(() => { + const availability = checkUseFreeSpace({ diskFreeSpace, diskTotalSpace, requiredSize }); + + return availability.available && !availability.hidden; + }, [diskFreeSpace, diskTotalSpace, requiredSize]); + + const loading = !error && checkStep !== undefined; + const storageRequirementsNotMet = !loading && (error || (!useConfiguredStorage && !useFreeSpace)); + + useEffect(() => { + if (!useConfiguredStorage && checkStep === "prepare-partitioning") { + setCheckStep(); + } + }, [useConfiguredStorage, checkStep]); + + useEffect(() => { + if (checkStep !== "luks") { + return; + } + + const devicesToUnlock = ( + Object.keys(cockpitPassphrases) + .map(dev => ({ + deviceName: deviceData[dev] ? dev : getDeviceNameByPath(deviceData, dev), + passphrase: cockpitPassphrases[dev] + }))) + .filter(({ devicePath, deviceName }) => { + return ( + deviceData[deviceName].formatData.type.v === "luks" && + deviceData[deviceName].formatData.attrs.v.has_key !== "True" + ); + }); + + if (devicesToUnlock.some(dev => !dev.passphrase)) { + onCritFail()({ message: _("Cockpit storage did not provide the passphrase to unlock encrypted device.") }); + } + + if (devicesToUnlock.length === 0) { + setCheckStep("prepare-partitioning"); + return; + } + + Promise.all(devicesToUnlock.map(unlockDevice)) + .catch(exc => { + setCheckStep(); + setError(exc); + }) + .then(() => { + dispatch(getDevicesAction()); + }); + }, [dispatch, checkStep, cockpitPassphrases, newMountPoints, deviceData, onCritFail, setError]); + + useEffect(() => { + // If the required devices needed for manual partitioning are set up, + // and prepare the partitioning + if (checkStep !== "prepare-partitioning") { + return; + } + + const applyNewPartitioning = async () => { + // CLEAR_PARTITIONS_NONE = 0 + try { + await setInitializationMode({ mode: 0 }); + const partitioning = await preparePartitioning({ deviceData, newMountPoints }); + + applyStorage({ + partitioning, + onFail: exc => { + setCheckStep(); + setError(exc); + }, + onSuccess: () => setCheckStep(), + }); + } catch (exc) { + setCheckStep(); + setError(exc); + } + }; + + applyNewPartitioning(); + }, [deviceData, checkStep, newMountPoints, useConfiguredStorage]); + + useEffect(() => { + if (checkStep !== "rescan" || useConfiguredStorage === undefined) { + return; + } + + // 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({ + task, + onSuccess: () => dispatch(getDevicesAction()) + .then(() => { + setCheckStep("luks"); + }) + .catch(exc => { + setCheckStep(); + setError(exc); + }), + onFail: exc => { + setCheckStep(); + setError(exc); + } + }); + }); + }, [useConfiguredStorage, checkStep, dispatch, setError]); + + const goBackToInstallation = () => { + const mode = useConfiguredStorage ? "use-configured-storage" : "use-free-space"; + + setStorageScenarioId(mode); + setShowStorage(false); + }; + + const loadingDescription = ( + + ); + + const modalProps = {}; + if (!loading) { + if (storageRequirementsNotMet) { + modalProps.title = _("Storage requirements not met"); + } else { + modalProps.title = _("Continue with installation"); + } + } else { + modalProps["aria-label"] = _("Checking storage configuration"); + } + + return ( + setShowDialog(false)} + titleIconVariant={!loading && storageRequirementsNotMet && "warning"} + position="top" variant="small" isOpen + {...modalProps} + footer={ + !loading && + <> + + {!storageRequirementsNotMet && + <> + + + } + {storageRequirementsNotMet && + <> + + + } + + + } + > + <> + {loading && loadingDescription} + {!loading && + <> + {storageRequirementsNotMet ? error?.message : null} + + {!storageRequirementsNotMet && + + {_("Current configuration can be used for installation.")} + } + + } + + + + ); +}; + const ModifyStorageSideBar = () => { const mountPointConstraints = useMountPointConstraints(); const requiredSize = useRequiredSize(); diff --git a/src/components/storage/CockpitStorageIntegration.scss b/src/components/storage/CockpitStorageIntegration.scss index e2952616e8..11e27b9d63 100644 --- a/src/components/storage/CockpitStorageIntegration.scss +++ b/src/components/storage/CockpitStorageIntegration.scss @@ -3,6 +3,10 @@ width: 100%; } +.cockpit-storage-integration-page-section-storage-alert { + padding-bottom: 0; +} + .cockpit-storage-integration-iframe-cockpit-storage { height: 100%; } @@ -32,3 +36,13 @@ ul.cockpit-storage-integration-requirements-hint-list { .cockpit-storage-integration-requirements-hint-detail { font-size: small; } + +// Hide the [x] button in the loading mode +.cockpit-storage-integration-check-storage-dialog--loading .pf-v5-c-modal-box__close { + display: none; +} + +// Make Spinner smaller - default EmptyStatePanel svg size is too big +.cockpit-storage-integration-check-storage-dialog--loading svg.pf-v5-c-spinner { + --pf-v5-c-spinner--diameter: var(--pf-v5-c-spinner--m-lg--diameter); +} diff --git a/src/components/storage/InstallationMethod.jsx b/src/components/storage/InstallationMethod.jsx index e9f017e575..aa1e6172ec 100644 --- a/src/components/storage/InstallationMethod.jsx +++ b/src/components/storage/InstallationMethod.jsx @@ -37,6 +37,8 @@ export const InstallationMethod = ({ isEfi, isFormDisabled, onCritFail, + partitioning, + scenarioPartitioningMapping, setIsFormDisabled, setIsFormValid, setShowStorage, @@ -64,11 +66,13 @@ export const InstallationMethod = ({