diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index 642c8702d9..f0413ccdfe 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Sun Dec 1 17:14:04 UTC 2024 - Knut Anderssen + +- Do not crash in the InstallationFinished page when running an + unattended installation with an storage section defined in the + profile (gh#agama-project/agama#1793). + ------------------------------------------------------------------- Thu Nov 28 14:34:49 UTC 2024 - David Diaz diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index d67166e828..ffa26f375b 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -23,6 +23,7 @@ import { get, post } from "~/api/http"; import { Job } from "~/types/job"; import { calculate, fetchSettings } from "~/api/storage/proposal"; +import { config } from "~/api/storage/types"; /** * Starts the storage probing process. @@ -33,6 +34,9 @@ const probe = (): Promise => post("/api/storage/probe"); export { probe }; +const fetchConfig = (): Promise => + get("/api/storage/config").then((config) => config.storage); + /** * Returns the list of jobs */ @@ -57,4 +61,4 @@ const refresh = async (): Promise => { await calculate(settings); }; -export { fetchStorageJobs, findStorageJob, refresh }; +export { fetchConfig, fetchStorageJobs, findStorageJob, refresh }; diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index aee104ea76..44db12a9b7 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -436,3 +436,7 @@ export type SetProposalSettingsResponse = boolean; export type UsableDevicesResponse = Array; export type PingResponse2 = PingResponse; + +import * as config from "./types/config"; + +export { config }; diff --git a/web/src/api/storage/types/config.ts b/web/src/api/storage/types/config.ts new file mode 100644 index 0000000000..7a7864a01d --- /dev/null +++ b/web/src/api/storage/types/config.ts @@ -0,0 +1,369 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type DriveElement = FormattedDrive | PartitionedDrive; +export type SearchElement = SimpleSearchAll | SimpleSearchByName | AdvancedSearch; +/** + * Shortcut to match all devices if there is any (equivalent to specify no conditions and to skip the entry if no device is found). + */ +export type SimpleSearchAll = "*"; +export type SimpleSearchByName = string; +/** + * How to handle the section if the device is not found. + */ +export type SearchAction = "skip" | "error"; +/** + * Alias used to reference a device. + */ +export type Alias = string; +export type Encryption = + | EncryptionLuks1 + | EncryptionLuks2 + | EncryptionPervasiveLuks2 + | EncryptionTPM + | EncryptionSwap; +/** + * Password to use when creating a new encryption device. + */ +export type EncryptionPassword = string; +/** + * The value must be compatible with the --cipher argument of the command cryptsetup. + */ +export type EncryptionCipher = string; +/** + * The value (in bits) has to be a multiple of 8. The possible key sizes are limited by the used cipher. + */ +export type EncryptionKeySize = number; +export type EncryptionPbkdFunction = "pbkdf2" | "argon2i" | "argon2id"; +/** + * Swap encryptions. + */ +export type EncryptionSwap = "protected_swap" | "secure_swap" | "random_swap"; +export type FilesystemType = FilesystemTypeAny | FilesystemTypeBtrfs; +export type FilesystemTypeAny = + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; +/** + * How to mount the device. + */ +export type MountBy = "device" | "id" | "label" | "path" | "uuid"; +/** + * Partition table type. + */ +export type PtableType = "gpt" | "msdos" | "dasd"; +export type PartitionElement = + | SimpleVolumesGenerator + | AdvancedPartitionsGenerator + | RegularPartition + | PartitionToDelete + | PartitionToDeleteIfNeeded; +export type PartitionId = "linux" | "swap" | "lvm" | "raid" | "esp" | "prep" | "bios_boot"; +export type Size = SizeValue | SizeTuple | SizeRange; +export type SizeValue = SizeString | SizeBytes; +/** + * Human readable size. + */ +export type SizeString = string; +/** + * Size in bytes. + */ +export type SizeBytes = number; +/** + * Lower size limit and optionally upper size limit. + * + * @minItems 1 + * @maxItems 2 + */ +export type SizeTuple = [SizeValueWithCurrent] | [SizeValueWithCurrent, SizeValueWithCurrent]; +export type SizeValueWithCurrent = SizeValue | SizeCurrent; +/** + * The current size of the device. + */ +export type SizeCurrent = "current"; +export type PhysicalVolumeElement = + | Alias + | SimplePhysicalVolumesGenerator + | AdvancedPhysicalVolumesGenerator; +export type LogicalVolumeElement = + | SimpleVolumesGenerator + | AdvancedLogicalVolumesGenerator + | LogicalVolume + | ThinPoolLogicalVolume + | ThinLogicalVolume; +/** + * Number of stripes. + */ +export type LogicalVolumeStripes = number; + +/** + * Storage config. + */ +export interface Config { + boot?: Boot; + /** + * Drives (disks, BIOS RAIDs and multipath devices). + */ + drives?: DriveElement[]; + /** + * LVM volume groups. + */ + volumeGroups?: VolumeGroup[]; +} +/** + * Allows configuring boot partitions automatically. + */ +export interface Boot { + /** + * Whether to configure partitions for booting. + */ + configure: boolean; + /** + * The target installation device is used by default. + */ + device?: string; +} +/** + * Drive without a partition table (e.g., directly formatted). + */ +export interface FormattedDrive { + search?: SearchElement; + alias?: Alias; + encryption?: Encryption; + filesystem: Filesystem; +} +/** + * Advanced options for searching devices. + */ +export interface AdvancedSearch { + condition?: SearchCondition; + /** + * Maximum devices to match. + */ + max?: number; + ifNotFound?: SearchAction; +} +export interface SearchCondition { + name: SimpleSearchByName; +} +/** + * LUKS1 encryption. + */ +export interface EncryptionLuks1 { + luks1: { + password: EncryptionPassword; + cipher?: EncryptionCipher; + keySize?: EncryptionKeySize; + }; +} +/** + * LUKS2 encryption. + */ +export interface EncryptionLuks2 { + luks2: { + password: EncryptionPassword; + cipher?: EncryptionCipher; + keySize?: EncryptionKeySize; + pbkdFunction?: EncryptionPbkdFunction; + /** + * LUKS2 label. + */ + label?: string; + }; +} +/** + * LUKS2 pervasive encryption. + */ +export interface EncryptionPervasiveLuks2 { + pervasiveLuks2: { + password: EncryptionPassword; + }; +} +/** + * TPM-Based Full Disk Encrytion. + */ +export interface EncryptionTPM { + tpmFde: { + password: EncryptionPassword; + }; +} +export interface Filesystem { + /** + * Try to reuse the existing file system. In some cases the file system could not be reused, for example, if the device is re-encrypted. + */ + reuseIfPossible?: boolean; + type?: FilesystemType; + /** + * File system label. + */ + label?: string; + /** + * Mount path. + */ + path?: string; + mountBy?: MountBy; + /** + * Options for creating the file system. + */ + mkfsOptions?: string[]; + /** + * Options to add to the fourth field of fstab. + */ + mountOptions?: string[]; +} +/** + * Btrfs file system. + */ +export interface FilesystemTypeBtrfs { + btrfs: { + /** + * Whether to configrue Btrfs snapshots. + */ + snapshots?: boolean; + }; +} +export interface PartitionedDrive { + search?: SearchElement; + alias?: Alias; + ptableType?: PtableType; + partitions?: PartitionElement[]; +} +/** + * Automatically creates the default or mandatory volumes configured by the selected product. + */ +export interface SimpleVolumesGenerator { + generate: "default" | "mandatory"; +} +/** + * Creates the default or mandatory partitions configured by the selected product. + */ +export interface AdvancedPartitionsGenerator { + generate: { + partitions: "default" | "mandatory"; + encryption?: Encryption; + }; +} +export interface RegularPartition { + search?: SearchElement; + alias?: Alias; + id?: PartitionId; + size?: Size; + encryption?: Encryption; + filesystem?: Filesystem; +} +/** + * Size range. + */ +export interface SizeRange { + min: SizeValueWithCurrent; + max?: SizeValueWithCurrent; +} +export interface PartitionToDelete { + search: SearchElement; + /** + * Delete the partition. + */ + delete: true; +} +export interface PartitionToDeleteIfNeeded { + search: SearchElement; + /** + * Delete the partition if needed to make space. + */ + deleteIfNeeded: true; + size?: Size; +} +/** + * LVM volume group. + */ +export interface VolumeGroup { + /** + * Volume group name. + */ + name?: string; + extentSize?: SizeValue; + /** + * Devices to use as physical volumes. + */ + physicalVolumes?: PhysicalVolumeElement[]; + logicalVolumes?: LogicalVolumeElement[]; +} +/** + * Automatically creates the needed physical volumes in the indicated devices. + */ +export interface SimplePhysicalVolumesGenerator { + generate: Alias[]; +} +/** + * Automatically creates the needed physical volumes in the indicated devices. + */ +export interface AdvancedPhysicalVolumesGenerator { + generate: { + targetDevices: Alias[]; + encryption?: Encryption; + }; +} +/** + * Automatically creates the default or mandatory logical volumes configured by the selected product. + */ +export interface AdvancedLogicalVolumesGenerator { + generate: { + logicalVolumes: "default" | "mandatory"; + encryption?: Encryption; + stripes?: LogicalVolumeStripes; + stripeSize?: SizeValue; + }; +} +export interface LogicalVolume { + /** + * Logical volume name. + */ + name?: string; + size?: Size; + stripes?: LogicalVolumeStripes; + stripeSize?: SizeValue; + encryption?: Encryption; + filesystem?: Filesystem; +} +export interface ThinPoolLogicalVolume { + /** + * LVM thin pool. + */ + pool: true; + alias?: Alias; + /** + * Logical volume name. + */ + name?: string; + size?: Size; + stripes?: LogicalVolumeStripes; + stripeSize?: SizeValue; + encryption?: Encryption; +} +export interface ThinLogicalVolume { + /** + * Thin logical volume name. + */ + name?: string; + size?: Size; + usedPool: Alias; + encryption?: Encryption; + filesystem?: Filesystem; +} diff --git a/web/src/components/core/InstallationFinished.test.tsx b/web/src/components/core/InstallationFinished.test.tsx index b6b9bdaca7..c96ed26e37 100644 --- a/web/src/components/core/InstallationFinished.test.tsx +++ b/web/src/components/core/InstallationFinished.test.tsx @@ -24,25 +24,69 @@ import React from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; -import { EncryptionMethods } from "~/types/storage"; import InstallationFinished from "./InstallationFinished"; - -let mockEncryptionPassword: string; -let mockEncryptionMethod: string; +import { Encryption } from "~/api/storage/types/config"; jest.mock("~/queries/status", () => ({ ...jest.requireActual("~/queries/status"), useInstallerStatus: () => ({ isBusy: false, useIguana: false, phase: 2, canInstall: false }), })); +type storageConfigType = "guided" | "raw"; +type guidedEncryption = { + password: string; + method: string; + pbkdFunction?: string; +}; + +let mockEncryption: undefined | Encryption | guidedEncryption; +let mockType: storageConfigType; + +const mockStorageConfig = ( + type: storageConfigType, + encryption: undefined | Encryption | guidedEncryption, +) => { + const encryptionHash = {}; + if (encryption !== undefined) encryptionHash["encryption"] = encryption; + + switch (type) { + case "guided": + return { + guided: { + ...encryptionHash, + }, + }; + case "raw": + return { + drives: [ + { + partitions: [ + { + filesystem: { + path: "/", + }, + id: "linux", + ...encryptionHash, + }, + { + filesystem: { + mountBy: "uuid", + path: "swap", + type: "swap", + }, + id: "swap", + size: "2 GiB", + }, + ], + }, + ], + }; + } +}; + jest.mock("~/queries/storage", () => ({ ...jest.requireActual("~/queries/storage"), - useProposalResult: () => ({ - settings: { - encryptionMethod: mockEncryptionMethod, - encryptionPassword: mockEncryptionPassword, - }, - }), + useConfig: () => mockStorageConfig(mockType, mockEncryption), })); const mockFinishInstallation = jest.fn(); @@ -56,8 +100,8 @@ jest.mock("~/components/core/InstallerOptions", () => () =>
Installer Optio describe("InstallationFinished", () => { beforeEach(() => { - mockEncryptionPassword = "n0tS3cr3t"; - mockEncryptionMethod = EncryptionMethods.LUKS2; + mockEncryption = null; + mockType = "guided"; }); it("shows the finished installation screen", () => { @@ -77,34 +121,54 @@ describe("InstallationFinished", () => { expect(mockFinishInstallation).toHaveBeenCalled(); }); - describe("when TPM is set as encryption method", () => { + describe("when running storage config in raw mode", () => { beforeEach(() => { - mockEncryptionMethod = EncryptionMethods.TPM; + mockType = "raw"; }); - describe("and encryption was set", () => { + describe("when TPM is set as encryption method", () => { + beforeEach(() => { + mockEncryption = { + tpmFde: { + password: "n0tS3cr3t", + }, + }; + }); + it("shows the TPM reminder", async () => { plainRender(); await screen.findAllByText(/TPM/); }); }); - describe("but encryption was not set", () => { + describe("when TPM is not set as encryption method", () => { + it("does not show the TPM reminder", async () => { + plainRender(); + expect(screen.queryAllByText(/TPM/)).toHaveLength(0); + }); + }); + }); + + describe("when running storage config in guided mode", () => { + describe("when TPM is set as encryption method", () => { beforeEach(() => { - mockEncryptionPassword = ""; + mockEncryption = { + method: "tpm_fde", + password: "n0tS3cr3t", + }; }); - it("does not show the TPM reminder", async () => { + it("shows the TPM reminder", async () => { plainRender(); - screen.queryAllByText(/TPM/); + await screen.findAllByText(/TPM/); }); }); - }); - describe("when TPM is not set as encryption method", () => { - it("does not show the TPM reminder", async () => { - plainRender(); - expect(screen.queryAllByText(/TPM/)).toHaveLength(0); + describe("when TPM is not set as encryption method", () => { + it("does not show the TPM reminder", async () => { + plainRender(); + expect(screen.queryAllByText(/TPM/)).toHaveLength(0); + }); }); }); }); diff --git a/web/src/components/core/InstallationFinished.tsx b/web/src/components/core/InstallationFinished.tsx index d61e5f5e96..598ed705ca 100644 --- a/web/src/components/core/InstallationFinished.tsx +++ b/web/src/components/core/InstallationFinished.tsx @@ -38,11 +38,10 @@ import { Text, } from "@patternfly/react-core"; import { Center, Icon } from "~/components/layout"; -import { EncryptionMethods } from "~/types/storage"; import { _ } from "~/i18n"; import alignmentStyles from "@patternfly/react-styles/css/utilities/Alignment/alignment"; import { useInstallerStatus } from "~/queries/status"; -import { useProposalResult } from "~/queries/storage"; +import { useConfig } from "~/queries/storage"; import { finishInstallation } from "~/api/manager"; import { InstallationPhase } from "~/types/status"; import { Navigate } from "react-router-dom"; @@ -77,11 +76,28 @@ the machine needs to boot directly to the new boot loader.", const SuccessIcon = () => ; +// TODO: define some utility method to get the device used as root (drive, partition, logical volume). +// TODO: use type checking for config. +function usingTpm(config): boolean { + const { guided, drives = [], volumeGroups = [] } = config; + + if (guided !== undefined) { + return guided.encryption?.method === "tpm_fde"; + } + const devices = [ + ...drives, + ...drives.flatMap((d) => d.partitions || []), + ...volumeGroups.flatMap((v) => v.logicalVolumes || []), + ]; + + const root = devices.find((d) => d.filesystem?.path === "/"); + + return root?.encryption?.tpmFde !== undefined; +} + function InstallationFinished() { const { phase, isBusy, useIguana } = useInstallerStatus({ suspense: true }); - const { - settings: { encryptionPassword, encryptionMethod }, - } = useProposalResult(); + const config = useConfig(); if (phase !== InstallationPhase.Install) { return ; @@ -91,8 +107,6 @@ function InstallationFinished() { return ; } - const usingTpm = encryptionPassword?.length > 0 && encryptionMethod === EncryptionMethods.TPM; - return (
@@ -119,7 +133,7 @@ function InstallationFinished() { "At this point you can reboot the machine to log in to the new system.", )} - {usingTpm && } + {usingTpm(config) && } diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index ee58f1c77c..d70e6b053b 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -29,6 +29,7 @@ import { } from "@tanstack/react-query"; import React from "react"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; +import { fetchConfig } from "~/api/storage"; import { calculate, fetchActions, @@ -123,6 +124,16 @@ const useDevices = ( return data; }; +const configQuery = { + queryKey: ["storage", "config"], + queryFn: fetchConfig, +}; + +const useConfig = () => { + const { data } = useSuspenseQuery(configQuery); + return data; +}; + /** * Hook that returns the list of available devices for installation. */ @@ -349,6 +360,7 @@ const useDeprecatedChanges = () => { }; export { + useConfig, useDevices, useAvailableDevices, useProductParams,