diff --git a/packaging/anaconda-webui.spec.in b/packaging/anaconda-webui.spec.in
index c479633bf0..99aeae809f 100644
--- a/packaging/anaconda-webui.spec.in
+++ b/packaging/anaconda-webui.spec.in
@@ -13,9 +13,18 @@ BuildRequires: gettext
%global anacondacorever 40.20
%global cockpitver 275
+%global cockpitstorver 309
+
+# LVM and BTRFS are only recommended by cockpit-storaged
+Requires: cockpit-storaged >= %{cockpitstorver}
+Requires: udisks2-lvm
+%if 0%{?fedora}
+Requires: udisks2-btrfs
+%endif
Requires: cockpit-bridge >= %{cockpitver}
Requires: cockpit-ws >= %{cockpitver}
+Requires: cockpit-storage
Requires: anaconda-core >= %{anacondacorever}
# Firefox dependency needs to be specified there as cockpit web-view does not have a hard dependency on Firefox as
# it can often fall back to a diferent browser. This does not work in the limited installer
diff --git a/src/components/AnacondaWizard.jsx b/src/components/AnacondaWizard.jsx
index 2df892205a..69a3a1e29d 100644
--- a/src/components/AnacondaWizard.jsx
+++ b/src/components/AnacondaWizard.jsx
@@ -35,6 +35,7 @@ import {
import { AnacondaPage } from "./AnacondaPage.jsx";
import { InstallationMethod, getPageProps as getInstallationMethodProps } from "./storage/InstallationMethod.jsx";
import { getDefaultScenario } from "./storage/InstallationScenario.jsx";
+import { CockpitStorageIntegration } from "./storage/CockpitStorageIntegration.jsx";
import { MountPointMapping, getPageProps as getMountPointMappingProps } from "./storage/MountPointMapping.jsx";
import { DiskEncryption, getStorageEncryptionState, getPageProps as getDiskEncryptionProps } from "./storage/DiskEncryption.jsx";
import { InstallationLanguage, getPageProps as getInstallationLanguageProps } from "./localization/InstallationLanguage.jsx";
@@ -51,7 +52,7 @@ import { SystemTypeContext, OsReleaseContext } from "./Common.jsx";
const _ = cockpit.gettext;
const N_ = cockpit.noop;
-export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtimeData, onCritFail, title, conf }) => {
+export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtimeData, onCritFail, showStorage, setShowStorage, title, conf }) => {
const [isFormDisabled, setIsFormDisabled] = useState(false);
const [isFormValid, setIsFormValid] = useState(false);
const [reusePartitioning, setReusePartitioning] = useState(false);
@@ -64,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);
@@ -107,11 +115,14 @@ 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);
setStorageScenarioId(scenarioId);
- }
+ },
+ setShowStorage,
},
...getInstallationMethodProps({ isBootIso, osRelease, isFormValid })
},
@@ -267,6 +278,20 @@ export const AnacondaWizard = ({ dispatch, storageData, localizationData, runtim
return currentStepId ? step.props.id === currentStepId : !step.props.isHidden;
}) + 1;
+ if (showStorage) {
+ return (
+
+ );
+ }
+
return (
{
return (
diff --git a/src/components/app.jsx b/src/components/app.jsx
index 9093655662..cc8e10834e 100644
--- a/src/components/app.jsx
+++ b/src/components/app.jsx
@@ -26,7 +26,7 @@ import {
import { read_os_release as readOsRelease } from "os-release.js";
import { WithDialogs } from "dialogs.jsx";
-import { AddressContext, LanguageContext, SystemTypeContext, OsReleaseContext } from "./Common.jsx";
+import { AddressContext, LanguageContext, SystemTypeContext, TargetSystemRootContext, OsReleaseContext } from "./Common.jsx";
import { AnacondaHeader } from "./AnacondaHeader.jsx";
import { AnacondaWizard } from "./AnacondaWizard.jsx";
import { CriticalError, errorHandlerWithContext, bugzillaPrefiledReportURL } from "./Error.jsx";
@@ -59,6 +59,7 @@ export const Application = () => {
const [storeInitilized, setStoreInitialized] = useState(false);
const criticalError = state?.error?.criticalError;
const [jsError, setJsEroor] = useState();
+ const [showStorage, setShowStorage] = useState(false);
const onCritFail = useCallback((contextData) => {
return errorHandlerWithContext(contextData, exc => dispatch(setCriticalErrorAction(exc)));
@@ -155,6 +156,7 @@ export const Application = () => {
reportLinkURL={bzReportURL} />}
{!jsError &&
<>
+ {!showStorage &&
{
isConnected={state.network.connected}
onCritFail={onCritFail}
/>
-
+ }
-
-
-
+
+
+
+
+
>}
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
new file mode 100644
index 0000000000..659393a811
--- /dev/null
+++ b/src/components/storage/CockpitStorageIntegration.jsx
@@ -0,0 +1,523 @@
+/*
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ HelperTextItem,
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with This program; If not, see .
+ */
+import cockpit from "cockpit";
+import React, { useEffect, useMemo, useState } from "react";
+
+import {
+ ActionList,
+ Alert,
+ Button,
+ Card,
+ CardBody,
+ Flex,
+ FlexItem,
+ HelperText,
+ HelperTextItem,
+ List,
+ ListItem,
+ Modal,
+ PageSection,
+ PageSectionVariants,
+ Text,
+ TextContent,
+ Title,
+} from "@patternfly/react-core";
+import { ArrowLeftIcon } from "@patternfly/react-icons";
+
+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 = ({ isDisabled, onAction }) => (
+ }
+ id={idPrefix + "-return-to-installation-button"}
+ isDisabled={isDisabled}
+ variant="secondary"
+ onClick={onAction}>
+ {_("Return to installation")}
+
+);
+
+export const CockpitStorageIntegration = ({
+ deviceData,
+ dispatch,
+ onCritFail,
+ scenarioAvailability,
+ scenarioPartitioningMapping,
+ selectedDisks,
+ setShowStorage,
+ setStorageScenarioId,
+}) => {
+ const [showDialog, setShowDialog] = useState(false);
+ const [needsResetPartitioning, setNeedsResetPartitioning] = useState(true);
+
+ useEffect(() => {
+ resetPartitioning().then(() => setNeedsResetPartitioning(false), onCritFail);
+ }, [onCritFail]);
+
+ return (
+ <>
+
+
+ {_("Configure storage")}
+
+
+
+
+
+ setShowDialog(true)} />
+
+ {showDialog &&
+ }
+ >
+ );
+};
+
+export const preparePartitioning = ({ deviceData, newMountPoints }) => {
+ let partitioning;
+ return setBootloaderDrive({ drive: "" })
+ .then(() => createPartitioning({ method: "MANUAL" }))
+ .then(part => {
+ partitioning = part;
+ return gatherRequests({ partitioning });
+ })
+ .then(_requests => {
+ const addRequestFromSubtree = (devicePath, object = {}) => {
+ let deviceSpec = Object.keys(deviceData).includes(devicePath) ? devicePath : getDeviceNameByPath(deviceData, devicePath);
+ const { type, dir, content, subvolumes } = object;
+ let mountPoint = dir;
+
+ if (type === "crypto") {
+ deviceSpec = deviceData[deviceSpec].children.v[0];
+ mountPoint = content.dir;
+ }
+
+ if (deviceSpec && (mountPoint || type === "swap")) {
+ const existingRequestIndex = (
+ requests.findIndex(request => request["device-spec"].v === deviceSpec)
+ );
+
+ if (existingRequestIndex !== -1) {
+ requests[existingRequestIndex] = {
+ ...requests[existingRequestIndex],
+ "mount-point": cockpit.variant("s", mountPoint || type),
+ };
+ } else {
+ requests.push({
+ "mount-point": cockpit.variant("s", mountPoint || type),
+ "device-spec": cockpit.variant("s", deviceSpec),
+ });
+ }
+ }
+
+ if (subvolumes) {
+ Object.keys(subvolumes).forEach(subvolume => {
+ addRequestFromSubtree(subvolume, subvolumes[subvolume]);
+ });
+ }
+ };
+
+ const requests = [..._requests];
+
+ Object.keys(newMountPoints).forEach(usedDevice => {
+ addRequestFromSubtree(usedDevice, newMountPoints[usedDevice]);
+ });
+
+ return setManualPartitioningRequests({ partitioning, requests });
+ })
+ .then(() => partitioning);
+};
+
+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 useConfiguredStorage = useMemo(() => {
+ const availability = checkConfiguredStorage({
+ mountPointConstraints,
+ scenarioPartitioningMapping,
+ newMountPoints,
+ });
+
+ return availability.available;
+ }, [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 cockpitDevices = (
+ Object.keys(newMountPoints)
+ .map(devicePath => ({
+ devicePath,
+ deviceName: getDeviceNameByPath(deviceData, devicePath),
+ }))
+ );
+
+ const devicesToUnlock = (
+ cockpitDevices
+ .filter(({ devicePath, deviceName }) => {
+ return (
+ newMountPoints[devicePath].type === "crypto" &&
+ deviceData[deviceName].formatData.attrs.v.has_key !== "True"
+ );
+ })
+ .map(({ devicePath, deviceName }) => ({
+ deviceName,
+ passphrase: newMountPoints[devicePath].passphrase,
+ }))
+ );
+
+ 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, newMountPoints, deviceData, onCritFail, setError]);
+
+ useEffect(() => {
+ // If the required devices needed for manual partitioning are set up,
+ // prepare the partitioning and set the scenario to 'use-configured-storage'.
+ if (checkStep !== "prepare-partitioning") {
+ return;
+ }
+
+ // CLEAR_PARTITIONS_NONE = 0
+ setInitializationMode({ mode: 0 })
+ .then(() => preparePartitioning({ deviceData, newMountPoints }))
+ .then(partitioning => {
+ applyStorage({
+ partitioning,
+ onFail: exc => {
+ setCheckStep();
+ setError(exc);
+ },
+ onSuccess: () => setCheckStep(),
+ });
+ })
+ .catch(setError);
+ }, [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(() => {
+ if (useConfiguredStorage) {
+ setCheckStep("luks");
+ } else {
+ setCheckStep();
+ }
+ })
+ .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();
+
+ if (mountPointConstraints === undefined) {
+ return null;
+ }
+
+ const requiredConstraints = (
+ mountPointConstraints.filter(constraint => constraint.required.v)
+ );
+ const recommendedConstraints = (
+ mountPointConstraints.filter(constraint => !constraint.required.v && constraint.recommended.v)
+ );
+
+ const requiredConstraintsSection = (
+ requiredConstraints.length > 0 &&
+ <>
+
+ {_("If you are configuring partitions the following are required:")}
+
+
+ {requiredConstraints.map(constraint => {
+ const item = [
+ constraint["mount-point"].v,
+ constraint["required-filesystem-type"].v
+ ]
+ .filter(c => !!c)
+ .join(" ");
+
+ return {item};
+ })}
+
+ >
+ );
+ const recommendedConstraintsSection = (
+ recommendedConstraints.length > 0 &&
+ <>
+
+ {_("Recommended partitions:")}
+
+
+ {recommendedConstraints.map(constraint => {
+ const item = [
+ constraint["mount-point"].v,
+ constraint["required-filesystem-type"].v
+ ]
+ .filter(c => !!c)
+ .join(" ");
+
+ return {item};
+ })}
+
+ >
+ );
+
+ return (
+
+
+
+
+
+ {_("Requirements")}
+
+
+ {cockpit.format(_("Fedora linux requires at least $0 of disk space."), cockpit.format_bytes(requiredSize))}
+
+
+ {_("You can either free up enough space here and let the installer handle the rest or manually set up partitions.")}
+
+
+
+
+ {_("Partitions (advanced)")}
+
+ {requiredConstraintsSection}
+ {recommendedConstraintsSection}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/storage/CockpitStorageIntegration.scss b/src/components/storage/CockpitStorageIntegration.scss
new file mode 100644
index 0000000000..11e27b9d63
--- /dev/null
+++ b/src/components/storage/CockpitStorageIntegration.scss
@@ -0,0 +1,48 @@
+.cockpit-storage-integration-page-section-cockpit-storage,
+.cockpit-storage-integration-iframe-cockpit-storage {
+ width: 100%;
+}
+
+.cockpit-storage-integration-page-section-storage-alert {
+ padding-bottom: 0;
+}
+
+.cockpit-storage-integration-iframe-cockpit-storage {
+ height: 100%;
+}
+
+.cockpit-storage-integration-page-section-cockpit-storage {
+ display: grid;
+ grid-template-columns: 3fr 1fr;
+}
+
+.cockpit-storage-integration-sidebar {
+ padding-left: 0;
+}
+
+.cockpit-storage-integration-page-section-cockpit-storage {
+ flex: 1;
+}
+
+.cockpit-storage-integration-page-section-storage-footer.pf-m-sticky-bottom {
+ flex: none;
+}
+
+.cockpit-storage-integration-requirements-hint,
+ul.cockpit-storage-integration-requirements-hint-list {
+ font-size: smaller;
+}
+
+.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/DiskEncryption.jsx b/src/components/storage/DiskEncryption.jsx
index 2992a0411c..4de14f0fa2 100644
--- a/src/components/storage/DiskEncryption.jsx
+++ b/src/components/storage/DiskEncryption.jsx
@@ -136,7 +136,7 @@ export const getPageProps = ({ storageScenarioId }) => {
return ({
id: "disk-encryption",
label: _("Disk encryption"),
- isHidden: storageScenarioId === "mount-point-mapping",
+ isHidden: ["mount-point-mapping", "use-configured-storage"].includes(storageScenarioId),
title: _("Encrypt the selected devices?")
});
};
diff --git a/src/components/storage/HelpAutopartOptions.jsx b/src/components/storage/HelpAutopartOptions.jsx
index 37a390792b..2ba2a33249 100644
--- a/src/components/storage/HelpAutopartOptions.jsx
+++ b/src/components/storage/HelpAutopartOptions.jsx
@@ -7,3 +7,5 @@ export const helpEraseAll = _("Remove all partitions on the selected devices, in
export const helpUseFreeSpace = _("Keep current disk layout and only install into available space.");
export const helpMountPointMapping = _("Manually select storage locations for installation.");
+
+export const helpConfiguredStorage = _("Storage is based on the configuration from 'Modify storage'.");
diff --git a/src/components/storage/InstallationDestination.jsx b/src/components/storage/InstallationDestination.jsx
index 54f23a020e..e09a0f4318 100644
--- a/src/components/storage/InstallationDestination.jsx
+++ b/src/components/storage/InstallationDestination.jsx
@@ -49,6 +49,7 @@ import {
runStorageTask,
scanDevicesWithTask,
} from "../../apis/storage.js";
+
import { resetPartitioning } from "../../apis/storage_partitioning.js";
import { setSelectedDisks } from "../../apis/storage_disks_selection.js";
@@ -314,11 +315,13 @@ const rescanDisks = (setIsRescanningDisks, refUsableDisks, dispatch, errorHandle
export const InstallationDestination = ({
deviceData,
diskSelection,
+ isEfi,
dispatch,
idPrefix,
isFormDisabled,
setIsFormValid,
setIsFormDisabled,
+ setShowStorage,
onRescanDisks,
onCritFail
}) => {
@@ -433,7 +436,13 @@ export const InstallationDestination = ({
: _("No usable disks detected")
)}
{rescanDisksButton}
- {!isBootIso && }
+ deviceData[disk].path.v)}
+ isEfi={isEfi}
+ />
>
diff --git a/src/components/storage/InstallationMethod.jsx b/src/components/storage/InstallationMethod.jsx
index 3beb992737..aa1e6172ec 100644
--- a/src/components/storage/InstallationMethod.jsx
+++ b/src/components/storage/InstallationMethod.jsx
@@ -34,10 +34,14 @@ export const InstallationMethod = ({
diskSelection,
dispatch,
idPrefix,
+ isEfi,
isFormDisabled,
onCritFail,
- setIsFormValid,
+ partitioning,
+ scenarioPartitioningMapping,
setIsFormDisabled,
+ setIsFormValid,
+ setShowStorage,
setStorageScenarioId,
storageScenarioId,
}) => {
@@ -50,21 +54,25 @@ export const InstallationMethod = ({
{
const availability = new AvailabilityState();
+
+ availability.hidden = false;
+
if (diskTotalSpace < requiredSize) {
availability.available = false;
availability.reason = _("Not enough space on selected disks.");
@@ -58,8 +60,11 @@ const checkEraseAll = ({ requiredSize, diskTotalSpace }) => {
return availability;
};
-const checkUseFreeSpace = ({ diskFreeSpace, diskTotalSpace, requiredSize }) => {
+export const checkUseFreeSpace = ({ diskFreeSpace, diskTotalSpace, requiredSize }) => {
const availability = new AvailabilityState();
+
+ availability.hidden = false;
+
if (diskFreeSpace > 0 && diskTotalSpace > 0) {
availability.hidden = diskFreeSpace === diskTotalSpace;
}
@@ -79,6 +84,8 @@ const checkUseFreeSpace = ({ diskFreeSpace, diskTotalSpace, requiredSize }) => {
const checkMountPointMapping = ({ hasFilesystems, duplicateDeviceNames }) => {
const availability = new AvailabilityState();
+ availability.hidden = false;
+
if (!hasFilesystems) {
availability.available = false;
availability.reason = _("No usable devices on the selected disks.");
@@ -92,7 +99,52 @@ const checkMountPointMapping = ({ hasFilesystems, duplicateDeviceNames }) => {
return availability;
};
-const scenarios = [{
+export const checkConfiguredStorage = ({ mountPointConstraints, partitioning, newMountPoints, scenarioPartitioningMapping }) => {
+ const availability = new AvailabilityState();
+
+ const currentPartitioningMatches = partitioning !== undefined && scenarioPartitioningMapping["use-configured-storage"] === partitioning;
+ availability.hidden = partitioning === undefined || !currentPartitioningMatches;
+
+ availability.available = (
+ newMountPoints === undefined ||
+ (
+ mountPointConstraints
+ ?.filter(m => m.required.v)
+ .every(m => {
+ const allDirs = [];
+ const getNestedDirs = (key, object) => {
+ const { dir, content, subvolumes } = object;
+
+ if (dir) {
+ allDirs.push(dir);
+ }
+ if (content) {
+ getNestedDirs(content.dir, content.content);
+ }
+ if (subvolumes) {
+ Object.keys(subvolumes).forEach(sv => getNestedDirs(sv, object.subvolumes[sv]));
+ }
+ };
+
+ if (m["mount-point"].v) {
+ Object.keys(newMountPoints).forEach(key => getNestedDirs(key, newMountPoints[key]));
+
+ return allDirs.includes(m["mount-point"].v);
+ }
+
+ // FIXME: cockpit does not return the biosboot partitions in the cockpit_mount_points
+ if (m["required-filesystem-type"].v === "biosboot") {
+ return true;
+ }
+ return false;
+ })
+ )
+ );
+
+ return availability;
+};
+
+export const scenarios = [{
id: "erase-all",
label: _("Erase data and install"),
detail: helpEraseAll,
@@ -134,7 +186,22 @@ const scenarios = [{
dialogTitleIconVariant: "",
dialogWarningTitle: _("Install on the custom mount points?"),
dialogWarning: _("The installation will use your configured partitioning layout."),
-}];
+}, {
+ id: "use-configured-storage",
+ label: _("Use configured storage"),
+ default: false,
+ detail: helpConfiguredStorage,
+ check: checkConfiguredStorage,
+ // CLEAR_PARTITIONS_NONE = 0
+ initializationMode: 0,
+ buttonLabel: _("Apply storage configuration and install"),
+ buttonVariant: "danger",
+ screenWarning: _("To prevent loss, make sure to backup your data."),
+ dialogTitleIconVariant: "",
+ dialogWarningTitle: _("Install using the configured storage?"),
+ dialogWarning: _("The installation will use your configured partitioning layout."),
+}
+];
export const getScenario = (scenarioId) => {
return scenarios.filter(s => s.id === scenarioId)[0];
@@ -157,12 +224,13 @@ const InstallationScenarioSelector = ({
idPrefix,
isFormDisabled,
onCritFail,
+ partitioning,
+ scenarioPartitioningMapping,
selectedDisks,
setIsFormValid,
setStorageScenarioId,
storageScenarioId,
}) => {
- const [selectedScenario, setSelectedScenario] = useState();
const [scenarioAvailability, setScenarioAvailability] = useState(Object.fromEntries(
scenarios.map((s) => [s.id, new AvailabilityState()])
));
@@ -170,20 +238,55 @@ const InstallationScenarioSelector = ({
const diskFreeSpace = useDiskFreeSpace({ selectedDisks, devices: deviceData });
const duplicateDeviceNames = useDuplicateDeviceNames({ deviceNames });
const hasFilesystems = useHasFilesystems({ selectedDisks, devices: deviceData });
+ const mountPointConstraints = useMountPointConstraints();
const requiredSize = useRequiredSize();
+ useEffect(() => {
+ if ([diskTotalSpace, diskFreeSpace, hasFilesystems, mountPointConstraints, requiredSize].some(itm => itm === undefined)) {
+ return;
+ }
+
+ setScenarioAvailability(oldAvailability => {
+ const newAvailability = {};
+
+ for (const scenario of scenarios) {
+ const availability = scenario.check({
+ diskFreeSpace,
+ diskTotalSpace,
+ duplicateDeviceNames,
+ hasFilesystems,
+ mountPointConstraints,
+ partitioning,
+ requiredSize,
+ scenarioPartitioningMapping,
+ storageScenarioId,
+ });
+ newAvailability[scenario.id] = availability;
+ }
+ return newAvailability;
+ });
+ }, [
+ diskFreeSpace,
+ diskTotalSpace,
+ duplicateDeviceNames,
+ hasFilesystems,
+ mountPointConstraints,
+ partitioning,
+ requiredSize,
+ storageScenarioId,
+ scenarioPartitioningMapping,
+ ]);
+
useEffect(() => {
let selectedScenarioId = "";
let availableScenarioExists = false;
- if ([diskTotalSpace, diskFreeSpace, hasFilesystems, requiredSize].some(itm => itm === undefined)) {
+ if (storageScenarioId && scenarioAvailability[storageScenarioId].available === undefined) {
return;
}
- const newAvailability = {};
for (const scenario of scenarios) {
- const availability = scenario.check({ diskTotalSpace, diskFreeSpace, hasFilesystems, requiredSize, duplicateDeviceNames });
- newAvailability[scenario.id] = availability;
+ const availability = scenarioAvailability[scenario.id];
if (availability.available) {
availableScenarioExists = true;
if (scenario.id === storageScenarioId) {
@@ -196,26 +299,24 @@ const InstallationScenarioSelector = ({
}
}
}
- setSelectedScenario(selectedScenarioId);
- setScenarioAvailability(newAvailability);
+ if (availableScenarioExists) {
+ setStorageScenarioId(selectedScenarioId);
+ }
setIsFormValid(availableScenarioExists);
- }, [deviceData, hasFilesystems, requiredSize, diskFreeSpace, diskTotalSpace, duplicateDeviceNames, setIsFormValid, storageScenarioId]);
+ }, [scenarioAvailability, setStorageScenarioId, setIsFormValid, storageScenarioId]);
useEffect(() => {
const applyScenario = async (scenarioId) => {
const scenario = getScenario(scenarioId);
- setStorageScenarioId(scenarioId);
- console.log("Updating scenario selected in backend to", scenario.id);
-
await setInitializationMode({ mode: scenario.initializationMode }).catch(console.error);
};
- if (selectedScenario) {
- applyScenario(selectedScenario);
+ if (storageScenarioId) {
+ applyScenario(storageScenarioId);
}
- }, [selectedScenario, setStorageScenarioId]);
+ }, [storageScenarioId]);
const onScenarioToggled = (scenarioId) => {
- setSelectedScenario(scenarioId);
+ setStorageScenarioId(scenarioId);
};
const scenarioItems = scenarios.filter(scenario => !scenarioAvailability[scenario.id].hidden).map(scenario => (
@@ -244,7 +345,19 @@ const InstallationScenarioSelector = ({
return scenarioItems;
};
-export const InstallationScenario = ({ deviceData, deviceNames, diskSelection, idPrefix, isFormDisabled, onCritFail, setIsFormValid, storageScenarioId, setStorageScenarioId }) => {
+export const InstallationScenario = ({
+ deviceData,
+ deviceNames,
+ idPrefix,
+ isFormDisabled,
+ onCritFail,
+ partitioning,
+ scenarioPartitioningMapping,
+ selectedDisks,
+ setIsFormValid,
+ setStorageScenarioId,
+ storageScenarioId,
+}) => {
const isBootIso = useContext(SystemTypeContext) === "BOOT_ISO";
const headingLevel = isBootIso ? "h2" : "h3";
@@ -255,13 +368,15 @@ export const InstallationScenario = ({ deviceData, deviceNames, diskSelection, i
>
diff --git a/src/components/storage/ModifyStorage.jsx b/src/components/storage/ModifyStorage.jsx
index a731685f9f..7c297e97df 100644
--- a/src/components/storage/ModifyStorage.jsx
+++ b/src/components/storage/ModifyStorage.jsx
@@ -15,121 +15,24 @@
* along with This program; If not, see .
*/
import cockpit from "cockpit";
-import React, { useState } from "react";
+import React, { useContext } from "react";
import {
Button,
- Modal,
- Text,
- TextContent,
- TextVariants,
} from "@patternfly/react-core";
-import { WrenchIcon, ExternalLinkAltIcon } from "@patternfly/react-icons";
-
-const _ = cockpit.gettext;
-const N_ = cockpit.noop;
-
-const startBlivetGUI = (onStart, onStarted, errorHandler) => {
- console.log("Spawning blivet-gui.");
- // We don't have an event informing that blivet-gui started so just wait a bit.
- const timeoutId = window.setTimeout(onStarted, 3000);
- cockpit.spawn(["blivet-gui", "--keep-above", "--auto-dev-updates"], { err: "message" })
- .then(() => {
- console.log("blivet-gui exited.");
- // If the blivet-gui exits earlier cancel the delay
- window.clearTimeout(timeoutId);
- return onStarted();
- })
- .catch((error) => { window.clearTimeout(timeoutId); errorHandler(error) });
- onStart();
-};
+import {
+ WrenchIcon,
+} from "@patternfly/react-icons";
-const StorageModifiedModal = ({ onClose, onRescan }) => {
- return (
-
-
-
- >
- }>
- {_("If you have made changes on partitions or disks, please rescan storage.")}
-
- );
-};
+import { TargetSystemRootContext } from "../Common.jsx";
+import { useMountPointConstraints } from "./Common.jsx";
-const ModifyStorageModal = ({ onClose, onToolStarted, errorHandler }) => {
- const [toolIsStarting, setToolIsStarting] = useState(false);
- const onStart = () => setToolIsStarting(true);
- const onStarted = () => { setToolIsStarting(false); onToolStarted() };
- return (
-
-
-
- >
- }>
-
-
- {_("Blivet-gui is an advanced storage editor that lets you resize, delete, and create partitions. It can set up LVM and much more.")}
-
-
- {_("Changes made in Blivet-gui will directly affect your storage.")}
-
-
-
- );
-};
+const _ = cockpit.gettext;
-export const ModifyStorage = ({ idPrefix, onCritFail, onRescan }) => {
- const [openedDialog, setOpenedDialog] = useState("");
+export const ModifyStorage = ({ idPrefix, onCritFail, onRescan, setShowStorage, selectedDevices }) => {
+ const targetSystemRoot = useContext(TargetSystemRootContext);
+ const mountPointConstraints = useMountPointConstraints();
+ const isEfi = mountPointConstraints?.some(c => c["required-filesystem-type"]?.v === "efi");
return (
<>
@@ -137,20 +40,19 @@ export const ModifyStorage = ({ idPrefix, onCritFail, onRescan }) => {
id={idPrefix + "-modify-storage"}
variant="link"
icon={}
- onClick={() => setOpenedDialog("modify")}>
+ onClick={() => {
+ window.sessionStorage.setItem("cockpit_anaconda",
+ JSON.stringify({
+ mount_point_prefix: targetSystemRoot,
+ available_devices: selectedDevices,
+ efi: isEfi,
+ })
+ );
+ setShowStorage(true);
+ }}
+ >
{_("Modify storage")}
- {openedDialog === "modify" &&
- setOpenedDialog("")}
- onToolStarted={() => setOpenedDialog("rescan")}
- errorHandler={onCritFail({ context: N_("Modifying the storage failed.") })}
- />}
- {openedDialog === "rescan" &&
- setOpenedDialog("")}
- onRescan={onRescan}
- />}
>
);
};
diff --git a/src/helpers/storage.js b/src/helpers/storage.js
index 7ad213f7ca..5824f02985 100644
--- a/src/helpers/storage.js
+++ b/src/helpers/storage.js
@@ -116,3 +116,7 @@ export const hasDuplicateFields = (requests, fieldName) => {
export const isDuplicateRequestField = (requests, fieldName, fieldValue) => {
return requests.filter((request) => request[fieldName] === fieldValue).length > 1;
};
+
+export const getDeviceNameByPath = (deviceData, path) => {
+ return Object.keys(deviceData).find(d => deviceData[d].path?.v === path || deviceData[d].links?.v.includes(path));
+};
diff --git a/test/check-storage b/test/check-storage
index 03cad3d271..88dda44bd0 100755
--- a/test/check-storage
+++ b/test/check-storage
@@ -24,7 +24,7 @@ from review import Review
from password import Password
from testlib import nondestructive, skipImage, test_main # pylint: disable=import-error
from progress import Progress
-from storagelib import StorageHelpers # pylint: disable=import-error
+from storagelib import StorageCase, StorageHelpers # pylint: disable=import-error
from utils import pretend_live_iso
@@ -1029,6 +1029,215 @@ class TestStorageMountPoints(anacondalib.VirtInstallMachineCase, StorageHelpers)
s.check_mountpoint_row_device_available(1, f"{dev}2", False)
s.check_mountpoint_row_device_available(1, f"{dev}3", False)
+class TestStorageCockpitIntegration(anacondalib.VirtInstallMachineCase, StorageCase):
+ @nondestructive
+ def testEncryptedUnlock(self):
+ b = self.browser
+ m = self.machine
+ i = Installer(b, m)
+ s = Storage(b, m)
+ r = Review(b)
+
+ vgname = "fedoravg"
+
+ self.addCleanup(m.execute, f"vgremove -y -ff {vgname}")
+
+ disk = "/dev/vda"
+ dev = "vda"
+
+ i.open()
+ i.reach(i.steps.INSTALLATION_METHOD)
+ s.wait_scenario_visible("use-configured-storage", False)
+ s.modify_storage()
+ b.wait_visible(".cockpit-storage-integration-sidebar")
+
+ frame = "iframe[name='cockpit-storage']"
+ b._wait_present(frame)
+ b.switch_to_frame("cockpit-storage")
+ b._wait_present("#storage.ct-page-fill")
+
+ self.click_dropdown(self.card_row("Storage", 1), "Create partition table")
+ self.confirm()
+
+ self.click_dropdown(self.card_row("Storage", 2), "Create partition")
+ self.dialog({ "size": 1, "type": "biosboot" })
+
+ self.click_dropdown(self.card_row("Storage", 3), "Create partition")
+ self.dialog({"size": 1070, "type": "ext4", "mount_point": "/boot" })
+
+ self.click_dropdown(self.card_row("Storage", 4), "Create partition")
+ self.dialog({
+ "type": "ext4", "mount_point": "/",
+ "crypto": self.default_crypto_type,
+ "passphrase": "redhat",
+ "passphrase2": "redhat",
+ })
+
+ # Exit the cockpit-storage iframe
+ b.switch_to_top()
+
+ s.return_to_installation()
+ s.return_to_installation_confirm()
+
+ s.set_partitioning("use-configured-storage")
+
+ self.addCleanup(lambda: dbus_reset_users(m))
+ i.reach(i.steps.REVIEW)
+
+ r.check_in_disk_row(dev, 2, "luks-")
+
+ @nondestructive
+ def testLVM(self):
+ b = self.browser
+ m = self.machine
+ i = Installer(b, m)
+ s = Storage(b, m)
+ r = Review(b)
+
+ vgname = "fedoravg"
+
+ self.addCleanup(m.execute, f"vgremove -y -ff {vgname}")
+
+ disk = "/dev/vda"
+ dev = "vda"
+
+ i.open()
+ i.reach(i.steps.INSTALLATION_METHOD)
+ s.wait_scenario_visible("use-configured-storage", False)
+ s.modify_storage()
+ b.wait_visible(".cockpit-storage-integration-sidebar")
+
+ frame = "iframe[name='cockpit-storage']"
+ b._wait_present(frame)
+ b.switch_to_frame("cockpit-storage")
+ b._wait_present("#storage.ct-page-fill")
+
+ self.click_dropdown(self.card_row("Storage", 1), "Create partition table")
+ self.confirm()
+
+ self.click_dropdown(self.card_row("Storage", 2), "Create partition")
+ self.dialog({ "size": 1, "type": "biosboot" })
+
+ self.click_dropdown(self.card_row("Storage", 3), "Create partition")
+ self.dialog({"size": 1070, "type": "ext4", "mount_point": "/boot" })
+
+ self.click_devices_dropdown("Create LVM2 volume group")
+ self.dialog({ "name": vgname, "disks": { dev: True } })
+
+ self.click_card_row("Storage", name=vgname)
+
+ b.click(self.card_button("LVM2 logical volumes", "Create new logical volume"))
+ self.dialog({ "name": "root", "size": 6010 })
+ self.click_card_row("LVM2 logical volumes", 1)
+ self.click_card_dropdown("Unformatted data", "Format")
+ self.dialog({ "type": "ext4", "mount_point": "/" })
+
+ b.click(self.card_parent_link())
+
+ b.click(self.card_button("LVM2 logical volumes", "Create new logical volume"))
+ self.dialog({ "name": "home", "size": 8120 })
+ self.click_card_row("LVM2 logical volumes", 1)
+ self.click_card_dropdown("Unformatted data", "Format")
+ self.dialog({"type": "ext4", "mount_point": "/home" })
+
+ b.click(self.card_parent_link())
+
+ b.click(self.card_button("LVM2 logical volumes", "Create new logical volume"))
+ self.dialog({ "name": "swap", "size": 898 })
+ self.click_card_row("LVM2 logical volumes", 3)
+ self.click_card_dropdown("Unformatted data", "Format")
+ self.dialog({ "type": "swap" })
+
+ # Exit the cockpit-storage iframe
+ b.switch_to_top()
+
+ s.return_to_installation()
+ s.return_to_installation_confirm()
+
+ s.set_partitioning("use-configured-storage")
+
+ self.addCleanup(lambda: dbus_reset_users(m))
+ i.reach(i.steps.REVIEW)
+
+ # verify review screen
+ disk = "vda"
+ r.check_disk(disk, "16.1 GB vda (0x1af4)")
+
+ r.check_disk_row(disk, 1, "vda2, 1.07 GB: mount, /boot")
+ r.check_disk_row(disk, 2, f"{vgname}-root, 6.01 GB: format as ext4, /")
+ r.check_disk_row(disk, 3, f"{vgname}-home, 8.12 GB: mount, /home")
+ r.check_disk_row(disk, 4, f"{vgname}-swap, 898 MB: mount, swap")
+
+
+ @nondestructive
+ def testBtrfsSubvolumes(self):
+ b = self.browser
+ m = self.machine
+ i = Installer(b, m)
+ s = Storage(b, m)
+ r = Review(b)
+
+ tmp_mount = "/btrfs-mount-test"
+ tmp_name = "btrfs-mount-test"
+
+ i.open()
+ i.reach(i.steps.INSTALLATION_METHOD)
+ s.wait_scenario_visible("use-configured-storage", False)
+ s.modify_storage()
+ b.wait_visible(".cockpit-storage-integration-sidebar")
+
+ frame = "iframe[name='cockpit-storage']"
+ b._wait_present(frame)
+ b.switch_to_frame("cockpit-storage")
+ b._wait_present("#storage.ct-page-fill")
+
+ self.click_dropdown(self.card_row("Storage", 1), "Create partition table")
+ self.confirm()
+
+ self.click_dropdown(self.card_row("Storage", 2), "Create partition")
+ self.dialog({ "size": 1, "type": "biosboot" })
+
+ self.click_dropdown(self.card_row("Storage", 3), "Create partition")
+ self.dialog({"size": 1070, "type": "ext4", "mount_point": "/boot" })
+
+ self.click_dropdown(self.card_row("Storage", 4), "Create partition")
+ self.dialog({ "type": "btrfs", "name": tmp_name })
+
+ self.click_card_row("Storage", name=tmp_name)
+ self.click_card_row("btrfs subvolumes", 1)
+
+ b.click(self.card_button("btrfs subvolume", "Mount"))
+ self.dialog({ "mount_point": tmp_mount })
+
+ b.click(self.card_button("btrfs subvolume", "Create subvolume"))
+ self.dialog({ "name": "root", "mount_point": "/" })
+
+ b.click(self.card_button("btrfs subvolume", "Create subvolume"))
+ self.dialog({ "name": "unused", "mount_point": "/unused" })
+
+ b.click(self.card_button("btrfs subvolume", "Create subvolume"))
+ self.dialog({ "name": "home", "mount_point": "/home" })
+
+ # Exit the cockpit-storage iframe
+ b.switch_to_top()
+
+ s.return_to_installation()
+ s.return_to_installation_confirm()
+
+ s.set_partitioning("use-configured-storage")
+
+ self.addCleanup(lambda: dbus_reset_users(m))
+ i.reach(i.steps.REVIEW)
+
+ # verify review screen
+ disk = "vda"
+ r.check_disk(disk, "16.1 GB vda (0x1af4)")
+
+ r.check_disk_row(disk, 1, "vda2, 1.07 GB: mount, /boot")
+ r.check_disk_row(disk, 2, f"{vgname}-root, 6.01 GB: format as ext4, /")
+ r.check_disk_row(disk, 3, f"{vgname}-home, 8.12 GB: mount, /home")
+ r.check_disk_row(disk, 4, f"{vgname}-swap, 898 MB: mount, swap")
+
class TestStorageMountPointsEFI(anacondalib.VirtInstallMachineCase):
efi = True
diff --git a/test/helpers/installer.py b/test/helpers/installer.py
index 2f19b153c9..ee5672c3b4 100644
--- a/test/helpers/installer.py
+++ b/test/helpers/installer.py
@@ -33,7 +33,7 @@ class InstallerSteps(UserDict):
_steps_jump = {}
_steps_jump[WELCOME] = [INSTALLATION_METHOD]
- _steps_jump[INSTALLATION_METHOD] = [DISK_ENCRYPTION, CUSTOM_MOUNT_POINT]
+ _steps_jump[INSTALLATION_METHOD] = [DISK_ENCRYPTION, CUSTOM_MOUNT_POINT, ACCOUNTS]
_steps_jump[DISK_ENCRYPTION] = [ACCOUNTS]
_steps_jump[CUSTOM_MOUNT_POINT] = [ACCOUNTS]
_steps_jump[ACCOUNTS] = [REVIEW]
@@ -43,6 +43,7 @@ class InstallerSteps(UserDict):
_parent_steps = {}
_parent_steps[DISK_ENCRYPTION] = DISK_CONFIGURATION
_parent_steps[CUSTOM_MOUNT_POINT] = DISK_CONFIGURATION
+ _parent_steps[ACCOUNTS] = INSTALLATION_METHOD
_steps_callbacks = {}
_steps_callbacks[ACCOUNTS] = create_user
diff --git a/test/helpers/storage.py b/test/helpers/storage.py
index 0e4951954e..920b26f8d3 100644
--- a/test/helpers/storage.py
+++ b/test/helpers/storage.py
@@ -106,6 +106,12 @@ def rescan_disks(self):
with b.wait_timeout(30):
b.wait_not_present(f"#{self._step}-rescan-disks.pf-m-disabled")
+ def return_to_installation(self):
+ self.browser.click("#cockpit-storage-integration-return-to-installation-button")
+
+ def return_to_installation_confirm(self):
+ self.browser.click("#cockpit-storage-integration-check-storage-dialog-continue")
+
def modify_storage(self):
self.browser.click(f"#{self._step}-modify-storage")