Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
<arg name="serialized_config" direction="in" type="s"/>
<arg name="result" direction="out" type="u"/>
</method>
<method name="ResetConfig">
<arg name="result" direction="out" type="u"/>
</method>
<method name="GetConfig">
<arg name="serialized_config" direction="out" type="s"/>
</method>
Expand Down
11 changes: 11 additions & 0 deletions doc/dbus/org.opensuse.Agama.Storage1.doc.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@
-->
<arg name="result" direction="out" type="u"/>
</method>
<!--
Resets the default storage config defined by the selected product.
-->
<method name="ResetConfig">
<!--
Whether the proposal was correctly calculated:
0: success
1: failure
-->
<arg name="result" direction="out" type="u"/>
</method>
<!--
Gets the unsolved storage config.
-->
Expand Down
5 changes: 5 additions & 0 deletions rust/agama-lib/src/storage/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ impl<'a> StorageClient<'a> {
.await?)
}

/// Reset the storage config to the default value
pub async fn reset_config(&self) -> Result<u32, ServiceError> {
Ok(self.storage_proxy.reset_config().await?)
}

/// Get the storage config according to the JSON schema
pub async fn get_config(&self) -> Result<StorageSettings, ServiceError> {
let serialized_settings = self.storage_proxy.get_config().await?;
Expand Down
3 changes: 3 additions & 0 deletions rust/agama-lib/src/storage/proxies/storage1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ pub trait Storage1 {
/// Set the storage config according to the JSON schema
fn set_config(&self, settings: &str) -> zbus::Result<u32>;

/// Reset the storage config to the default value
fn reset_config(&self) -> zbus::Result<u32>;

/// Get the current storage config according to the JSON schema
fn get_config(&self) -> zbus::Result<String>;

Expand Down
19 changes: 19 additions & 0 deletions rust/agama-server/src/storage/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ pub async fn storage_service(dbus: zbus::Connection) -> Result<Router, ServiceEr
let state = StorageState { client };
let router = Router::new()
.route("/config", put(set_config).get(get_config))
.route("/config/reset", put(reset_config))
.route("/config_model", put(set_config_model).get(get_config_model))
.route("/config_model/solve", get(solve_config_model))
.route("/probe", post(probe))
Expand Down Expand Up @@ -209,6 +210,24 @@ async fn get_config_model(
Ok(Json(config_model))
}

/// Resets the storage config to the default value.
///
/// * `state`: service state.
#[utoipa::path(
put,
path = "/config/reset",
context_path = "/api/storage",
operation_id = "reset_storage_config",
responses(
(status = 200, description = "Reset the storage configuration"),
(status = 400, description = "The D-Bus service could not perform the action")
)
)]
async fn reset_config(State(state): State<StorageState<'_>>) -> Result<Json<()>, Error> {
let _status: u32 = state.client.reset_config().await.map_err(Error::Service)?;
Ok(Json(()))
}

/// Sets the storage config model.
///
/// * `state`: service state.
Expand Down
18 changes: 17 additions & 1 deletion service/lib/agama/dbus/storage/manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ module Agama
module DBus
module Storage
# D-Bus object to manage storage installation
class Manager < BaseObject
class Manager < BaseObject # rubocop:disable Metrics/ClassLength
include WithISCSIAuth
include WithServiceStatus
include ::DBus::ObjectManager
Expand Down Expand Up @@ -119,6 +119,15 @@ def apply_config(serialized_config)
proposal.success? ? 0 : 1
end

# Calculates the initial proposal.
#
# @return [Integer] 0 success; 1 error
def reset_config
logger.info("Reset storage config from D-Bus")
backend.calculate_proposal
backend.proposal.success? ? 0 : 1
end

# Gets and serializes the storage config used for calculating the current proposal.
#
# @return [String]
Expand Down Expand Up @@ -174,12 +183,19 @@ def deprecated_system
backend.deprecated_system?
end

# FIXME: Revisit return values.
# * Methods like #SetConfig or #ResetConfig return whether the proposal successes, but
# they should return whether the config was actually applied.
# * Methods like #Probe or #Install return nothing.
dbus_interface STORAGE_INTERFACE do
dbus_method(:Probe) { probe }
dbus_method(:Reprobe) { probe(keep_config: true) }
dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config|
busy_while { apply_config(serialized_config) }
end
dbus_method(:ResetConfig, "out result:u") do
busy_while { reset_config }
end
dbus_method(:GetConfig, "out serialized_config:s") { recover_config }
dbus_method(:SetConfigModel, "in serialized_model:s, out result:u") do |serialized_model|
busy_while { apply_config_model(serialized_model) }
Expand Down
20 changes: 10 additions & 10 deletions service/lib/agama/storage/manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ def finish
Finisher.new(logger, product_config, security).run
end

# Calculates the proposal.
#
# @param keep_config [Boolean] Whether to use the current storage config for calculating the
# proposal. If false, then the default config from the product is used.
def calculate_proposal(keep_config: false)
config_json = proposal.storage_json if keep_config
config_json ||= ConfigJSONReader.new(product_config).read
proposal.calculate_from_json(config_json)
end

# Storage proposal manager
#
# @return [Storage::Proposal]
Expand Down Expand Up @@ -225,16 +235,6 @@ def probe_devices
Y2Storage::StorageManager.instance.probe(callbacks)
end

# Calculates the proposal.
#
# @param keep_config [Boolean] Whether to use the current storage config for calculating the
# proposal. If false, then the default config from the product is used.
def calculate_proposal(keep_config: false)
config_json = proposal.storage_json if keep_config
config_json ||= ConfigJSONReader.new(product_config).read
proposal.calculate_from_json(config_json)
end

# Adds the required packages to the list of resolvables to install
def add_packages
devicegraph = Y2Storage::StorageManager.instance.staging
Expand Down
1 change: 1 addition & 0 deletions service/test/agama/dbus/storage/manager_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def serialize(value)
iscsi: iscsi,
software: software,
product_config: product_config,
calculate_proposal: nil,
on_probe: nil,
on_progress_change: nil,
on_progress_finish: nil,
Expand Down
3 changes: 3 additions & 0 deletions web/src/api/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const fetchConfigModel = (): Promise<configModel.Config | undefined> =>

const setConfig = (config: config.Config) => put("/api/storage/config", { storage: config });

const resetConfig = () => put("/api/storage/config/reset", {});

const setConfigModel = (model: configModel.Config) => put("/api/storage/config_model", model);

const solveConfigModel = (model: configModel.Config): Promise<configModel.Config> => {
Expand Down Expand Up @@ -79,6 +81,7 @@ export {
fetchConfig,
fetchConfigModel,
setConfig,
resetConfig,
setConfigModel,
solveConfigModel,
fetchUsableDevices,
Expand Down
136 changes: 136 additions & 0 deletions web/src/components/storage/ConfigEditorMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright (c) [2025] SUSE LLC
*
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation; either version 2 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 General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, contact SUSE LLC.
*
* To contact SUSE LLC about this file by physical or electronic mail, you may
* find current contact information at www.suse.com.
*/

import React from "react";
import { screen, within } from "@testing-library/react";
import { plainRender, mockNavigateFn } from "~/test-utils";
import ConfigEditorMenu from "./ConfigEditorMenu";
import { STORAGE as PATHS } from "~/routes/paths";

const mockUseZFCPSupported = jest.fn();
jest.mock("~/queries/storage/zfcp", () => ({
...jest.requireActual("~/queries/storage/zfcp"),
useZFCPSupported: () => mockUseZFCPSupported(),
}));

const mockUseDASDSupported = jest.fn();
jest.mock("~/queries/storage/dasd", () => ({
...jest.requireActual("~/queries/storage/dasd"),
useDASDSupported: () => mockUseDASDSupported(),
}));

const mockUseResetConfigMutation = jest.fn();
jest.mock("~/queries/storage", () => ({
...jest.requireActual("~/queries/storage"),
useResetConfigMutation: () => mockUseResetConfigMutation(),
}));

const mockReset = jest.fn();
beforeEach(() => {
mockUseZFCPSupported.mockReturnValue(false);
mockUseDASDSupported.mockReturnValue(false);
mockUseResetConfigMutation.mockReturnValue({ mutate: mockReset });
});

async function openMenu() {
const { user } = plainRender(<ConfigEditorMenu />);
const button = screen.getByRole("button", { name: "More options toggle" });
await user.click(button);
const menu = screen.getByRole("menu");
return { user, menu };
}

it("renders the menu", () => {
plainRender(<ConfigEditorMenu />);
expect(screen.queryByText("More options")).toBeInTheDocument();
});

it("allows users to change the boot options", async () => {
const { user, menu } = await openMenu();
const bootItem = within(menu).getByRole("menuitem", { name: /boot options/ });
await user.click(bootItem);
expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.bootDevice);
});

it("allows users to reset the config", async () => {
const { user, menu } = await openMenu();
const resetItem = within(menu).getByRole("menuitem", { name: /Reset to/ });
await user.click(resetItem);
expect(mockReset).toHaveBeenCalled();
});

it("allows users to configure iSCSI", async () => {
const { user, menu } = await openMenu();
const iscsiItem = within(menu).getByRole("menuitem", { name: /Configure iSCSI/ });
await user.click(iscsiItem);
expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.iscsi);
});

describe("if zFCP is not supported", () => {
beforeEach(() => {
mockUseZFCPSupported.mockReturnValue(false);
});

it("does not allow users to configure zFCP", async () => {
const { menu } = await openMenu();
const zfcpItem = within(menu).queryByRole("menuitem", { name: /Configure zFCP/ });
expect(zfcpItem).not.toBeInTheDocument();
});
});

describe("if zFCP is supported", () => {
beforeEach(() => {
mockUseZFCPSupported.mockReturnValue(true);
});

it("allows users to configure zFCP", async () => {
const { user, menu } = await openMenu();
const zfcpItem = within(menu).getByRole("menuitem", { name: /Configure zFCP/ });
await user.click(zfcpItem);
expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.zfcp.root);
});
});

describe("if DASD is not supported", () => {
beforeEach(() => {
mockUseDASDSupported.mockReturnValue(false);
});

it("does not allow users to configure DASD", async () => {
const { menu } = await openMenu();
const dasdItem = within(menu).queryByRole("menuitem", { name: /Configure DASD/ });
expect(dasdItem).not.toBeInTheDocument();
});
});

describe("if DASD is supported", () => {
beforeEach(() => {
mockUseDASDSupported.mockReturnValue(true);
});

it("allows users to configure DASD", async () => {
const { user, menu } = await openMenu();
const dasdItem = within(menu).getByRole("menuitem", { name: /Configure DASD/ });
await user.click(dasdItem);
expect(mockNavigateFn).toHaveBeenCalledWith(PATHS.dasd);
});
});
Loading