diff --git a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml index aabc7084ba..f5c91d7edb 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml @@ -52,8 +52,8 @@ - - + + diff --git a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml index f7f0272a34..a848f9f8c0 100644 --- a/doc/dbus/org.opensuse.Agama.Storage1.doc.xml +++ b/doc/dbus/org.opensuse.Agama.Storage1.doc.xml @@ -56,34 +56,35 @@ - + - + diff --git a/rust/agama-lib/share/examples/storage/model.json b/rust/agama-lib/share/examples/storage/model.json new file mode 100644 index 0000000000..a90734569a --- /dev/null +++ b/rust/agama-lib/share/examples/storage/model.json @@ -0,0 +1,69 @@ +{ + "drives": [ + { + "name": "/dev/vda", + "alias": "root", + "mountPath": "/", + "filesystem": { + "default": true, + "type": "btrfs", + "snapshots": true + }, + "spacePolicy": "delete" + }, + { + "name": "/dev/vdb", + "spacePolicy": "custom", + "ptableType": "gpt", + "partitions": [ + { + "name": "/dev/vda1", + "size": { + "default": false, + "min": 0, + "max": 1234567 + }, + "deleteIfNeeded": true, + "resizeIfNeeded": true + }, + { + "name": "/dev/vda2", + "size": { + "default": false, + "min": 1234567, + "max": 1234567 + }, + "resize": true + }, + { + "name": "/dev/vdb3", + "delete": true + }, + { + "size": { + "default": true, + "min": 4444, + "max": 8888 + }, + "mountPath": "swap", + "filesystem": { + "default": true, + "type": "swap" + } + }, + { + "size": { + "default": false, + "min": 100000, + "max": 100000 + }, + "mountPath": "/home", + "filesystem": { + "default": false, + "type": "xfs" + } + } + ] + } + ] +} diff --git a/rust/agama-lib/share/storage.model.schema.json b/rust/agama-lib/share/storage.model.schema.json new file mode 100644 index 0000000000..e069a3fb70 --- /dev/null +++ b/rust/agama-lib/share/storage.model.schema.json @@ -0,0 +1,96 @@ +{ + "title": "Config", + "description": "Config model", + "type": "object", + "additionalProperties": false, + "properties": { + "drives": { + "type": "array", + "items": { "$ref": "#/$defs/drive" } + } + }, + "$defs": { + "drive": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "alias": { "type": "string" }, + "mountPath": { "type": "string" }, + "filesystem": { "$ref": "#/$defs/filesystem" }, + "spacePolicy": { "$ref": "#/$defs/spacePolicy" }, + "ptableType": { "$ref": "#/$defs/ptableType" }, + "partitions": { + "type": "array", + "items": { "$ref": "#/$defs/partition" } + } + } + }, + "partition": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "alias": { "type": "string" }, + "id": { "$ref": "#/$defs/partitionId" }, + "mountPath": { "type": "string" }, + "filesystem": { "$ref": "#/$defs/filesystem" }, + "size": { "$ref": "#/$defs/size" }, + "delete": { "type": "boolean" }, + "deleteIfNeeded": { "type": "boolean" }, + "resize": { "type": "boolean" }, + "resizeIfNeeded": { "type": "boolean" } + } + }, + "spacePolicy": { + "enum": ["delete", "resize", "keep", "custom"] + }, + "ptableType": { + "enum": ["gpt", "msdos", "dasd"] + }, + "partitionId": { + "enum": ["linux", "swap", "lvm", "raid", "esp", "prep", "bios_boot"] + }, + "filesystem": { + "type": "object", + "additionalProperties": false, + "required": ["default"], + "properties": { + "default": { "type": "boolean" }, + "type": { "$ref": "#/$defs/filesystemType" }, + "snapshots": { "type": "boolean" } + } + }, + "filesystemType": { + "enum": [ + "bcachefs", + "btrfs", + "exfat", + "ext2", + "ext3", + "ext4", + "f2fs", + "jfs", + "nfs", + "nilfs2", + "ntfs", + "reiserfs", + "swap", + "tmpfs", + "vfat", + "xfs" + ] + }, + "size": { + "type": "object", + "additionalProperties": false, + "required": ["default", "min"], + "properties": { + "default": { "type": "boolean" }, + "min": { "type": "integer", "minimum": 0 }, + "max": { "type": "integer", "minimum": 0 } + } + } + } +} diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 4125188233..d145d76309 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -29,6 +29,7 @@ use super::proxies::{DevicesProxy, ProposalCalculatorProxy, ProposalProxy, Stora use super::StorageSettings; use crate::dbus::get_property; use crate::error::ServiceError; +use serde_json::value::RawValue; use std::collections::HashMap; use zbus::fdo::ObjectManagerProxy; use zbus::names::{InterfaceName, OwnedInterfaceName}; @@ -153,11 +154,11 @@ impl<'a> StorageClient<'a> { Ok(settings) } - /// Get the storage solved config according to the JSON schema - pub async fn get_solved_config(&self) -> Result { - let serialized_settings = self.storage_proxy.get_solved_config().await?; - let settings = serde_json::from_str(serialized_settings.as_str()).unwrap(); - Ok(settings) + /// Get the storage config model according to the JSON schema + pub async fn get_config_model(&self) -> Result, ServiceError> { + let serialized_config_model = self.storage_proxy.get_config_model().await?; + let config_model = serde_json::from_str(serialized_config_model.as_str()).unwrap(); + Ok(config_model) } pub async fn calculate(&self, settings: ProposalSettingsPatch) -> Result { diff --git a/rust/agama-lib/src/storage/proxies.rs b/rust/agama-lib/src/storage/proxies.rs index f83ee7af09..cde0e5491e 100644 --- a/rust/agama-lib/src/storage/proxies.rs +++ b/rust/agama-lib/src/storage/proxies.rs @@ -44,8 +44,8 @@ trait Storage1 { /// Get the current storage config according to the JSON schema fn get_config(&self) -> zbus::Result; - /// Get the current storage solved config according to the JSON schema - fn get_solved_config(&self) -> zbus::Result; + /// Get the storage config model according to the JSON schema + fn get_config_model(&self) -> zbus::Result; /// DeprecatedSystem property #[dbus_proxy(property)] diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index bfb909e055..259e82acb0 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -39,6 +39,7 @@ use axum::{ Json, Router, }; use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use tokio_stream::{Stream, StreamExt}; use zfcp::{zfcp_service, zfcp_stream}; @@ -113,7 +114,7 @@ pub async fn storage_service(dbus: zbus::Connection) -> Result>) -> Result), (status = 400, description = "The D-Bus service could not perform the action") ) )] -async fn get_solved_config( +async fn get_config_model( State(state): State>, -) -> Result, Error> { - // StorageSettings is just a wrapper over serde_json::value::RawValue - let settings = state +) -> Result>, Error> { + let config_model = state .client - .get_solved_config() + .get_config_model() .await .map_err(Error::Service)?; - Ok(Json(settings)) + Ok(Json(config_model)) } /// Sets the storage configuration. diff --git a/service/lib/agama/config.rb b/service/lib/agama/config.rb index 67b0999908..8c51602c4f 100644 --- a/service/lib/agama/config.rb +++ b/service/lib/agama/config.rb @@ -203,6 +203,20 @@ def mandatory_paths default_paths.select { |p| mandatory_path?(p) } end + # Default policy to make space. + # + # @return [String] + def space_policy + data.dig("storage", "space_policy") || "keep" + end + + # Whether LVM must be used by default. + # + # @return [Boolean] + def lvm? + data.dig("storage", "lvm") || false + end + private def mandatory_path?(path) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 3194ed5157..c6742718eb 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -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 @@ -111,12 +111,19 @@ def apply_config(serialized_config) proposal.success? ? 0 : 1 end - # Gets and serializes the storage config used to calculate the current proposal. + # Gets and serializes the storage config used for calculating the current proposal. # - # @param solved [Boolean] Whether to recover the solved config. - # @return [String] Serialized config according to the JSON schema. - def recover_config(solved: false) - json = proposal.storage_json(solved: solved) + # @return [String] + def recover_config + json = proposal.storage_json + JSON.pretty_generate(json) + end + + # Gets and serializes the storage config model. + # + # @return [String] + def recover_model + json = proposal.model_json JSON.pretty_generate(json) end @@ -141,7 +148,7 @@ def deprecated_system busy_while { apply_config(serialized_config) } end dbus_method(:GetConfig, "out serialized_config:s") { recover_config } - dbus_method(:GetSolvedConfig, "out serialized_config:s") { recover_config(solved: true) } + dbus_method(:GetConfigModel, "out serialized_model:s") { recover_model } dbus_method(:Install) { install } dbus_method(:Finish) { finish } dbus_reader(:deprecated_system, "b") @@ -458,7 +465,7 @@ def tree_path(tree_root) # @return [Agama::Config] def config - backend.config + backend.product_config end # @return [Agama::VolumeTemplatesBuilder] diff --git a/service/lib/agama/storage/config_conversions.rb b/service/lib/agama/storage/config_conversions.rb index e8f49d38f5..1bcc863703 100644 --- a/service/lib/agama/storage/config_conversions.rb +++ b/service/lib/agama/storage/config_conversions.rb @@ -21,6 +21,7 @@ require "agama/storage/config_conversions/from_json" require "agama/storage/config_conversions/to_json" +require "agama/storage/config_conversions/to_model" module Agama module Storage diff --git a/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb b/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb index 14e032d280..995604a9bc 100644 --- a/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb +++ b/service/lib/agama/storage/config_conversions/from_json_conversions/filesystem_type.rb @@ -45,6 +45,7 @@ def convert # @return [Hash] def conversions { + default: false, fs_type: convert_type, btrfs: convert_btrfs } diff --git a/service/lib/agama/storage/config_conversions/to_model.rb b/service/lib/agama/storage/config_conversions/to_model.rb new file mode 100644 index 0000000000..c59ad9f12d --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/config" + +module Agama + module Storage + module ConfigConversions + # Config conversion to model according to the JSON schema. + class ToModel + # @param config [Storage::Config] + def initialize(config) + @config = config + end + + # Performs the conversion to config model according to the JSON schema. + # + # @return [Hash] + def convert + ToModelConversions::Config.new(config).convert + end + + private + + # @return [Storage::Config] + attr_reader :config + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions.rb b/service/lib/agama/storage/config_conversions/to_model_conversions.rb new file mode 100644 index 0000000000..87dede1eda --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/config" +require "agama/storage/config_conversions/to_model_conversions/drive" +require "agama/storage/config_conversions/to_model_conversions/filesystem" +require "agama/storage/config_conversions/to_model_conversions/partition" +require "agama/storage/config_conversions/to_model_conversions/size" +require "agama/storage/config_conversions/to_model_conversions/space_policy" + +module Agama + module Storage + module ConfigConversions + # Conversions to model according to the JSON schema. + module ToModelConversions + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/base.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/base.rb new file mode 100644 index 0000000000..e447dd1e11 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/base.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Base class for conversions to model according to the JSON schema. + class Base + # Defines the expected config type to perform the conversion. + # + # @raise If a subclass does not defines a type. + # @return [Class] + def self.config_type + raise "Undefined config type" + end + + # @param config [Object] The config type is provided by the {.config_type} method. + def initialize(config) + type = self.class.config_type + raise "Invalid config (#{type} expected): #{config}" unless config.is_a?(type) + + @config = config + end + + # Performs the conversion to model according to the JSON schema. + # + # @return [Hash, nil] + def convert + model_json = {} + + conversions.each do |property, value| + next if value.nil? + + model_json[property] = value + end + + model_json.empty? ? nil : model_json + end + + private + + # @return [Object] The config type is provided by the {.config_type} method. + attr_reader :config + + # Values to generate the model. + # + # @return [Hash] e.g., { name: "/dev/vda" }. + def conversions + {} + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb new file mode 100644 index 0000000000..94b65aeeab --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/config.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config" +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/drive" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Config conversion to model according to the JSON schema. + class Config < Base + # @see Base + def self.config_type + Storage::Config + end + + private + + # @see Base#conversions + def conversions + { drives: convert_drives } + end + + # @return [Array] + def convert_drives + valid_drives.map { |d| ToModelConversions::Drive.new(d).convert } + end + + # @return [Array] + def valid_drives + config.drives.select(&:found_device) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb new file mode 100644 index 0000000000..8bd54b4e62 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/drive.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/with_filesystem" +require "agama/storage/config_conversions/to_model_conversions/with_partitions" +require "agama/storage/config_conversions/to_model_conversions/with_space_policy" +require "agama/storage/configs/drive" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Drive conversion to model according to the JSON schema. + class Drive < Base + include WithFilesystem + include WithPartitions + include WithSpacePolicy + + # @see Base + def self.config_type + Configs::Drive + end + + private + + # @see Base#conversions + def conversions + { + name: config.found_device&.name, + alias: config.alias, + mountPath: config.filesystem&.path, + filesystem: convert_filesystem, + spacePolicy: convert_space_policy, + ptableType: config.ptable_type&.to_s, + partitions: convert_partitions + } + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb new file mode 100644 index 0000000000..c8b6b04f24 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/filesystem.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/configs/filesystem" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Drive conversion to model according to the JSON schema. + class Filesystem < Base + # @see Base + def self.config_type + Configs::Filesystem + end + + private + + # @see Base#conversions + def conversions + { + default: convert_default, + type: convert_type, + snapshots: convert_snapshots + } + end + + # @return [Boolean, nil] + def convert_default + return unless config.type + + config.type.default? + end + + # @return [String, nil] + def convert_type + return unless config.type&.fs_type + + config.type.fs_type.to_s + end + + # @return [Boolean, nil] + def convert_snapshots + return unless config.type&.fs_type&.is?(:btrfs) + + config.type.btrfs&.snapshots? + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb new file mode 100644 index 0000000000..f8ac908ba0 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/partition.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/config_conversions/to_model_conversions/with_filesystem" +require "agama/storage/config_conversions/to_model_conversions/with_size" +require "agama/storage/configs/partition" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Partition conversion to model according to the JSON schema. + class Partition < Base + include WithFilesystem + include WithSize + + # @see Base + def self.config_type + Configs::Partition + end + + private + + # @see Base#conversions + def conversions + { + name: config.found_device&.name, + alias: config.alias, + id: config.id&.to_s, + mountPath: config.filesystem&.path, + filesystem: convert_filesystem, + size: convert_size, + delete: config.delete?, + deleteIfNeeded: config.delete_if_needed?, + resize: convert_resize, + resizeIfNeeded: convert_resize_if_needed + } + end + + # @return [Booelan] + def convert_resize + size = config.size + + !size.nil? && !size.default? && size.min == size.max + end + + # @return [Booelan] + def convert_resize_if_needed + size = config.size + + !size.nil? && !size.default? && size.min != size.max + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb new file mode 100644 index 0000000000..23e8217f73 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/size.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/base" +require "agama/storage/configs/size" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Size conversion to model according to the JSON schema. + class Size < Base + # @see Base + def self.config_type + Configs::Size + end + + private + + # @see Base#conversions + def conversions + { + default: config.default?, + min: config.min&.to_i, + max: convert_max_size + } + end + + # @return [Integer, nil] + def convert_max_size + max = config.max + return if max.nil? || max.unlimited? + + max.to_i + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb new file mode 100644 index 0000000000..6b028b7614 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/space_policy.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Space policy conversion to model according to the JSON schema. + class SpacePolicy + # TODO: make it work with volume groups and raids too. + # + # @param config [Configs::Drive] + def initialize(config) + @config = config + end + + # @return [String] + def convert + return "delete" if config.filesystem || delete_all_partition? + return "resize" if shrink_all_partition? + return "custom" if delete_partition? || resize_partition? + + "keep" + end + + private + + # @return [Configs::Drive] + attr_reader :config + + # @return [Boolean] + def delete_all_partition? + config.partitions.any? { |p| delete_all?(p) } + end + + # @return [Boolean] + def shrink_all_partition? + config.partitions.any? { |p| shrink_all?(p) } + end + + # @return [Boolean] + def delete_partition? + config.partitions + .select(&:found_device) + .any? { |p| p.delete? || p.delete_if_needed? } + end + + # @return [Boolean] + def resize_partition? + config.partitions + .select(&:found_device) + .any? { |p| !p.size.default? } + end + + # @param partition_config [Configs::Partition] + # @return [Boolean] + def delete_all?(partition_config) + search_all?(partition_config) && partition_config.delete? + end + + # @param partition_config [Configs::Partition] + # @return [Boolean] + def shrink_all?(partition_config) + search_all?(partition_config) && + !partition_config.size.nil? && + !partition_config.size.min.nil? && + partition_config.size.min.to_i == 0 + end + + # @param partition_config [Configs::Partition] + # @return [Boolean] + def search_all?(partition_config) + !partition_config.search.nil? && + partition_config.search.always_match? && + partition_config.search.max.nil? + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_filesystem.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_filesystem.rb new file mode 100644 index 0000000000..903d6609d5 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_filesystem.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/filesystem" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Mixin for filesystem conversion to model according to the JSON schema. + module WithFilesystem + # @return [Hash, nil] + def convert_filesystem + filesystem = config.filesystem + return unless filesystem + + ToModelConversions::Filesystem.new(filesystem).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb new file mode 100644 index 0000000000..d13152bb1e --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_partitions.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/partition" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Mixin for partitions conversion to model according to the JSON schema. + module WithPartitions + # @return [Array] + def convert_partitions + valid_partitions + .map { |p| ToModelConversions::Partition.new(p).convert } + .compact + end + + # @return [Array] + def valid_partitions + config.partitions.select { |p| valid_partition?(p) } + end + + # @param partition_config [Configs::Partition] + # @return [Boolean] + def valid_partition?(partition_config) + valid_new_partition(partition_config) || valid_existing_partition(partition_config) + end + + # @param partition_config [Configs::Partition] + # @return [Boolean] + def valid_new_partition(partition_config) + delete = partition_config.delete? || partition_config.delete_if_needed? + return false if delete + + partition_config.search.nil? || partition_config.search.create_device? + end + + # @param partition_config [Configs::Partition] + # @return [Boolean] + def valid_existing_partition(partition_config) + !partition_config.found_device.nil? + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_size.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_size.rb new file mode 100644 index 0000000000..6844076b21 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_size.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/size" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Mixin for size conversion to model according to the JSON schema. + module WithSize + # @return [Hash, nil] + def convert_size + size = config.size + return unless size + + ToModelConversions::Size.new(size).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb b/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb new file mode 100644 index 0000000000..d87ae591c7 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/to_model_conversions/with_space_policy.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require "agama/storage/config_conversions/to_model_conversions/space_policy" + +module Agama + module Storage + module ConfigConversions + module ToModelConversions + # Mixin for space policy conversion to model according to the JSON schema. + module WithSpacePolicy + # @return [String, nil] + def convert_space_policy + return unless config.respond_to?(:partitions) + + ToModelConversions::SpacePolicy.new(config).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_reader.rb b/service/lib/agama/storage/config_json_reader.rb similarity index 58% rename from service/lib/agama/storage/config_reader.rb rename to service/lib/agama/storage/config_json_reader.rb index 64d05af17d..b17cb983d3 100644 --- a/service/lib/agama/storage/config_reader.rb +++ b/service/lib/agama/storage/config_json_reader.rb @@ -19,61 +19,30 @@ # To contact SUSE LLC about this file by physical or electronic mail, you may # find current contact information at www.suse.com. -require "agama/storage/config_conversions" - module Agama module Storage - # Reader for the initial storage config - class ConfigReader - # @param agama_config [Agama::Config] - def initialize(agama_config) - @agama_config = agama_config + # Reader for the initial JSON config. + class ConfigJSONReader + # @param product_config [Agama::Config] + def initialize(product_config) + @product_config = product_config end - # Generates a storage config from the Agama control file. + # Generates a JSON config from the product config. # - # @return [Storage::Config] + # @return [Hash] def read - ConfigConversions::FromJSON.new(json, default_paths: default_paths).convert + json = product_config.lvm? ? json_for_lvm : json_for_disk + + { storage: json } end private # @return [Agama::Config] - attr_reader :agama_config - - # Default filesystem paths from the Agama control file - # - # @return [Array] - def default_paths - @default_paths ||= agama_config.default_paths - end + attr_reader :product_config - # Default policy to make space from the Agama control file - # - # @return [String] - def space_policy - @space_policy ||= agama_config.data.dig("storage", "space_policy") - end - - # Whether the Agama control file specifies that LVM must be used by default - # - # @return [Boolean] - def lvm? - return @lvm unless @lvm.nil? - - @lvm = !!agama_config.data.dig("storage", "lvm") - end - - # JSON representation of the initial storage config - # - # @return [Hash] - def json - lvm? ? json_for_lvm : json_for_disk - end - - # @see #json - # + # @see #read # @return [Hash] def json_for_disk { @@ -83,17 +52,16 @@ def json_for_disk } end - # @see #json - # + # @see #read # @return [Hash] def json_for_lvm + partition = partition_for_existing + + drive = { alias: "target" } + drive[:partitions] = [partition] if partition + { - drives: [ - { - alias: "target", - partitions: [partition_for_existing].compact - } - ], + drives: [drive], volumeGroups: [ { name: "system", @@ -104,17 +72,18 @@ def json_for_lvm } end - # JSON piece to generate default filesystems as partitions or logical volumes + # JSON piece to generate default filesystems as partitions or logical volumes. # # @return [Hash] def volumes_generator { generate: "default" } end - # JSON piece to specify what to do with existing partitions + # JSON piece to specify what to do with existing partitions. # - # @return [Hash, nil] nil if no actions are to be performed + # @return [Hash, nil] nil if no actions are to be performed. def partition_for_existing + space_policy = product_config.space_policy return unless ["delete", "resize"].include?(space_policy) partition = { search: "*" } diff --git a/service/lib/agama/storage/config_size_solver.rb b/service/lib/agama/storage/config_size_solver.rb index bb8ffbc5b0..3785b96da3 100644 --- a/service/lib/agama/storage/config_size_solver.rb +++ b/service/lib/agama/storage/config_size_solver.rb @@ -80,12 +80,9 @@ def solve_default_device_size(config) # @param config [Configs::Partition, Configs::LogicalVolume] def solve_current_size(config) - min = config.size.min - max = config.size.max size = size_from_device(config.found_device) - size.min = min if min - size.max = max if max - config.size = size + config.size.min ||= size.min + config.size.max ||= size.max end # @param config [Configs::Partition, Configs::LogicalVolume] @@ -107,7 +104,6 @@ def size_from_product(config) # @return [Configs::Size] def size_from_device(device) Configs::Size.new.tap do |config| - config.default = false config.min = device.size config.max = device.size end diff --git a/service/lib/agama/storage/configs/filesystem_type.rb b/service/lib/agama/storage/configs/filesystem_type.rb index d9ef3cddf1..1b9cc04d19 100644 --- a/service/lib/agama/storage/configs/filesystem_type.rb +++ b/service/lib/agama/storage/configs/filesystem_type.rb @@ -22,12 +22,21 @@ module Agama module Storage module Configs + # Config for a filesystem type. class FilesystemType + # @return [Boolean] + attr_accessor :default + alias_method :default?, :default + # @return [Y2Storage::Filesystems::Type, nil] attr_accessor :fs_type # @return [Configs::Btrfs, nil] attr_accessor :btrfs + + def initialize + @default = true + end end end end diff --git a/service/lib/agama/storage/configs/size.rb b/service/lib/agama/storage/configs/size.rb index 63353fe46a..a84a774154 100644 --- a/service/lib/agama/storage/configs/size.rb +++ b/service/lib/agama/storage/configs/size.rb @@ -26,6 +26,7 @@ module Configs class Size # @return [Boolean] attr_accessor :default + alias_method :default?, :default # @return [Y2Storage::DiskSize, nil] attr_accessor :min @@ -37,11 +38,6 @@ class Size def initialize @default = true end - - # @return [Boolean] - def default? - @default - end end end end diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index be84d91fea..9689523d9e 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -29,7 +29,7 @@ require "agama/storage/callbacks" require "agama/storage/iscsi/manager" require "agama/storage/finisher" -require "agama/storage/config_reader" +require "agama/storage/config_json_reader" require "agama/issue" require "agama/with_locale" require "agama/with_issues" @@ -49,17 +49,17 @@ class Manager include WithProgress include Yast::I18n - # @return [Config] - attr_reader :config + # @return [Agama::Config] + attr_reader :product_config # Constructor # - # @param config [Config] + # @param product_config [Agama::Config] # @param logger [Logger] - def initialize(config, logger) + def initialize(product_config, logger) textdomain "agama" - @config = config + @product_config = product_config @logger = logger register_proposal_callbacks on_progress_change { logger.info progress.to_s } @@ -110,7 +110,7 @@ def on_probe(&block) # Probes storage devices and performs an initial proposal def probe start_progress_with_size(4) - config.pick_product(software.selected_product) + product_config.pick_product(software.selected_product) check_multipath progress.step(_("Activating storage devices")) { activate_devices } progress.step(_("Probing storage devices")) { probe_devices } @@ -139,14 +139,14 @@ def install # Performs the final steps on the target file system(s) def finish - Finisher.new(logger, config, security).run + Finisher.new(logger, product_config, security).run end # Storage proposal manager # # @return [Storage::Proposal] def proposal - @proposal ||= Proposal.new(config, logger: logger) + @proposal ||= Proposal.new(product_config, logger: logger) end # iSCSI manager @@ -214,10 +214,10 @@ def probe_devices self.deprecated_system = false end - # Calculates the proposal using the settings from the config file. + # Calculates the proposal using the storage config from the product. def calculate_proposal - settings = ConfigReader.new(config).read - proposal.calculate_agama(settings) + 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 @@ -297,7 +297,7 @@ def available_devices_issue # # @return [Security] def security - @security ||= Security.new(logger, config) + @security ||= Security.new(logger, product_config) end # Returns the client to ask questions diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 7f179692b3..74531aaf1e 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -23,6 +23,7 @@ require "agama/storage/actions_generator" require "agama/storage/config_conversions/from_json" require "agama/storage/config_conversions/to_json" +require "agama/storage/config_conversions/to_model" require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" require "json" @@ -71,13 +72,10 @@ def available_devices disk_analyzer&.candidate_disks || [] end - # Storage JSON config from the current proposal, if any. + # Storage config according to the JSON schema from the current proposal. # - # @param solved [Boolean] Whether to get the solved config. - # @return [Hash] JSON config according to the JSON schema. - def storage_json(solved: false) - return source_json if !solved && source_json - + # @return [Hash, nil] nil if there is no proposal yet. + def storage_json case strategy when ProposalStrategies::Guided { @@ -86,17 +84,24 @@ def storage_json(solved: false) } } when ProposalStrategies::Agama - config = config(solved: solved) - { - storage: ConfigConversions::ToJSON.new(config).convert - } + source_json || { storage: ConfigConversions::ToJSON.new(config).convert } when ProposalStrategies::Autoyast - strategy.settings - else - {} + source_json || { + legacyAutoyastStorage: JSON.parse(strategy.settings.to_json, symbolize_names: true) + } end end + # Config model according to the JSON schema. + # + # @return [Hash, nil] nil if there is no config. + def model_json + config = config(solved: true) + return unless config + + ConfigConversions::ToModel.new(config).convert + end + # Calculates a new proposal using the given JSON. # # @raise If the JSON is not valid. diff --git a/service/test/agama/dbus/storage/manager_test.rb b/service/test/agama/dbus/storage/manager_test.rb index 53f312caf3..7381474efb 100644 --- a/service/test/agama/dbus/storage/manager_test.rb +++ b/service/test/agama/dbus/storage/manager_test.rb @@ -49,7 +49,7 @@ proposal: proposal, iscsi: iscsi, software: software, - config: config, + product_config: product_config, on_probe: nil, on_progress_change: nil, on_progress_finish: nil, @@ -57,10 +57,10 @@ on_deprecated_system_change: nil) end - let(:config) { Agama::Config.new(config_data) } + let(:product_config) { Agama::Config.new(config_data) } let(:config_data) { {} } - let(:proposal) { Agama::Storage::Proposal.new(config) } + let(:proposal) { Agama::Storage::Proposal.new(product_config) } let(:iscsi) do instance_double(Agama::Storage::ISCSI::Manager, @@ -563,8 +563,8 @@ def serialize(value) end context "if a proposal has not been calculated" do - it "returns serialized empty storage config" do - expect(subject.recover_config).to eq(serialize({})) + it "returns 'null'" do + expect(subject.recover_config).to eq("null") end end @@ -583,37 +583,25 @@ def serialize(value) } end - context "and unsolved config is requested" do - let(:solved) { false } - - it "returns serialized unsolved guided storage config" do - expect(subject.recover_config(solved: solved)).to eq(serialize(settings_json)) - end - end - - context "and solved config is requested" do - let(:solved) { true } - - it "returns serialized solved guided storage config" do - expect(subject.recover_config(solved: solved)).to eq( - serialize({ - storage: { - guided: { - target: { - disk: "/dev/vda" - }, - boot: { - configure: true - }, - space: { - policy: "keep" - }, - volumes: [] - } + it "returns serialized solved guided storage config" do + expect(subject.recover_config).to eq( + serialize({ + storage: { + guided: { + target: { + disk: "/dev/vda" + }, + boot: { + configure: true + }, + space: { + policy: "keep" + }, + volumes: [] } - }) - ) - end + } + }) + ) end end @@ -638,52 +626,110 @@ def serialize(value) } end - context "and unsolved config is requested" do - let(:solved) { false } + it "returns serialized storage config" do + expect(subject.recover_config).to eq(serialize(config_json)) + end + end - it "returns serialized unsolved storage config" do - expect(subject.recover_config(solved: solved)).to eq(serialize(config_json)) - end + context "if an AutoYaST proposal has been calculated" do + before do + proposal.calculate_from_json(autoyast_json) + end + + let(:autoyast_json) do + { + legacyAutoyastStorage: [ + { device: "/dev/vda" } + ] + } end - context "and solved config is requested" do - let(:solved) { true } + it "returns the serialized AutoYaST config" do + expect(subject.recover_config).to eq(serialize(autoyast_json)) + end + end + end - it "returns serialized solved guided storage config" do - expect(subject.recover_config(solved: solved)).to eq( - serialize({ - storage: { - boot: { - configure: true - }, - drives: [ + describe "#recover_model" do + def serialize(value) + JSON.pretty_generate(value) + end + + context "if a proposal has not been calculated" do + it "returns 'null'" do + expect(subject.recover_model).to eq("null") + end + end + + context "if a guided proposal has been calculated" do + before do + proposal.calculate_from_json(settings_json) + end + + let(:settings_json) do + { + storage: { + guided: { + target: { disk: "/dev/vda" } + } + } + } + end + + it "returns 'null'" do + expect(subject.recover_model).to eq("null") + end + end + + context "if an agama proposal has been calculated" do + before do + proposal.calculate_from_json(config_json) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [ { - search: { - condition: { name: "/dev/sda" }, - ifNotFound: "error", - max: 1 + filesystem: { path: "/" } + } + ] + } + ] + } + } + end + + it "returns the serialized config model" do + expect(subject.recover_model).to eq( + serialize({ + drives: [ + { + name: "/dev/sda", + spacePolicy: "keep", + partitions: [ + { + mountPath: "/", + filesystem: { + default: true, + type: "ext4" }, - partitions: [ - { - filesystem: { - reuseIfPossible: false, - path: "/", - mountOptions: [], - mkfsOptions: [], - type: "ext4" - }, - size: { - min: 0 - } - } - ] + size: { + default: true, + min: 0 + }, + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false } - ], - volumeGroups: [] + ] } - }) - ) - end + ] + }) + ) end end @@ -700,8 +746,8 @@ def serialize(value) } end - it "returns the serialized AutoYaST config" do - expect(subject.recover_config).to eq(serialize(autoyast_json)) + it "returns 'null'" do + expect(subject.recover_model).to eq("null") end end end diff --git a/service/test/agama/storage/config_conversions/from_json_test.rb b/service/test/agama/storage/config_conversions/from_json_test.rb index 2f46c8e134..62aa519836 100644 --- a/service/test/agama/storage/config_conversions/from_json_test.rb +++ b/service/test/agama/storage/config_conversions/from_json_test.rb @@ -273,6 +273,7 @@ filesystem = config.filesystem expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) expect(filesystem.reuse?).to eq(true) + expect(filesystem.type.default?).to eq(false) expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) expect(filesystem.type.btrfs).to be_nil expect(filesystem.label).to eq("test") @@ -298,6 +299,7 @@ filesystem = config.filesystem expect(filesystem).to be_a(Agama::Storage::Configs::Filesystem) expect(filesystem.reuse?).to eq(false) + expect(filesystem.type.default?).to eq(false) expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) expect(filesystem.type.btrfs.snapshots?).to eq(true) expect(filesystem.label).to be_nil diff --git a/service/test/agama/storage/config_conversions/to_model_test.rb b/service/test/agama/storage/config_conversions/to_model_test.rb new file mode 100644 index 0000000000..fed7db4b8f --- /dev/null +++ b/service/test/agama/storage/config_conversions/to_model_test.rb @@ -0,0 +1,736 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require_relative "../storage_helpers" +require_relative "../../../test_helper" +require "agama/storage/config_conversions" +require "agama/storage/config_solver" +require "y2storage/refinements" + +using Y2Storage::Refinements::SizeCasts + +shared_examples "without name" do |result_scope| + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:name) + end +end + +shared_examples "without alias" do |result_scope| + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:alias) + end +end + +shared_examples "without filesystem" do |result_scope| + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:mountPath) + expect(model_json.keys).to_not include(:filesystem) + end +end + +shared_examples "without ptable_type" do |result_scope| + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:ptableType) + end +end + +shared_examples "without partitions" do |result_scope| + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("keep") + expect(model_json[:partitions]).to eq([]) + end +end + +shared_examples "with name" do |result_scope, device_scope| + let(:search) { device.name } + let(:device) { device_scope.call(devicegraph) } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:name]).to eq(device.name) + end +end + +shared_examples "with alias" do |result_scope| + let(:device_alias) { "test" } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:alias]).to eq("test") + end +end + +shared_examples "with filesystem" do |result_scope| + let(:filesystem) do + { + reuseIfPossible: true, + type: "xfs", + label: "test", + path: "/test", + mountBy: "device", + mkfsOptions: ["version=2"], + mountOptions: ["rw"] + } + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:mountPath]).to eq("/test") + expect(model_json[:filesystem]).to eq( + { + default: false, + type: "xfs" + } + ) + end + + context "with a default filesystem" do + let(:filesystem) { { path: "/" } } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:mountPath]).to eq("/") + expect(model_json[:filesystem]).to eq( + { + default: true, + type: "btrfs", + snapshots: true + } + ) + end + end +end + +shared_examples "with ptable_type" do |result_scope| + let(:ptableType) { "gpt" } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:ptableType]).to eq("gpt") + end +end + +shared_examples "with partitions" do |result_scope, device_scope| + let(:partitions) do + [ + partition, + {} + ] + end + + let(:partition) { {} } + + let(:default_partition_json) do + { + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { default: true, min: 100.MiB.to_i } + } + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:partitions]).to eq( + [ + default_partition_json, + default_partition_json + ] + ) + end + + context "if a partition is set to delete without device" do + let(:partition) { { delete: true } } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:partitions]).to eq( + [ + default_partition_json + ] + ) + end + end + + context "if a partition is set to delete if needed without device" do + let(:partition) { { deleteIfNeeded: true } } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:partitions]).to eq( + [ + default_partition_json + ] + ) + end + end + + context "if a device is not found for a partition" do + let(:partition) { { search: "/not/found" } } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:partitions]).to eq( + [ + default_partition_json + ] + ) + end + end + + context "if a partition should be created if not found" do + let(:partition) do + { + search: { + condition: { name: "/not/found" }, + ifNotFound: "create" + } + } + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:partitions]).to eq( + [ + default_partition_json, + default_partition_json + ] + ) + end + end + + context "if a device is found for a partition" do + # The device should have at least one partition. + let(:partition_device) { device_scope.call(devicegraph).partitions.first } + + let(:partition) { { search: partition_device.name } } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:partitions]).to eq( + [ + { + name: partition_device.name, + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false, + size: { + default: true, + min: partition_device.size.to_i, + max: partition_device.size.to_i + } + }, + default_partition_json + ] + ) + end + end + + partition_result_scope = proc { |c| result_scope.call(c)[:partitions].first } + partition_scope = proc { |c| device_scope.call(c).partitions.first } + + context "if a device is not assigned to a partition" do + let(:partition) { {} } + include_examples "without name", partition_result_scope + end + + context "if #alias is not configured for a partition" do + let(:partition) { {} } + include_examples "without alias", partition_result_scope + end + + context "if #id is not configured for a partition" do + let(:partition) { {} } + + it "generates the expected JSON" do + model_json = partition_result_scope.call(subject.convert) + expect(model_json.keys).to_not include(:id) + end + end + + context "if #filesystem is not configured for a partition" do + let(:partition) { {} } + include_examples "without filesystem", partition_result_scope + end + + context "if a device is assigned to a partition" do + let(:partition) { { search: search } } + include_examples "with name", partition_result_scope, partition_scope + end + + context "if #alias is configured for a partition" do + let(:partition) { { alias: device_alias } } + include_examples "with alias", partition_result_scope + end + + context "if #id is configured for a partition" do + let(:partition) { { id: "esp" } } + + it "generates the expected JSON" do + model_json = partition_result_scope.call(subject.convert) + expect(model_json[:id]).to eq("esp") + end + end + + context "if #filesystem is configured for a partition" do + let(:partition) { { filesystem: filesystem } } + include_examples "with filesystem", partition_result_scope + end + + context "for the #size property" do + let(:partition) { { search: search, size: size } } + include_examples "#size property", partition_result_scope, partition_scope + end + + context "for the #delete property" do + let(:partition) { { search: device.name, delete: delete } } + let(:device) { partition_scope.call(devicegraph) } + include_examples "#delete property", partition_result_scope + end + + context "for the #deleteIfNeeded property" do + let(:partition) { { search: device.name, deleteIfNeeded: delete_if_needed } } + let(:device) { partition_scope.call(devicegraph) } + include_examples "#deleteIfNeeded property", partition_result_scope + end +end + +shared_examples "#spacePolicy property" do |result_scope| + context "if there is a 'delete all' partition" do + let(:partitions) do + [ + { search: "*", delete: true }, + { size: "2 GiB" } + ] + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("delete") + end + end + + context "if there is a 'resize all' partition" do + let(:partitions) do + [ + { search: "*", size: { min: 0, max: "current" } }, + { size: "2 GiB" } + ] + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("resize") + end + end + + context "if there is a 'delete' partition" do + let(:partitions) do + [ + { search: { max: 1 }, delete: true }, + { filesystem: { path: "/" } } + ] + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("custom") + end + end + + context "if there is a 'delete if needed' partition" do + let(:partitions) do + [ + { search: { max: 1 }, deleteIfNeeded: true }, + { filesystem: { path: "/" } } + ] + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("custom") + end + end + + context "if there is a 'resize' partition" do + let(:partitions) do + [ + { search: { max: 1 }, size: "1 GiB" }, + { filesystem: { path: "/" } } + ] + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("custom") + end + end + + context "if there is a 'resize if needed' partition" do + let(:partitions) do + [ + { search: { max: 1 }, size: { min: 0, max: "1 GiB" } }, + { filesystem: { path: "/" } } + ] + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("custom") + end + end + + context "if there is neither 'delete' nor 'resize' partition" do + let(:partitions) do + [ + { size: { min: "1 GiB" } }, + { filesystem: { path: "/" } } + ] + end + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:spacePolicy]).to eq("keep") + end + end +end + +shared_examples "#size property" do |result_scope, partition_scope| + context "if there is not device assigned to the config" do + let(:search) { nil } + + context "if the config contains the default product size" do + let(:size) { nil } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: true, + min: 100.MiB.to_i + } + ) + end + end + + context "if the config contains a specific size" do + let(:size) { "10 GiB" } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: false, + min: 10.GiB.to_i, + max: 10.GiB.to_i + } + ) + end + end + end + + context "if there is a device assigned to the config" do + let(:search) { device.name } + let(:device) { partition_scope.call(devicegraph) } + + context "if the config contains the default product size" do + let(:size) { nil } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: true, + min: device.size.to_i, + max: device.size.to_i + } + ) + end + end + + context "if the config contains a specific size" do + let(:size) { "10 GiB" } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:size]).to eq( + { + default: false, + min: 10.GiB.to_i, + max: 10.GiB.to_i + } + ) + end + end + end +end + +shared_examples "#delete property" do |result_scope| + context "if #delete is set to false" do + let(:delete) { false } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:delete]).to eq(false) + end + end + + context "if #delete is set to true" do + let(:delete) { true } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:delete]).to eq(true) + end + end +end + +shared_examples "#deleteIfNeeded property" do |result_scope| + context "if #delete_if_needed is set to false" do + let(:delete_if_needed) { false } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:deleteIfNeeded]).to eq(false) + end + end + + context "if #delete_if_needed is set to true" do + let(:delete_if_needed) { true } + + it "generates the expected JSON" do + model_json = result_scope.call(subject.convert) + expect(model_json[:deleteIfNeeded]).to eq(true) + end + end +end + +describe Agama::Storage::ConfigConversions::ToModel do + include Agama::RSpec::StorageHelpers + + let(:product_data) do + { + "storage" => { + "volumes" => ["/", "swap"], + "volume_templates" => [ + { + "mount_path" => "/", + "filesystem" => "btrfs", + "size" => { + "auto" => true, + "min" => "5 GiB", + "max" => "10 GiB" + }, + "btrfs" => { + "snapshots" => true + }, + "outline" => { + "required" => true, + "snapshots_configurable" => true, + "auto_size" => { + "base_min" => "5 GiB", + "base_max" => "10 GiB" + } + } + }, + { + "mount_path" => "/home", + "filesystem" => "xfs", + "size" => { + "auto" => false, + "min" => "5 GiB" + }, + "outline" => { + "required" => false + } + }, + { + "mount_path" => "swap", + "filesystem" => "swap", + "size" => { + "auto" => true + }, + "outline" => { + "auto_size" => { + "base_min" => "2 GiB", + "base_max" => "4 GiB" + } + } + }, + { + "mount_path" => "", + "filesystem" => "ext4", + "size" => { + "min" => "100 MiB" + } + } + ] + } + } + end + + let(:product_config) { Agama::Config.new(product_data) } + + let(:devicegraph) { Y2Storage::StorageManager.instance.probed } + + let(:config) do + Agama::Storage::ConfigConversions::FromJSON + .new(config_json) + .convert + .tap { |c| Agama::Storage::ConfigSolver.new(devicegraph, product_config).solve(c) } + end + + before do + mock_storage(devicegraph: scenario) + # To speed-up the tests + allow(Y2Storage::EncryptionMethod::TPM_FDE) + .to(receive(:possible?)) + .and_return(true) + end + + subject { described_class.new(config) } + + describe "#convert" do + let(:scenario) { "disks.yaml" } + + context "with the default config" do + let(:config_json) { {} } + + it "generates the expected JSON" do + expect(subject.convert).to eq( + { + drives: [] + } + ) + end + end + + context "if #drives is configured" do + let(:config_json) do + { drives: drives } + end + + let(:drives) do + [ + drive, + {} + ] + end + + let(:drive) { {} } + + it "generates the expected JSON for 'drives'" do + drives_json = subject.convert[:drives] + + expect(drives_json).to eq( + [ + { name: "/dev/vda", spacePolicy: "keep", partitions: [] }, + { name: "/dev/vdb", spacePolicy: "keep", partitions: [] } + ] + ) + end + + context "if a device is not found for a drive" do + let(:drive) { { search: "/dev/vdd" } } + + it "generates the expected JSON for 'drives'" do + drives_json = subject.convert[:drives] + + expect(drives_json).to eq( + [ + { name: "/dev/vda", spacePolicy: "keep", partitions: [] } + ] + ) + end + end + + context "if a device is found for a drive" do + let(:drive) { { search: "/dev/vda" } } + + it "generates the expected JSON for 'drives'" do + drives_json = subject.convert[:drives] + + expect(drives_json).to eq( + [ + { name: "/dev/vda", spacePolicy: "keep", partitions: [] }, + { name: "/dev/vdb", spacePolicy: "keep", partitions: [] } + ] + ) + end + end + + drive_result_scope = proc { |c| c[:drives].first } + drive_scope = proc { |d| d.find_by_name("/dev/vda") } + + context "if #alias is not configured for a drive" do + let(:drive) { {} } + include_examples "without alias", drive_result_scope + end + + context "if #filesystem is not configured for a drive" do + let(:drive) { {} } + include_examples "without filesystem", drive_result_scope + end + + context "if #ptable_type is not configured for a drive" do + let(:drive) { {} } + include_examples "without ptable_type", drive_result_scope + end + + context "if #partitions is not configured for a drive" do + let(:drive) { {} } + include_examples "without partitions", drive_result_scope + end + + context "if #alias is configured for a drive" do + let(:drive) { { alias: device_alias } } + include_examples "with alias", drive_result_scope + end + + context "if #filesystem is configured for a drive" do + let(:drive) { { filesystem: filesystem } } + include_examples "with filesystem", drive_result_scope + end + + context "if #ptable_type is configured for a drive" do + let(:drive) { { ptableType: ptableType } } + include_examples "with ptable_type", drive_result_scope + end + + context "if #partitions is configured for a drive" do + let(:drive) { { partitions: partitions } } + include_examples "with partitions", drive_result_scope, drive_scope + end + + context "for the #spacePolicy property" do + let(:drive) { { partitions: partitions } } + include_examples "#spacePolicy property", drive_result_scope + end + end + end +end diff --git a/service/test/agama/storage/config_json_reader_test.rb b/service/test/agama/storage/config_json_reader_test.rb new file mode 100644 index 0000000000..6aa6c3aa77 --- /dev/null +++ b/service/test/agama/storage/config_json_reader_test.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +# Copyright (c) [2024] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require_relative "../../test_helper" +require "agama/config" +require "agama/storage/config_json_reader" + +describe Agama::Storage::ConfigJSONReader do + let(:product_config) { Agama::Config.new(config_data) } + + subject { described_class.new(product_config) } + + describe "#read" do + let(:config_data) do + { + "storage" => { + "lvm" => lvm, + "space_policy" => space_policy, + "encryption" => { + "method" => "luks2", + "pbkd_function" => "argon2id" + }, + "volumes" => ["/", "swap"], + "volume_templates" => [ + { + "mount_path" => "/", + "outline" => { "required" => true } + }, + { + "mount_path" => "/home", + "outline" => { "required" => false } + }, + { + "mount_path" => "swap", + "outline" => { "required" => false } + } + ] + } + } + end + + context "if lvm is disabled" do + let(:lvm) { false } + + context "and the space policy is 'delete'" do + let(:space_policy) { "delete" } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { + partitions: [ + { search: "*", delete: true }, + { generate: "default" } + ] + } + ] + } + } + ) + end + end + + context "and the space policy is 'resize'" do + let(:space_policy) { "resize" } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { + partitions: [ + { search: "*", size: { min: 0, max: "current" } }, + { generate: "default" } + ] + } + ] + } + } + ) + end + end + + context "and the space policy is 'keep'" do + let(:space_policy) { "keep" } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { + partitions: [ + { generate: "default" } + ] + } + ] + } + } + ) + end + end + + context "and the space policy is unknown" do + let(:space_policy) { nil } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { + partitions: [ + { generate: "default" } + ] + } + ] + } + } + ) + end + end + end + + context "if lvm is enabled" do + let(:lvm) { true } + + context "and the space policy is 'delete'" do + let(:space_policy) { "delete" } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { + alias: "target", + partitions: [ + { search: "*", delete: true } + ] + } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [{ generate: ["target"] }], + logicalVolumes: [{ generate: "default" }] + } + ] + } + } + ) + end + end + + context "and the space policy is 'resize'" do + let(:space_policy) { "resize" } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { + alias: "target", + partitions: [ + { search: "*", size: { min: 0, max: "current" } } + ] + } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [{ generate: ["target"] }], + logicalVolumes: [{ generate: "default" }] + } + ] + } + } + ) + end + end + + context "and the space policy is 'keep'" do + let(:space_policy) { "keep" } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { alias: "target" } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [{ generate: ["target"] }], + logicalVolumes: [{ generate: "default" }] + } + ] + } + } + ) + end + end + + context "and the space policy is 'keep'" do + let(:space_policy) { nil } + + it "generates the expected JSON" do + expect(subject.read).to eq( + { + storage: { + drives: [ + { alias: "target" } + ], + volumeGroups: [ + { + name: "system", + physicalVolumes: [{ generate: ["target"] }], + logicalVolumes: [{ generate: "default" }] + } + ] + } + } + ) + end + end + end + end +end diff --git a/service/test/agama/storage/config_reader_test.rb b/service/test/agama/storage/config_reader_test.rb deleted file mode 100644 index 4e021a9a04..0000000000 --- a/service/test/agama/storage/config_reader_test.rb +++ /dev/null @@ -1,180 +0,0 @@ -# frozen_string_literal: true - -# Copyright (c) [2024] SUSE LLC -# -# All Rights Reserved. -# -# This program is free software; you can redistribute it and/or modify it -# under the terms of version 2 of the GNU General Public License as published -# by the Free Software Foundation. -# -# 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. - -require_relative "../../test_helper" -require "agama/config" -require "agama/storage/device_settings" -require "agama/storage/config_reader" -require "y2storage" - -describe Agama::Storage::ConfigReader do - let(:agama_config) { Agama::Config.new(config_data) } - - subject { described_class.new(agama_config) } - - describe "#read" do - let(:lvm) { false } - let(:space_policy) { "delete" } - let(:config_data) do - { - "storage" => { - "lvm" => lvm, - "space_policy" => space_policy, - "encryption" => { - "method" => "luks2", - "pbkd_function" => "argon2id" - }, - "volumes" => ["/", "swap"], - "volume_templates" => [ - { - "mount_path" => "/", - "outline" => { "required" => true } - }, - { - "mount_path" => "/home", - "outline" => { "required" => false } - }, - { - "mount_path" => "swap", - "outline" => { "required" => false } - } - ] - } - } - end - - it "generates the corresponding storage configuration" do - config = subject.read - expect(config).to be_a(Agama::Storage::Config) - expect(config.drives.size).to eq 1 - end - - context "if lvm is disabled" do - let(:lvm) { false } - - it "applies the space policy to the first drive and places the default volumes there" do - config = subject.read - expect(config.drives.size).to eq 1 - - partitions = config.drives.first.partitions - expect(partitions).to contain_exactly( - an_object_having_attributes( - search: an_instance_of(Agama::Storage::Configs::Search), filesystem: nil - ), - an_object_having_attributes( - search: nil, filesystem: an_object_having_attributes(path: "/") - ), - an_object_having_attributes( - search: nil, filesystem: an_object_having_attributes(path: "swap") - ) - ) - end - end - - context "if lvm is enabled" do - let(:lvm) { true } - - it "applies the space policy to the first drive" do - config = subject.read - expect(config.drives.size).to eq 1 - - partitions = config.drives.first.partitions - expect(partitions.size).to eq 1 - partition = partitions.first - expect(partition.search).to be_a Agama::Storage::Configs::Search - end - - it "places the default volumes at a new LVM over the first disk" do - config = subject.read - expect(config.volume_groups.size).to eq 1 - vg = config.volume_groups.first - disk_alias = config.drives.first.alias - expect(vg.physical_volumes_devices).to contain_exactly disk_alias - - expect(vg.logical_volumes).to contain_exactly( - an_object_having_attributes(filesystem: an_object_having_attributes(path: "/")), - an_object_having_attributes(filesystem: an_object_having_attributes(path: "swap")) - ) - end - end - - context "if the space policy is unknown" do - let(:space_policy) { nil } - - it "generates no partitition config to match existing partitions" do - config = subject.read - partitions = config.drives.first.partitions - expect(partitions).to_not include( - an_object_having_attributes(search: an_instance_of(Agama::Storage::Configs::Search)) - ) - end - end - - context "if the space policy is 'keep'" do - let(:space_policy) { "keep" } - - it "generates no partitition config to match existing partitions" do - config = subject.read - partitions = config.drives.first.partitions - expect(partitions).to_not include( - an_object_having_attributes(search: an_instance_of(Agama::Storage::Configs::Search)) - ) - end - end - - context "if the space policy is 'delete'" do - let(:space_policy) { "delete" } - - it "generates a partitition config to delete existing partitions" do - config = subject.read - partitions = config.drives.first.partitions - expect(partitions).to include( - an_object_having_attributes(search: an_instance_of(Agama::Storage::Configs::Search)) - ) - - search_part = partitions.find(&:search) - expect(search_part.delete).to eq true - expect(search_part.search.name).to be_nil - expect(search_part.search.if_not_found).to eq :skip - end - end - - context "if the space policy is 'resize'" do - let(:space_policy) { "resize" } - - it "generates a partitition config to shrink existing partitions" do - config = subject.read - partitions = config.drives.first.partitions - expect(partitions).to include( - an_object_having_attributes(search: an_instance_of(Agama::Storage::Configs::Search)) - ) - - search_part = partitions.find(&:search) - expect(search_part.delete).to eq false - expect(search_part.size).to have_attributes( - default: false, min: Y2Storage::DiskSize.zero, max: nil - ) - expect(search_part.search.name).to be_nil - expect(search_part.search.if_not_found).to eq :skip - end - end - end -end diff --git a/service/test/agama/storage/config_solver_test.rb b/service/test/agama/storage/config_solver_test.rb index eb0cc66949..1ef682034f 100644 --- a/service/test/agama/storage/config_solver_test.rb +++ b/service/test/agama/storage/config_solver_test.rb @@ -185,6 +185,7 @@ drive = config.drives.first filesystem = drive.filesystem expect(filesystem.type).to be_a(Agama::Storage::Configs::FilesystemType) + expect(filesystem.type.default?).to eq(true) expect(filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) expect(filesystem.type.btrfs).to be_a(Agama::Storage::Configs::Btrfs) expect(filesystem.type.btrfs.snapshots?).to eq(true) @@ -288,7 +289,7 @@ it "sets the device size" do subject.solve(config) partition = partition_proc.call(config) - expect(partition.size.default?).to eq(false) + expect(partition.size.default?).to eq(true) expect(partition.size.min).to eq(20.GiB) expect(partition.size.max).to eq(20.GiB) end diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index ce1fcbf211..c942dc7fc7 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -22,16 +22,17 @@ require_relative "../../test_helper" require_relative "../with_progress_examples" require_relative "../with_issues_examples" -require_relative "storage_helpers" +require_relative "./storage_helpers" +require "agama/dbus/clients/questions" +require "agama/config" +require "agama/http" +require "agama/issue" +require "agama/storage/config_json_reader" +require "agama/storage/iscsi/manager" require "agama/storage/manager" require "agama/storage/proposal" require "agama/storage/proposal_settings" -require "agama/storage/iscsi/manager" require "agama/storage/volume" -require "agama/config" -require "agama/issue" -require "agama/dbus/clients/questions" -require "agama/http" require "y2storage/issue" Yast.import "Installation" @@ -163,16 +164,13 @@ allow(proposal).to receive(:issues).and_return(proposal_issues) allow(proposal).to receive(:available_devices).and_return(devices) - allow(proposal).to receive(:calculate_agama) + allow(proposal).to receive(:calculate_from_json) allow(config).to receive(:pick_product) allow(iscsi).to receive(:activate) allow(y2storage_manager).to receive(:activate) allow(iscsi).to receive(:probe) allow(y2storage_manager).to receive(:probe) - - allow_any_instance_of(Agama::Storage::ConfigReader).to receive(:read) - .and_return(storage_config) end let(:raw_devicegraph) do @@ -185,8 +183,6 @@ let(:devices) { [disk1, disk2] } - let(:storage_config) { Agama::Storage::Config.new } - let(:disk1) { instance_double(Y2Storage::Disk, name: "/dev/vda") } let(:disk2) { instance_double(Y2Storage::Disk, name: "/dev/vdb") } @@ -204,7 +200,7 @@ end expect(iscsi).to receive(:probe) expect(y2storage_manager).to receive(:probe) - expect(proposal).to receive(:calculate_agama).with(storage_config) + expect(proposal).to receive(:calculate_from_json) storage.probe end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index 5933a5e89f..ae0c78f936 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -118,9 +118,9 @@ def drive(partitions) describe "#storage_json" do context "if no proposal has been calculated yet" do - it "returns an empty hash" do + it "returns nil" do expect(subject.calculated?).to eq(false) - expect(proposal.storage_json).to eq({}) + expect(proposal.storage_json).to be_nil end end @@ -156,83 +156,65 @@ def drive(partitions) subject.calculate_agama(achivable_config) end - context "and unsolved config is requested" do - let(:solved) { false } - - it "returns the unsolved JSON config" do - expect(subject.storage_json(solved: solved)).to eq( - { - storage: { - boot: { configure: true }, - drives: [ - { - search: { - ifNotFound: "error", - max: 1 - }, - partitions: [ - { - filesystem: { - reuseIfPossible: false, - path: "/", - type: "btrfs", - mkfsOptions: [], - mountOptions: [] - }, - size: { - min: 10.GiB.to_i, - max: 10.GiB.to_i - } + it "returns the unsolved JSON config" do + expect(subject.storage_json).to eq( + { + storage: { + boot: { configure: true }, + drives: [ + { + search: { + ifNotFound: "error", + max: 1 + }, + partitions: [ + { + filesystem: { + reuseIfPossible: false, + path: "/", + type: "btrfs", + mkfsOptions: [], + mountOptions: [] + }, + size: { + min: 10.GiB.to_i, + max: 10.GiB.to_i } - ] - } - ], - volumeGroups: [] - } + } + ] + } + ], + volumeGroups: [] } - ) - end + } + ) end + end - context "and solved config is requested" do - let(:solved) { true } + context "if a proposal was calculated with the autoyast strategy" do + before do + subject.calculate_autoyast(partitioning) + end - it "returns the solved JSON config" do - expect(subject.storage_json(solved: solved)).to eq( - { - storage: { - boot: { configure: true }, - drives: [ - { - search: { - condition: { name: "/dev/sda" }, - ifNotFound: "error", - max: 1 - }, - partitions: [ - { - filesystem: { - reuseIfPossible: false, - path: "/", - type: { - btrfs: { snapshots: false } - }, - mkfsOptions: [], - mountOptions: [] - }, - size: { - min: 10.GiB.to_i, - max: 10.GiB.to_i - } - } - ] - } - ], - volumeGroups: [] + let(:partitioning) do + [ + { + partitions: [ + { + mount: "/", + size: "10 GiB" } - } - ) - end + ] + } + ] + end + + it "returns the unsolved JSON config" do + expect(subject.storage_json).to eq( + { + legacyAutoyastStorage: partitioning + } + ) end end @@ -253,37 +235,25 @@ def drive(partitions) } end - context "and unsolved config is requested" do - let(:solved) { false } - - it "returns the given guided JSON config" do - expect(subject.storage_json(solved: solved)).to eq(config_json) - end - end - - context "and solved config is requested" do - let(:solved) { true } - - it "returns the solved guided JSON config" do - expected_json = { - storage: { - guided: { - boot: { - configure: true - }, - space: { - policy: "keep" - }, - target: { - disk: "/dev/vda" - }, - volumes: [] - } + it "returns the solved guided JSON config" do + expected_json = { + storage: { + guided: { + boot: { + configure: true + }, + space: { + policy: "keep" + }, + target: { + disk: "/dev/vda" + }, + volumes: [] } } + } - expect(subject.storage_json(solved: solved)).to eq(expected_json) - end + expect(subject.storage_json).to eq(expected_json) end end @@ -307,45 +277,8 @@ def drive(partitions) } end - context "and unsolved config is requested" do - let(:solved) { false } - - it "returns the given JSON config" do - expect(subject.storage_json(solved: solved)).to eq(config_json) - end - end - - context "and solved config is requested" do - let(:solved) { true } - - it "returns the solved JSON config" do - expect(subject.storage_json(solved: solved)).to eq( - { - storage: { - boot: { configure: false }, - drives: [ - { - search: { - condition: { name: "/dev/sda" }, - ifNotFound: "error", - max: 1 - }, - filesystem: { - mkfsOptions: [], - mountOptions: [], - reuseIfPossible: false, - type: { - btrfs: { snapshots: false } - } - }, - partitions: [] - } - ], - volumeGroups: [] - } - } - ) - end + it "returns the given JSON config" do + expect(subject.storage_json).to eq(config_json) end end @@ -375,6 +308,104 @@ def drive(partitions) end end + describe "#model_json" do + context "if no proposal has been calculated yet" do + it "returns nil" do + expect(subject.model_json).to be_nil + end + end + + context "if a guided proposal has been calculated" do + before do + subject.calculate_from_json(settings_json) + end + + let(:settings_json) do + { + storage: { + guided: { + target: { disk: "/dev/vda" } + } + } + } + end + + it "returns nil" do + expect(subject.model_json).to be_nil + end + end + + context "if an agama proposal has been calculated" do + before do + subject.calculate_from_json(config_json) + end + + let(:config_json) do + { + storage: { + drives: [ + { + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ] + } + } + end + + it "returns the config model" do + expect(subject.model_json).to eq( + { + drives: [ + { + name: "/dev/sda", + spacePolicy: "keep", + partitions: [ + { + mountPath: "/", + filesystem: { + default: true, + type: "ext4" + }, + size: { + default: true, + min: 0 + }, + delete: false, + deleteIfNeeded: false, + resize: false, + resizeIfNeeded: false + } + ] + } + ] + } + ) + end + end + + context "if an AutoYaST proposal has been calculated" do + before do + subject.calculate_from_json(autoyast_json) + end + + let(:autoyast_json) do + { + legacyAutoyastStorage: [ + { device: "/dev/vda" } + ] + } + end + + it "returns nil" do + expect(subject.model_json).to be_nil + end + end + end + shared_examples "check proposal callbacks" do |action, settings| it "runs all the callbacks" do callback1 = proc {} diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts index 2f3608427d..9913e89d39 100644 --- a/web/src/api/storage.ts +++ b/web/src/api/storage.ts @@ -23,7 +23,7 @@ import { get, post, put } from "~/api/http"; import { Job } from "~/types/job"; import { calculate, fetchSettings } from "~/api/storage/proposal"; -import { config } from "~/api/storage/types"; +import { config, configModel } from "~/api/storage/types"; /** * Starts the storage probing process. @@ -35,8 +35,8 @@ const probe = (): Promise => post("/api/storage/probe"); const fetchConfig = (): Promise => get("/api/storage/config").then((config) => config.storage); -const fetchSolvedConfig = (): Promise => - get("/api/storage/solved_config").then((config) => config.storage); +const fetchConfigModel = (): Promise => + get("/api/storage/config_model"); const setConfig = (config: config.Config) => put("/api/storage/config", config); @@ -67,7 +67,7 @@ const refresh = async (): Promise => { export { probe, fetchConfig, - fetchSolvedConfig, + fetchConfigModel, setConfig, fetchStorageJobs, findStorageJob, diff --git a/web/src/api/storage/types.ts b/web/src/api/storage/types.ts index d295e566ae..216d0eda10 100644 --- a/web/src/api/storage/types.ts +++ b/web/src/api/storage/types.ts @@ -21,6 +21,7 @@ */ import * as config from "./types/config"; +import * as configModel from "./types/config-model"; export * from "./types/openapi"; -export { config }; +export { config, configModel }; diff --git a/web/src/api/storage/types/config-model.ts b/web/src/api/storage/types/config-model.ts new file mode 100644 index 0000000000..4d09b437b4 --- /dev/null +++ b/web/src/api/storage/types/config-model.ts @@ -0,0 +1,65 @@ +/* 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 FilesystemType = + | "bcachefs" + | "btrfs" + | "exfat" + | "ext2" + | "ext3" + | "ext4" + | "f2fs" + | "jfs" + | "nfs" + | "nilfs2" + | "ntfs" + | "reiserfs" + | "swap" + | "tmpfs" + | "vfat" + | "xfs"; +export type SpacePolicy = "delete" | "resize" | "keep" | "custom"; +export type PtableType = "gpt" | "msdos" | "dasd"; +export type PartitionId = "linux" | "swap" | "lvm" | "raid" | "esp" | "prep" | "bios_boot"; + +/** + * Config model + */ +export interface Config { + drives?: Drive[]; +} +export interface Drive { + name: string; + alias?: string; + mountPath?: string; + filesystem?: Filesystem; + spacePolicy?: SpacePolicy; + ptableType?: PtableType; + partitions?: Partition[]; +} +export interface Filesystem { + default: boolean; + type?: FilesystemType; + snapshots?: boolean; +} +export interface Partition { + name?: string; + alias?: string; + id?: PartitionId; + mountPath?: string; + filesystem?: Filesystem; + size?: Size; + delete?: boolean; + deleteIfNeeded?: boolean; + resize?: boolean; + resizeIfNeeded?: boolean; +} +export interface Size { + default: boolean; + min: number; + max?: number; +} diff --git a/web/src/queries/storage.ts b/web/src/queries/storage.ts index a6a53b7a48..05385f6cf1 100644 --- a/web/src/queries/storage.ts +++ b/web/src/queries/storage.ts @@ -28,7 +28,7 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import React from "react"; -import { fetchConfig, fetchSolvedConfig, setConfig } from "~/api/storage"; +import { fetchConfig, fetchConfigModel, setConfig } from "~/api/storage"; import { fetchDevices, fetchDevicesDirty } from "~/api/storage/devices"; import { calculate, @@ -40,6 +40,7 @@ import { import { useInstallerClient } from "~/context/installer"; import { config, + configModel, ProductParams, Volume as APIVolume, ProposalSettingsPatch, @@ -51,7 +52,6 @@ import { Volume, VolumeTarget, } from "~/types/storage"; -import * as ConfigModel from "~/storage/model/config"; import { QueryHookOptions } from "~/types/queries"; @@ -61,9 +61,9 @@ const configQuery = { staleTime: Infinity, }; -const solvedConfigQuery = { - queryKey: ["storage", "solvedConfig"], - queryFn: fetchSolvedConfig, +const configModelQuery = { + queryKey: ["storage", "configModel"], + queryFn: fetchConfigModel, staleTime: Infinity, }; @@ -128,10 +128,10 @@ const useConfig = (options?: QueryHookOptions): config.Config => { }; /** - * Hook that returns the solved config. + * Hook that returns the config model. */ -const useSolvedConfig = (options?: QueryHookOptions): config.Config => { - const query = solvedConfigQuery; +const useConfigModel = (options?: QueryHookOptions): configModel.Config => { + const query = configModelQuery; const func = options?.suspense ? useSuspenseQuery : useQuery; const { data } = func(query); return data; @@ -150,18 +150,6 @@ const useConfigMutation = () => { return useMutation(query); }; -/** - * Hook that returns the config devices. - */ -const useConfigDevices = (options?: QueryHookOptions): ConfigModel.Device[] => { - const config = useConfig(options); - const solvedConfig = useSolvedConfig(options); - - if (!config || !solvedConfig) return []; - - return ConfigModel.generate(config, solvedConfig); -}; - /** * Hook that returns the list of storage devices for the given scope. * @@ -345,9 +333,8 @@ const useDeprecatedChanges = () => { export { useConfig, - useSolvedConfig, useConfigMutation, - useConfigDevices, + useConfigModel, useDevices, useAvailableDevices, useProductParams, diff --git a/web/src/storage/model.ts b/web/src/storage/model.ts deleted file mode 100644 index ecbb67c203..0000000000 --- a/web/src/storage/model.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) [2024] 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 * as config from "~/storage/model/config"; - -export { config }; diff --git a/web/src/storage/model/config.test.ts b/web/src/storage/model/config.test.ts deleted file mode 100644 index eed3735cef..0000000000 --- a/web/src/storage/model/config.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) [2024] 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 * as model from "~/storage/model/config"; - -describe("#generate", () => { - it("returns a list of devices", () => { - expect( - model.generate( - { - drives: [ - { partitions: [{ search: "*", delete: true }] }, - { partitions: [{ filesystem: { path: "/test" } }] }, - ], - }, - { - drives: [ - { - search: "/dev/vda", - partitions: [ - { search: "/dev/vda1", delete: true }, - { search: "/dev/vda2", delete: true }, - ], - }, - { - search: "/dev/vdb", - partitions: [ - { - filesystem: { type: "xfs", path: "/test" }, - size: { min: 1024, max: 2048 }, - }, - ], - }, - ], - }, - ), - ).toEqual([ - { - name: "/dev/vda", - alias: undefined, - spacePolicy: "delete", - partitions: [ - { - name: "/dev/vda1", - delete: true, - }, - { - name: "/dev/vda2", - delete: true, - }, - ], - }, - { - name: "/dev/vdb", - alias: undefined, - spacePolicy: "keep", - partitions: [ - { - name: undefined, - alias: undefined, - resizeIfNeeded: true, - filesystem: "xfs", - mountPath: "/test", - snapshots: undefined, - size: { min: 1024, max: 2048 }, - }, - ], - }, - ]); - }); -}); diff --git a/web/src/storage/model/config.ts b/web/src/storage/model/config.ts deleted file mode 100644 index fc3a121b88..0000000000 --- a/web/src/storage/model/config.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) [2024] 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 { config } from "~/api/storage/types"; -import { Drive, generate as generateDrive } from "~/storage/model/config/drive"; - -export type Device = Drive; - -class ConfigDevicesGenerator { - private config: config.Config; - private solvedConfig: config.Config; - - constructor(config: config.Config, solvedConfig: config.Config) { - this.config = config; - this.solvedConfig = solvedConfig; - } - - generate(): Device[] { - return this.generateDrives(); - } - - private generateDrives(): Drive[] { - const solvedDriveConfigs = this.solvedConfig.drives || []; - return solvedDriveConfigs.map((c, i) => this.generateDrive(c, i)); - } - - private generateDrive(solvedDriveConfig: config.DriveElement, id: number): Drive { - // TODO: Use an index to associate a drive config with an unsolved drive config. - const driveConfig = (this.config.drives || [])[id]; - return generateDrive(driveConfig, solvedDriveConfig); - } -} - -export function generate(config: config.Config, solvedConfig: config.Config): Device[] { - const generator = new ConfigDevicesGenerator(config, solvedConfig); - return generator.generate(); -} diff --git a/web/src/storage/model/config/common.test.ts b/web/src/storage/model/config/common.test.ts deleted file mode 100644 index a63d417b84..0000000000 --- a/web/src/storage/model/config/common.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/* - * Copyright (c) [2024] 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 * as model from "~/storage/model/config/common"; - -describe("#generateName", () => { - it("returns the device name from the search section", () => { - expect( - model.generateName({ - search: "/dev/vda", - }), - ).toEqual("/dev/vda"); - - expect( - model.generateName({ - search: { - condition: { - name: "/dev/vda", - }, - }, - }), - ).toEqual("/dev/vda"); - }); - - it("returns undefined if the search section has no name", () => { - expect( - model.generateName({ - search: "*", - }), - ).toBeUndefined; - - expect( - model.generateName({ - search: { - max: 5, - ifNotFound: "skip", - }, - }), - ).toBeUndefined; - }); - - it("returns undefined if there is no search section", () => { - expect(model.generateName({})).toBeUndefined; - }); -}); - -describe("#generateFilesystem", () => { - it("returns the file system type from the filesystem section", () => { - expect( - model.generateFilesystem({ - filesystem: { - type: "xfs", - }, - }), - ).toEqual("xfs"); - - expect( - model.generateFilesystem({ - filesystem: { - type: { - btrfs: { - snapshots: true, - }, - }, - }, - }), - ).toEqual("btrfs"); - }); - - it("returns undefined if the filesystem section has no type", () => { - expect( - model.generateFilesystem({ - filesystem: { - path: "/", - }, - }), - ).toBeUndefined; - }); - - it("returns undefined if there is no filesystem section", () => { - expect(model.generateFilesystem({})).toBeUndefined; - }); -}); - -describe("#generateSnapshots", () => { - it("returns the snapshots value from the filesystem section", () => { - expect( - model.generateSnapshots({ - filesystem: { - type: { - btrfs: { - snapshots: true, - }, - }, - }, - }), - ).toEqual(true); - - expect( - model.generateSnapshots({ - filesystem: { - type: { - btrfs: { - snapshots: false, - }, - }, - }, - }), - ).toEqual(false); - }); - - it("returns undefined if the filesystem section has no snapshots", () => { - expect( - model.generateSnapshots({ - filesystem: { - type: "btrfs", - }, - }), - ).toBeUndefined; - - expect( - model.generateSnapshots({ - filesystem: { - type: { - btrfs: {}, - }, - }, - }), - ).toBeUndefined; - }); - - it("returns undefined if there is no filesystem section", () => { - expect(model.generateSnapshots({})).toBeUndefined; - }); -}); diff --git a/web/src/storage/model/config/common.ts b/web/src/storage/model/config/common.ts deleted file mode 100644 index 3dc8a9c37d..0000000000 --- a/web/src/storage/model/config/common.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) [2024] 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 { config } from "~/api/storage/types"; -import * as checks from "~/api/storage/types/checks"; - -interface WithFilesystem { - filesystem?: config.Filesystem; -} - -interface WithSearch { - search?: config.SearchElement; -} - -export function generateName(config: Type): string | undefined { - const search = config.search; - - if (!search) return; - if (checks.isSimpleSearchAll(search)) return; - if (checks.isSimpleSearchByName(search)) return search; - if (checks.isAdvancedSearch(search)) return search.condition?.name; -} - -export function generateFilesystem(config: Type): string | undefined { - const fstype = config.filesystem?.type; - - if (!fstype) return; - if (checks.isFilesystemTypeBtrfs(fstype)) return "btrfs"; - if (checks.isFilesystemTypeAny(fstype)) return fstype; -} - -export function generateSnapshots(config: Type): boolean | undefined { - const fstype = config.filesystem?.type; - - if (!fstype) return; - if (checks.isFilesystemTypeAny(fstype)) return; - if (checks.isFilesystemTypeBtrfs(fstype)) return fstype.btrfs.snapshots; -} diff --git a/web/src/storage/model/config/drive.test.ts b/web/src/storage/model/config/drive.test.ts deleted file mode 100644 index 82f1c9ed55..0000000000 --- a/web/src/storage/model/config/drive.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (c) [2024] 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 * as model from "~/storage/model/config/drive"; - -describe("#generate", () => { - it("returns a drive object from a drive section", () => { - expect( - model.generate(undefined, { - search: "/dev/vda", - alias: "test", - filesystem: { - type: "xfs", - path: "/test", - }, - }), - ).toEqual({ - name: "/dev/vda", - alias: "test", - filesystem: "xfs", - mountPath: "/test", - snapshots: undefined, - spacePolicy: undefined, - }); - - expect( - model.generate( - { - partitions: [{ search: "*", delete: true }], - }, - { - search: "/dev/vda", - alias: "test", - filesystem: { - type: { - btrfs: { snapshots: false }, - }, - path: "/test", - }, - }, - ), - ).toEqual({ - name: "/dev/vda", - alias: "test", - filesystem: "btrfs", - mountPath: "/test", - snapshots: false, - spacePolicy: "delete", - }); - - expect( - model.generate( - { - partitions: [{ search: "*", delete: true }, { generate: "default" }], - }, - { - search: "/dev/vda", - partitions: [{ search: "/dev/vda1", delete: true }, { filesystem: { path: "/" } }], - }, - ), - ).toEqual({ - name: "/dev/vda", - alias: undefined, - spacePolicy: "delete", - partitions: [ - { - name: "/dev/vda1", - delete: true, - }, - { - name: undefined, - alias: undefined, - filesystem: undefined, - mountPath: "/", - snapshots: undefined, - size: undefined, - }, - ], - }); - }); -}); diff --git a/web/src/storage/model/config/drive.ts b/web/src/storage/model/config/drive.ts deleted file mode 100644 index 7d6aa54115..0000000000 --- a/web/src/storage/model/config/drive.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) [2024] 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 { config } from "~/api/storage/types"; -import * as checks from "~/api/storage/types/checks"; -import { - Partition, - isPartitionConfig, - generate as generatePartition, -} from "~/storage/model/config/partition"; -import { SpacePolicy, generate as generateSpacePolicy } from "~/storage/model/config/space-policy"; -import { generateName, generateFilesystem, generateSnapshots } from "~/storage/model/config/common"; - -export type Drive = { - name?: string; - alias?: string; - filesystem?: string; - mountPath?: string; - snapshots?: boolean; - spacePolicy?: SpacePolicy; - partitions?: Partition[]; -}; - -class DriveGenerator { - private driveConfig: config.DriveElement | undefined; - private solvedDriveConfig: config.DriveElement; - - constructor( - driveConfig: config.DriveElement | undefined, - solvedDriveConfig: config.DriveElement, - ) { - this.driveConfig = driveConfig; - this.solvedDriveConfig = solvedDriveConfig; - } - - generate(): Drive { - if (checks.isFormattedDrive(this.solvedDriveConfig)) { - return this.fromFormattedDrive(this.solvedDriveConfig); - } else if (checks.isPartitionedDrive(this.solvedDriveConfig)) { - return this.fromPartitionedDrive(this.solvedDriveConfig); - } - } - - private fromFormattedDrive(solvedDriveConfig: config.FormattedDrive): Drive { - return { - name: generateName(solvedDriveConfig), - alias: solvedDriveConfig.alias, - spacePolicy: this.generateSpacePolicy(), - filesystem: generateFilesystem(solvedDriveConfig), - mountPath: solvedDriveConfig.filesystem?.path, - snapshots: generateSnapshots(solvedDriveConfig), - }; - } - - private fromPartitionedDrive(solvedDriveConfig: config.PartitionedDrive): Drive { - return { - name: generateName(solvedDriveConfig), - alias: solvedDriveConfig.alias, - spacePolicy: this.generateSpacePolicy(), - partitions: this.generatePartitions(solvedDriveConfig), - }; - } - - private generateSpacePolicy(): SpacePolicy { - if (this.driveConfig === undefined) return; - - return generateSpacePolicy(this.driveConfig); - } - - private generatePartitions(solvedDriveConfig: config.PartitionedDrive): Partition[] { - const solvedPartitionConfigs = solvedDriveConfig.partitions || []; - return solvedPartitionConfigs.filter(isPartitionConfig).map(generatePartition); - } -} - -export function generate(config: config.DriveElement, solvedConfig: config.DriveElement): Drive { - const generator = new DriveGenerator(config, solvedConfig); - return generator.generate(); -} diff --git a/web/src/storage/model/config/partition.test.ts b/web/src/storage/model/config/partition.test.ts deleted file mode 100644 index 310a101fb6..0000000000 --- a/web/src/storage/model/config/partition.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) [2024] 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 * as model from "~/storage/model/config/partition"; - -describe("#generate", () => { - it("returns a partition object from a partition section", () => { - expect( - model.generate({ - search: "/dev/vda1", - delete: true, - }), - ).toEqual({ - name: "/dev/vda1", - delete: true, - }); - - expect( - model.generate({ - search: "/dev/vda1", - deleteIfNeeded: true, - size: 1024, - }), - ).toEqual({ - name: "/dev/vda1", - deleteIfNeeded: true, - resizeIfNeeded: false, - size: { min: 1024, max: 1024 }, - }); - - expect( - model.generate({ - search: "/dev/vda1", - alias: "test", - filesystem: { - path: "/test", - type: { - btrfs: { snapshots: true }, - }, - }, - size: { min: 0, max: 2048 }, - }), - ).toEqual({ - name: "/dev/vda1", - alias: "test", - resizeIfNeeded: true, - filesystem: "btrfs", - mountPath: "/test", - snapshots: true, - size: { min: 0, max: 2048 }, - }); - - expect( - model.generate({ - filesystem: { - path: "/test", - }, - }), - ).toEqual({ - name: undefined, - alias: undefined, - resizeIfNeeded: undefined, - filesystem: undefined, - mountPath: "/test", - snapshots: undefined, - size: undefined, - }); - }); -}); diff --git a/web/src/storage/model/config/partition.ts b/web/src/storage/model/config/partition.ts deleted file mode 100644 index 6f0025d143..0000000000 --- a/web/src/storage/model/config/partition.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright (c) [2024] 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 { config } from "~/api/storage/types"; -import * as checks from "~/api/storage/types/checks"; -import { Size, WithSize, generate as generateSize } from "~/storage/model/config/size"; -import { generateName, generateFilesystem, generateSnapshots } from "~/storage/model/config/common"; - -export type Partition = { - name?: string; - alias?: string; - delete?: boolean; - deleteIfNeeded?: boolean; - resizeIfNeeded?: boolean; - filesystem?: string; - mountPath?: string; - snapshots?: boolean; - size?: Size; -}; - -export type PartitionConfig = - | config.RegularPartition - | config.PartitionToDelete - | config.PartitionToDeleteIfNeeded; - -export function isPartitionConfig( - partition: config.PartitionElement, -): partition is PartitionConfig { - return ( - checks.isRegularPartition(partition) || - checks.isPartitionToDelete(partition) || - checks.isPartitionToDeleteIfNeeded(partition) - ); -} - -class PartitionGenerator { - private partitionConfig: PartitionConfig; - - constructor(partitionConfig: PartitionConfig) { - this.partitionConfig = partitionConfig; - } - - generate(): Partition { - if (checks.isRegularPartition(this.partitionConfig)) { - return this.fromRegularPartition(this.partitionConfig); - } else if (checks.isPartitionToDelete(this.partitionConfig)) { - return this.fromPartitionToDelete(this.partitionConfig); - } else if (checks.isPartitionToDeleteIfNeeded(this.partitionConfig)) { - return this.fromPartitionToDeleteIfNeeded(this.partitionConfig); - } - } - - private fromRegularPartition(partitionConfig: config.RegularPartition): Partition { - return { - name: generateName(partitionConfig), - alias: partitionConfig.alias, - resizeIfNeeded: this.generateResizeIfNeeded(partitionConfig), - filesystem: generateFilesystem(partitionConfig), - mountPath: partitionConfig.filesystem?.path, - snapshots: generateSnapshots(partitionConfig), - size: generateSize(partitionConfig), - }; - } - - private fromPartitionToDelete(partitionConfig: config.PartitionToDelete): Partition { - return { - name: generateName(partitionConfig), - delete: true, - }; - } - - private fromPartitionToDeleteIfNeeded( - partitionConfig: config.PartitionToDeleteIfNeeded, - ): Partition { - return { - name: generateName(partitionConfig), - deleteIfNeeded: true, - resizeIfNeeded: this.generateResizeIfNeeded(partitionConfig), - size: generateSize(partitionConfig), - }; - } - - private generateResizeIfNeeded( - partitionConfig: TypeWithSize, - ): boolean | undefined { - if (!partitionConfig.size) return; - - const size = generateSize(partitionConfig); - return size.min !== undefined && size.min !== size.max; - } -} - -export function generate(config: PartitionConfig): Partition { - const generator = new PartitionGenerator(config); - return generator.generate(); -} diff --git a/web/src/storage/model/config/size.test.ts b/web/src/storage/model/config/size.test.ts deleted file mode 100644 index 8cb49a866d..0000000000 --- a/web/src/storage/model/config/size.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (c) [2024] 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 * as model from "~/storage/model/config/size"; - -describe("#generate", () => { - it("returns the size in bytes from the size section", () => { - expect( - model.generate({ - size: 1024, - }), - ).toEqual({ min: 1024, max: 1024 }); - - expect( - model.generate({ - size: "1 KiB", - }), - ).toEqual({ min: 1024, max: 1024 }); - - expect( - model.generate({ - size: "1KiB", - }), - ).toEqual({ min: 1024, max: 1024 }); - - expect( - model.generate({ - size: "1kb", - }), - ).toEqual({ min: 1000, max: 1000 }); - - expect( - model.generate({ - size: "1k", - }), - ).toEqual({ min: 1000, max: 1000 }); - - expect( - model.generate({ - size: "665.284 TiB", - }), - ).toEqual({ min: 731487493773328, max: 731487493773328 }); - - expect( - model.generate({ - size: [1024], - }), - ).toEqual({ min: 1024 }); - - expect( - model.generate({ - size: [1024, 2048], - }), - ).toEqual({ min: 1024, max: 2048 }); - - expect( - model.generate({ - size: ["1 kib", "2 KIB"], - }), - ).toEqual({ min: 1024, max: 2048 }); - - expect( - model.generate({ - size: { - min: 1024, - }, - }), - ).toEqual({ min: 1024 }); - - expect( - model.generate({ - size: { - min: 1024, - max: 2048, - }, - }), - ).toEqual({ min: 1024, max: 2048 }); - - expect( - model.generate({ - size: { - min: "1 kib", - max: "2 KiB", - }, - }), - ).toEqual({ min: 1024, max: 2048 }); - }); - it("returns undefined for 'custom' value", () => { - expect( - model.generate({ - size: { - min: "custom", - max: 2048, - }, - }), - ).toEqual({ min: undefined, max: 2048 }); - }); - - it("returns undefined if there is no size section", () => { - expect(model.generate({})).toBeUndefined; - }); -}); diff --git a/web/src/storage/model/config/size.ts b/web/src/storage/model/config/size.ts deleted file mode 100644 index 657aef034d..0000000000 --- a/web/src/storage/model/config/size.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) [2024] 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 { config } from "~/api/storage/types"; -import * as checks from "~/api/storage/types/checks"; -import xbytes from "xbytes"; - -export type Size = { - min?: number; - max?: number; -}; - -export interface WithSize { - size?: config.Size; -} - -class SizeGenerator { - private config: TypeWithSize; - - constructor(config: TypeWithSize) { - this.config = config; - } - - // TODO: detect auto size by checking the unsolved config. - generate(): Size | undefined { - const size = this.config.size; - - if (!size) return; - if (checks.isSizeValue(size)) return this.fromSizeValue(size); - if (checks.isSizeTuple(size)) return this.fromSizeTuple(size); - if (checks.isSizeRange(size)) return this.fromSizeRange(size); - } - - private fromSizeValue(value: config.SizeValue): Size { - const bytes = this.bytes(value); - return { min: bytes, max: bytes }; - } - - private fromSizeTuple(sizeTuple: config.SizeTuple): Size { - const size: Size = { min: this.bytes(sizeTuple[0]) }; - if (sizeTuple.length === 2) size.max = this.bytes(sizeTuple[1]); - - return size; - } - - private fromSizeRange(sizeRange: config.SizeRange): Size { - const size: Size = { min: this.bytes(sizeRange.min) }; - if (sizeRange.max) size.max = this.bytes(sizeRange.max); - - return size; - } - - private parseSizeString(value: string): number | undefined { - // xbytes.parseSize will not work with a string like '10k', the unit must end with 'b' or 'B' - let adapted = value.trim(); - if (!adapted.match(/b$/i)) adapted = adapted + "b"; - - const parsed = xbytes.parseSize(adapted, { bits: false }) || parseInt(adapted); - if (parsed) return Math.trunc(parsed); - } - - private bytes(value: config.SizeValueWithCurrent): number | undefined { - if (checks.isSizeCurrent(value)) return; - if (checks.isSizeString(value)) return this.parseSizeString(value); - if (checks.isSizeBytes(value)) return value; - } -} - -export function generate(config: TypeWithSize): Size | undefined { - return new SizeGenerator(config).generate(); -} diff --git a/web/src/storage/model/config/space-policy.test.ts b/web/src/storage/model/config/space-policy.test.ts deleted file mode 100644 index 80a8ad66c3..0000000000 --- a/web/src/storage/model/config/space-policy.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (c) [2024] 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 * as model from "~/storage/model/config/space-policy"; - -describe("#generate", () => { - it("returns 'delete' if there is a file system", () => { - expect( - model.generate({ - filesystem: { type: "xfs" }, - }), - ).toEqual("delete"); - }); - - it("returns 'delete' if there is a 'delete all' partition", () => { - expect( - model.generate({ - partitions: [{ search: "*", delete: true }], - }), - ).toEqual("delete"); - - expect( - model.generate({ - partitions: [{ search: { ifNotFound: "skip" }, delete: true }], - }), - ).toEqual("delete"); - - expect( - model.generate({ - partitions: [{ search: "*", delete: true }, { filesystem: { path: "/" } }], - }), - ).toEqual("delete"); - }); - - it("returns 'resize' if there is a 'shrink all' partition", () => { - expect( - model.generate({ - partitions: [{ search: "*", size: { min: 0, max: "current" } }], - }), - ).toEqual("resize"); - - expect( - model.generate({ - partitions: [{ search: "*", size: [0, "current"] }], - }), - ).toEqual("resize"); - - expect( - model.generate({ - partitions: [{ search: { ifNotFound: "skip" }, size: { min: 0, max: "current" } }], - }), - ).toEqual("resize"); - - expect( - model.generate({ - partitions: [{ search: "*", size: { min: 0, max: "current" } }, { generate: "default" }], - }), - ).toEqual("resize"); - }); - - it("returns 'custom' if there is a 'delete' or 'resize' partition", () => { - expect( - model.generate({ - partitions: [{ search: "/dev/vda", delete: true }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: { max: 2, ifNotFound: "skip" }, delete: true }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: "*", deleteIfNeeded: true }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: "*", deleteIfNeeded: true, size: [0, "current"] }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: "*", size: { min: 0 } }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: "*", size: { min: 0, max: 1024 } }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: "/dev/vda", delete: true }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: "/dev/vda", size: { min: 0, max: "current" } }], - }), - ).toEqual("custom"); - - expect( - model.generate({ - partitions: [{ search: "/dev/vda", delete: true }, { filesystem: { path: "/" } }], - }), - ).toEqual("custom"); - }); - - it("returns 'keep' if there is neither 'delete' nor 'resize' partition", () => { - expect( - model.generate({ - partitions: [{ search: "*", filesystem: { type: "xfs" } }], - }), - ).toEqual("keep"); - - expect( - model.generate({ - partitions: [{ generate: "default" }, { filesystem: { path: "/home" } }], - }), - ).toEqual("keep"); - }); - - it("returns 'keep' if there are not partitions", () => { - expect( - model.generate({ - search: "/dev/vda", - }), - ).toEqual("keep"); - }); -}); diff --git a/web/src/storage/model/config/space-policy.ts b/web/src/storage/model/config/space-policy.ts deleted file mode 100644 index 86e726b516..0000000000 --- a/web/src/storage/model/config/space-policy.ts +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright (c) [2024] 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 { config } from "~/api/storage/types"; -import * as checks from "~/api/storage/types/checks"; - -export type SpacePolicy = "keep" | "resize" | "delete" | "custom"; - -class SpacePolicyGenerator { - private driveConfig: config.DriveElement; - - constructor(driveConfig: config.DriveElement) { - this.driveConfig = driveConfig; - } - - generate(): SpacePolicy { - if (this.isDeletePolicy()) return "delete"; - if (this.isResizePolicy()) return "resize"; - if (this.isCustomPolicy()) return "custom"; - - return "keep"; - } - - private isDeletePolicy(): boolean { - return checks.isFormattedDrive(this.driveConfig) || this.hasDeleteAllPartition(); - } - - private isResizePolicy(): boolean { - return this.hasResizeAllPartition(); - } - - private isCustomPolicy(): boolean { - return this.hasDeletePartition() || this.hasResizePartition(); - } - - private hasDeleteAllPartition(): boolean { - const deleteAllPartition = this.partitionConfigs().find((c) => this.isDeleteAllPartition(c)); - return deleteAllPartition !== undefined; - } - - private hasDeletePartition(): boolean { - const deletePartition = this.partitionConfigs().find((c) => this.isDeletePartition(c)); - return deletePartition !== undefined; - } - - private hasResizeAllPartition(): boolean { - const resizeAllPartition = this.partitionConfigs().find((c) => this.isResizeAllPartition(c)); - return resizeAllPartition !== undefined; - } - - private hasResizePartition(): boolean { - const resizePartition = this.partitionConfigs().find((c) => this.isResizePartition(c)); - return resizePartition !== undefined; - } - - private isDeleteAllPartition(partitionConfig: config.PartitionElement): boolean { - return checks.isPartitionToDelete(partitionConfig) && this.isSearchAll(partitionConfig.search); - } - - private isDeletePartition(partitionConfig: config.PartitionElement): boolean { - const isDelete = - checks.isPartitionToDelete(partitionConfig) || - checks.isPartitionToDeleteIfNeeded(partitionConfig); - - return isDelete && !this.isDeleteAllPartition(partitionConfig); - } - - private isResizeAllPartition(partitionConfig: config.PartitionElement): boolean { - return ( - checks.isRegularPartition(partitionConfig) && - partitionConfig.search && - this.isSearchAll(partitionConfig.search) && - partitionConfig.size && - this.isShrinkAllSize(partitionConfig.size) - ); - } - - // TODO: if the size is not a range, then detect resize partitions by comparing with the size of - // the system device. - private isResizePartition(partitionConfig: config.PartitionElement): boolean { - if (!checks.isRegularPartition(partitionConfig)) return false; - - return ( - !this.isResizeAllPartition(partitionConfig) && - partitionConfig.search && - partitionConfig.size && - this.isResizeSize(partitionConfig.size) - ); - } - - private isSearchAll(searchConfig: config.SearchElement): boolean { - const isAdvancedSearchAll = - checks.isAdvancedSearch(searchConfig) && - !searchConfig.condition && - !searchConfig.max && - searchConfig.ifNotFound === "skip"; - - return isAdvancedSearchAll || checks.isSimpleSearchAll(searchConfig); - } - - private isShrinkAllSize(sizeConfig: config.Size): boolean { - if (!this.isResizeSize(sizeConfig)) return false; - - return ( - (checks.isSizeTuple(sizeConfig) && - sizeConfig[0] === 0 && - sizeConfig[1] && - checks.isSizeCurrent(sizeConfig[1])) || - (checks.isSizeRange(sizeConfig) && - sizeConfig.min === 0 && - sizeConfig.max && - checks.isSizeCurrent(sizeConfig.max)) - ); - } - - private isResizeSize(sizeConfig: config.Size): boolean { - if (checks.isSizeBytes(sizeConfig)) return false; - if (checks.isSizeString(sizeConfig)) return false; - if (checks.isSizeTuple(sizeConfig)) return sizeConfig[0] !== sizeConfig[1]; - if (checks.isSizeRange(sizeConfig)) return sizeConfig.min !== sizeConfig.max; - } - - private partitionConfigs(): config.PartitionElement[] { - if (checks.isFormattedDrive(this.driveConfig)) return []; - - return this.driveConfig.partitions || []; - } -} - -export function generate(config: config.DriveElement): SpacePolicy { - const generator = new SpacePolicyGenerator(config); - return generator.generate(); -}