Skip to content

Commit

Permalink
Indicate transactional system (#1063)
Browse files Browse the repository at this point in the history
## Problem

Some products are defined as "transactional" (e.g., openSUSE MicroOS).
Those systems use a specific configuration for the root file system,
that is, read-only Btrfs with snapshots, and the users are not expected
to change it. Because of that, it does not make sense to offer the
"Btrfs Snapshots" switcher for transactional products.

Moreover, the storage page should indicate if the selected product is
transactional.

## Solution

For transactional products, the "Btrfs Snapshots" switcher is replaced
by an indication about transactional.

## Testing

* Added unit tests
* Tested manually

## Screenshots

![localhost_8080_
(28)](https://github.com/openSUSE/agama/assets/1112304/edf5aa9b-0c07-4a75-b0ea-57ff31e8b7db)
  • Loading branch information
joseivanlopez authored Mar 1, 2024
2 parents 8946391 + cdfd4c0 commit a4d77c3
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 34 deletions.
6 changes: 6 additions & 0 deletions web/package/cockpit-agama.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Fri Mar 1 10:56:35 UTC 2024 - José Iván López González <[email protected]>

- Indicate whether the system is transactional
(gh#openSUSE/agama/1063).

-------------------------------------------------------------------
Wed Feb 28 22:26:23 UTC 2024 - Balsa Asanovic <[email protected]>

Expand Down
7 changes: 7 additions & 0 deletions web/src/components/storage/ProposalPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ jest.mock("@patternfly/react-core", () => {
jest.mock("~/components/core/Sidebar", () => () => <div>Agama sidebar</div>);
jest.mock("~/components/storage/ProposalPageMenu", () => () => <div>ProposalPage Options</div>);

jest.mock("~/context/product", () => ({
...jest.requireActual("~/context/product"),
useProduct: () => ({
selectedProduct : { name: "Test" }
})
}));

const vda = {
sid: "59",
type: "disk",
Expand Down
33 changes: 28 additions & 5 deletions web/src/components/storage/ProposalSettingsSection.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@

import React, { useEffect, useState } from "react";
import { Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core";
import { sprintf } from "sprintf-js";

import { _ } from "~/i18n";
import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core";
import { Icon } from "~/components/layout";
import { noop } from "~/utils";
import { hasFS } from "~/components/storage/utils";
import { hasFS, isTransactionalSystem } from "~/components/storage/utils";
import { useProduct } from "~/context/product";

/**
* @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings
Expand Down Expand Up @@ -147,7 +149,9 @@ const SnapshotsField = ({
const forcedSnapshots = !configurableSnapshots && hasFS(rootVolume, "Btrfs") && rootVolume.snapshots;

const SnapshotsToggle = () => {
const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous version of the system after configuration changes or software upgrades.");
const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous \
version of the system after configuration changes or software upgrades.");

return (
<>
<Switch
Expand Down Expand Up @@ -293,6 +297,8 @@ export default function ProposalSettingsSection({
encryptionMethods = [],
onChange = noop
}) {
const { selectedProduct } = useProduct();

const changeEncryption = ({ password, method }) => {
onChange({ encryptionPassword: password, encryptionMethod: method });
};
Expand All @@ -312,12 +318,29 @@ export default function ProposalSettingsSection({

const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0;

const transactional = isTransactionalSystem(settings?.volumes || []);

return (
<>
<Section title={_("Settings")}>
<SnapshotsField
settings={settings}
onChange={changeBtrfsSnapshots}
<If
condition={transactional}
then={
<div>
<label>{_("Transactional system")}</label>
<div>
{/* TRANSLATORS: %s is replaced by a product name (e.g., openSUSE Tumbleweed) */}
{sprintf(_("%s is an immutable system with atomic updates using a read-only Btrfs \
root file system."), selectedProduct.name)}
</div>
</div>
}
else={
<SnapshotsField
settings={settings}
onChange={changeBtrfsSnapshots}
/>
}
/>
<EncryptionField
password={settings.encryptionPassword || ""}
Expand Down
33 changes: 33 additions & 0 deletions web/src/components/storage/ProposalSettingsSection.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,45 @@ jest.mock("@patternfly/react-core", () => {
};
});

jest.mock("~/context/product", () => ({
...jest.requireActual("~/context/product"),
useProduct: () => ({
selectedProduct : { name: "Test" }
})
}));

let props;

beforeEach(() => {
props = {};
});

const rootVolume = { mountPath: "/", fsType: "Btrfs", outline: { snapshotsConfigurable: true } };

describe("if the system is not transactional", () => {
beforeEach(() => {
props.settings = { volumes: [rootVolume] };
});

it("renders the snapshots switch", () => {
plainRender(<ProposalSettingsSection {...props} />);

screen.getByRole("checkbox", { name: "Use Btrfs Snapshots" });
});
});

describe("if the system is transactional", () => {
beforeEach(() => {
props.settings = { volumes: [{ ...rootVolume, transactional: true }] };
});

it("renders explanation about transactional system", () => {
plainRender(<ProposalSettingsSection {...props} />);

screen.getByText("Transactional system");
});
});

describe("Encryption field", () => {
describe("if encryption password setting is not set yet", () => {
beforeEach(() => {
Expand Down
10 changes: 5 additions & 5 deletions web/src/components/storage/ProposalVolumes.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2022-2023] SUSE LLC
* Copyright (c) [2022-2024] SUSE LLC
*
* All Rights Reserved.
*
Expand Down Expand Up @@ -34,7 +34,7 @@ import { _ } from "~/i18n";
import { Em, If, Popup, RowActions, Tip } from '~/components/core';
import { Icon } from '~/components/layout';
import { VolumeForm } from '~/components/storage';
import { deviceSize } from '~/components/storage/utils';
import { deviceSize, hasSnapshots, isTransactionalRoot } from '~/components/storage/utils';
import { noop } from "~/utils";

/**
Expand Down Expand Up @@ -200,8 +200,8 @@ const VolumeRow = ({ columns, volume, options, isLoading, onEdit, onDelete }) =>
};

const Details = ({ volume, options }) => {
const hasSnapshots = volume.fsType === "Btrfs" && volume.snapshots;
const transactional = volume.fsType === "Btrfs" && volume.transactional;
const snapshots = hasSnapshots(volume);
const transactional = isTransactionalRoot(volume);

// TRANSLATORS: the filesystem uses a logical volume (LVM)
const text = `${volume.fsType} ${options.lvm ? _("logical volume") : _("partition")}`;
Expand All @@ -215,7 +215,7 @@ const VolumeRow = ({ columns, volume, options, isLoading, onEdit, onDelete }) =>
{/* TRANSLATORS: filesystem flag, it uses an encryption */}
<If condition={options.encryption} then={<Em icon={lockIcon}>{_("encrypted")}</Em>} />
{/* TRANSLATORS: filesystem flag, it allows creating snapshots */}
<If condition={hasSnapshots} then={<Em icon={snapshotsIcon}>{_("with snapshots")}</Em>} />
<If condition={snapshots && !transactional} then={<Em icon={snapshotsIcon}>{_("with snapshots")}</Em>} />
{/* TRANSLATORS: flag for transactional file system */}
<If condition={transactional} then={<Em icon={transactionalIcon}>{_("transactional")}</Em>} />
</div>
Expand Down
22 changes: 4 additions & 18 deletions web/src/components/storage/ProposalVolumes.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2022-2023] SUSE LLC
* Copyright (c) [2022-2024] SUSE LLC
*
* All Rights Reserved.
*
Expand Down Expand Up @@ -217,9 +217,9 @@ describe("if there are volumes", () => {
within(popup).getByText("Edit file system");
});

describe("and there is transactional Btrfs volume", () => {
describe("and there is transactional Btrfs root volume", () => {
beforeEach(() => {
props.volumes = [{ ...volumes.root, transactional: true }];
props.volumes = [{ ...volumes.root, snapshots: true, transactional: true }];
});

it("renders 'transactional' legend as part of its information", async () => {
Expand All @@ -233,7 +233,7 @@ describe("if there are volumes", () => {

describe("and there is Btrfs volume using snapshots", () => {
beforeEach(() => {
props.volumes = [{ ...volumes.root, snapshots: true }];
props.volumes = [{ ...volumes.root, snapshots: true, transactional: false }];
});

it("renders 'with snapshots' legend as part of its information", async () => {
Expand All @@ -244,20 +244,6 @@ describe("if there are volumes", () => {
within(volumes).getByRole("row", { name: "/ Btrfs partition with snapshots 1 KiB - 2 KiB" });
});
});

describe("and there is a transactional Btrfs volume using snapshots", () => {
beforeEach(() => {
props.volumes = [{ ...volumes.root, transactional: true, snapshots: true }];
});

it("renders 'with snapshots' and 'transactional' legends as part of its information", async () => {
plainRender(<ProposalVolumes {...props} />);

const [, volumes] = await screen.findAllByRole("rowgroup");

within(volumes).getByRole("row", { name: "/ Btrfs partition with snapshots transactional 1 KiB - 2 KiB" });
});
});
});

describe("if there are not volumes", () => {
Expand Down
46 changes: 42 additions & 4 deletions web/src/components/storage/utils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2023] SUSE LLC
* Copyright (c) [2023-2024] SUSE LLC
*
* All Rights Reserved.
*
Expand All @@ -25,6 +25,11 @@ import xbytes from "xbytes";

import { N_ } from "~/i18n";

/**
* @typedef {import ("~/client/storage").Volume} Volume
* @typedef {import ("~/clients/storage").StorageDevice} StorageDevice
*/

/**
* @typedef {Object} SizeObject
*
Expand Down Expand Up @@ -125,7 +130,7 @@ const parseToBytes = (size) => {
/**
* Generates the label for the given device
*
* @param {import(~/clients/storage).StorageDevice} device
* @param {StorageDevice} device
* @returns {string}
*/
const deviceLabel = (device) => {
Expand All @@ -139,7 +144,7 @@ const deviceLabel = (device) => {
* Checks if volume uses given fs. This method works same as in backend
* case insensitive.
*
* @param {import(~/clients/storage).Volume} volume
* @param {Volume} volume
* @param {string} fs - Filesystem name to check.
* @returns {boolean} true when volume uses given fs
*/
Expand All @@ -149,6 +154,36 @@ const hasFS = (volume, fs) => {
return volFS.toLowerCase() === fs.toLocaleLowerCase();
};

/**
* Checks whether the given volume has snapshots.
*
* @param {Volume} volume
* @returns {boolean}
*/
const hasSnapshots = (volume) => {
return hasFS(volume, "btrfs") && volume.snapshots;
};

/**
* Checks whether the given volume defines a transactional root.
*
* @param {Volume} volume
* @returns {boolean}
*/
const isTransactionalRoot = (volume) => {
return volume.mountPath === "/" && volume.transactional;
};

/**
* Checks whether the given volumes defines a transactional system.
*
* @param {Volume[]} volumes
* @returns {boolean}
*/
const isTransactionalSystem = (volumes) => {
return volumes.find(v => isTransactionalRoot(v)) !== undefined;
};

export {
DEFAULT_SIZE_UNIT,
SIZE_METHODS,
Expand All @@ -157,5 +192,8 @@ export {
deviceSize,
parseToBytes,
splitSize,
hasFS
hasFS,
hasSnapshots,
isTransactionalRoot,
isTransactionalSystem
};
61 changes: 59 additions & 2 deletions web/src/components/storage/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) [2023] SUSE LLC
* Copyright (c) [2023-2024] SUSE LLC
*
* All Rights Reserved.
*
Expand All @@ -19,7 +19,16 @@
* find current contact information at www.suse.com.
*/

import { deviceSize, deviceLabel, parseToBytes, splitSize, hasFS } from "./utils";
import {
deviceSize,
deviceLabel,
parseToBytes,
splitSize,
hasFS,
hasSnapshots,
isTransactionalRoot,
isTransactionalSystem
} from "./utils";

describe("deviceSize", () => {
it("returns the size with units", () => {
Expand Down Expand Up @@ -100,3 +109,51 @@ describe("hasFS", () => {
expect(hasFS({ fsType: "Btrfs" }, "EXT4")).toBe(false);
});
});

describe("hasSnapshots", () => {
it("returns false if the volume has not Btrfs file system", () => {
expect(hasSnapshots({ fsType: "EXT4", snapshots: true })).toBe(false);
});

it("returns false if the volume has not snapshots enabled", () => {
expect(hasSnapshots({ fsType: "Btrfs", snapshots: false })).toBe(false);
});

it("returns true if the volume has Btrfs file system and snapshots enabled", () => {
expect(hasSnapshots({ fsType: "Btrfs", snapshots: true })).toBe(true);
});
});

describe("isTransactionalRoot", () => {
it("returns false if the volume is not root", () => {
expect(isTransactionalRoot({ mountPath: "/home", transactional: true })).toBe(false);
});

it("returns false if the volume has not transactional enabled", () => {
expect(isTransactionalRoot({ mountPath: "/", transactional: false })).toBe(false);
});

it("returns true if the volume is root and has transactional enabled", () => {
expect(isTransactionalRoot({ mountPath: "/", transactional: true })).toBe(true);
});
});

describe("isTransactionalSystem", () => {
it("returns false if volumes does not include a transactional root", () => {
expect(isTransactionalSystem([])).toBe(false);

const volumes = [
{ mountPath: "/" },
{ mountPath: "/home", transactional: true }
];
expect(isTransactionalSystem(volumes)).toBe(false);
});

it("returns true if volumes includes a transactional root", () => {
const volumes = [
{ mountPath: "EXT4" },
{ mountPath: "/", transactional: true }
];
expect(isTransactionalSystem(volumes)).toBe(true);
});
});

0 comments on commit a4d77c3

Please sign in to comment.