diff --git a/doc/auto_storage.md b/doc/auto_storage.md index fd61f3c1b..3aea6a7ed 100644 --- a/doc/auto_storage.md +++ b/doc/auto_storage.md @@ -5,10 +5,10 @@ installation. ## Agama and AutoYaST -AutoYaST profiles can be used with Agama offering a 100% backward compatibility. - -The `legacyAutoyastStorage` section of the Agama profile is a 1:1 representation of the XML -specification of AutoYaST. No JSON validation will be performed for it. +The Agama profile has a special `legacyAutoyastStorage` section which is a 1:1 representation of the +XML AutoYaST profile. This section supports everything offered by the *partitioning* AutoYaST +section. Note that Agama does not validate this special section, so be careful to provide valid +AutoYaST options. ~~~json { @@ -21,21 +21,6 @@ specification of AutoYaST. No JSON validation will be performed for it. } ~~~ -### Implementation Considerations for AutoYaST Specification - -In principle, implementing the legacy AutoYaST module is as simple as converting the corresponding -section of the profile into a `Y2Storage::PartitioningSection` object and use -`Y2Storage::AutoInstProposal` to calculate the result. - -But there are some special cases in which AutoYaST fallbacks to read some settings from the YaST -settings or to use some YaST mechanisms. Those cases should be taken into account during the -implementation. - -For example, AutoYaST relies on the traditional YaST proposal settings when "auto" is used to -specify the size of a partition or to determine the default list of subvolumes when Btrfs is used. -See also the sections "Automatic Partitioning" and "Guided Partitioning" at the AutoYaST -documentation for situations in which AutoYaST uses the standard YaST `GuidedProposal` as fallback. - ### Problems with the AutoYaST Schema The AutoYaST schema is far from ideal and it presents some problems. @@ -166,6 +151,21 @@ going to be used by. It would be more natural to indicate the used devices directly in the RAID or logical volume drive. +### Implementation Considerations for AutoYaST Specification + +In principle, implementing the legacy AutoYaST module is as simple as converting the corresponding +section of the profile into a `Y2Storage::PartitioningSection` object and use +`Y2Storage::AutoInstProposal` to calculate the result. + +But there are some special cases in which AutoYaST fallbacks to read some settings from the YaST +settings or to use some YaST mechanisms. Those cases should be taken into account during the +implementation. + +For example, AutoYaST relies on the traditional YaST proposal settings when "auto" is used to +specify the size of a partition or to determine the default list of subvolumes when Btrfs is used. +See also the sections "Automatic Partitioning" and "Guided Partitioning" at the AutoYaST +documentation for situations in which AutoYaST uses the standard YaST `GuidedProposal` as fallback. + ## The New Storage Schema Agama offers its own storage schema which is more semantic, comprehensive and flexible than the @@ -240,7 +240,7 @@ VolumeGroup alias [] name [] peSize [] - physicalVolumes [<[]>] + physicalVolumes [[]>] logicalVolumes [] delete [] @@ -855,6 +855,95 @@ space first by shrinking the partitions and deleting them only if shrinking is n } ``` +### Generating Default Volumes + +Every product provides a configuration which defines the storage volumes (e.g., feasible file +systems for root, default partitions to create, etc). The default or mandatory product volumes can +be automatically generated by using a *generate* section in the *partitions* or *logicalVolumes* +sections. + +```json +"storage": { + "drives": [ + { + "partitions": [ + { "generate": "default" } + ] + } + ] +} + +``` + +The *generate* section allows creating the product volumes without explicitly writing all of them. +The config above would be equivalent to something like this: + +```json +"storage": { + "drives": [ + { + "partitions": [ + { "filesystem": { "path": "/" } }, + { "filesystem": { "path": "/home" } }, + { "filesystem": { "path": "swap" } } + ] + } + ] +} + +``` + +If any path is explicitly defined, then the *generate* section will not generate a volume for it. +For example, with the following config only root and swap would be automatically added: + +```json +"storage": { + "drives": [ + { + "partitions": [ + { "generate": "default" }, + { "filesystem": { "path": "/home" } } + ] + } + ] +} +``` + +The auto-generated volumes can be also configured. For example, for encrypting the partitions: + +```json +"storage": { + "drives": [ + { + "partitions": [ + { + "generate": { + "partitions": "default", + "encryption": { + "luks1": { "password": "12345" } + } + } + } + ] + } + ] +} +``` + +The *mandatory* keyword can be used for only generating the mandatory partitions or logical volumes: + +```json +"storage": { + "volumeGroups": [ + { + "logicalVolumes": [ + { "generate": "mandatory" } + ] + } + ] +} +``` + ### Using the Automatic Proposal On the first implementations, Agama can rely on the process known as Guided Proposal to calculate diff --git a/rust/agama-lib/share/examples/storage_drives.json b/rust/agama-lib/share/examples/storage/drives.json similarity index 100% rename from rust/agama-lib/share/examples/storage_drives.json rename to rust/agama-lib/share/examples/storage/drives.json diff --git a/rust/agama-lib/share/examples/storage/generate_lvs.json b/rust/agama-lib/share/examples/storage/generate_lvs.json new file mode 100644 index 000000000..028da5b45 --- /dev/null +++ b/rust/agama-lib/share/examples/storage/generate_lvs.json @@ -0,0 +1,24 @@ +{ + "storage": { + "drives": [ + { + "partitions": [ + { + "alias": "pv1", + "id": "lvm", + "size": { "min": "10 GiB" } + } + ] + } + ], + "volumeGroups": [ + { + "name": "system", + "physicalVolumes": ["pv1"], + "logicalVolumes": [ + { "generate": "default" } + ] + } + ] + } +} diff --git a/rust/agama-lib/share/examples/storage/generate_lvs_extended.json b/rust/agama-lib/share/examples/storage/generate_lvs_extended.json new file mode 100644 index 000000000..d7709e884 --- /dev/null +++ b/rust/agama-lib/share/examples/storage/generate_lvs_extended.json @@ -0,0 +1,43 @@ +{ + "storage": { + "drives": [ + { + "partitions": [ + { + "alias": "pv1", + "id": "lvm", + "size": { "min": "10 GiB" } + } + ] + } + ], + "volumeGroups": [ + { + "name": "system", + "physicalVolumes": ["pv1"], + "logicalVolumes": [ + { + "generate": { + "logicalVolumes": "mandatory", + "encryption": { + "luks2": { + "password": "12345" + } + }, + "stripes": 10, + "stripeSize": "4 KiB" + } + }, + { + "name": "data", + "size": "5 GiB", + "filesystem": { + "path": "/data", + "type": "xfs" + } + } + ] + } + ] + } +} diff --git a/rust/agama-lib/share/examples/storage/generate_partitions.json b/rust/agama-lib/share/examples/storage/generate_partitions.json new file mode 100644 index 000000000..e94dac500 --- /dev/null +++ b/rust/agama-lib/share/examples/storage/generate_partitions.json @@ -0,0 +1,13 @@ +{ + "storage": { + "drives": [ + { + "partitions": [ + { + "generate": "mandatory" + } + ] + } + ] + } +} diff --git a/rust/agama-lib/share/examples/storage/generate_partitions_extended.json b/rust/agama-lib/share/examples/storage/generate_partitions_extended.json new file mode 100644 index 000000000..e8fc2f23e --- /dev/null +++ b/rust/agama-lib/share/examples/storage/generate_partitions_extended.json @@ -0,0 +1,26 @@ +{ + "storage": { + "drives": [ + { + "partitions": [ + { + "size": "10 GiB", + "filesystem": { + "type": "vfat" + } + }, + { + "generate": { + "partitions": "default", + "encryption": { + "luks2": { + "password": "12345" + } + } + } + } + ] + } + ] + } +} diff --git a/rust/agama-lib/share/examples/storage_guided.json b/rust/agama-lib/share/examples/storage/guided.json similarity index 100% rename from rust/agama-lib/share/examples/storage_guided.json rename to rust/agama-lib/share/examples/storage/guided.json diff --git a/rust/agama-lib/share/examples/storage_lvm.json b/rust/agama-lib/share/examples/storage/lvm.json similarity index 100% rename from rust/agama-lib/share/examples/storage_lvm.json rename to rust/agama-lib/share/examples/storage/lvm.json diff --git a/rust/agama-lib/share/examples/storage_sizes.json b/rust/agama-lib/share/examples/storage/sizes.json similarity index 100% rename from rust/agama-lib/share/examples/storage_sizes.json rename to rust/agama-lib/share/examples/storage/sizes.json diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index ffc5970f6..dab5ccc21 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -540,6 +540,37 @@ "type": "array", "items": { "anyOf": [ + { + "$ref": "#/$defs/generateVolumes" + }, + { + "title": "Generate logical volumes", + "description": "Creates the default or mandatory logical volumes configured by the selected product, allowing to customize some properties.", + "type": "object", + "additionalProperties": false, + "required": ["generate"], + "properties": { + "generate": { + "type": "object", + "additionalProperties": false, + "required": ["logicalVolumes"], + "properties": { + "logicalVolumes": { + "enum": ["default", "mandatory"] + }, + "encryption": { + "$ref": "#/$defs/encryption" + }, + "stripes": { + "$ref": "#/$defs/lvStripes" + }, + "stripeSize": { + "$ref": "#/$defs/lvStripeSize" + } + } + } + } + }, { "title": "Logical volume", "type": "object", @@ -555,14 +586,10 @@ "$ref": "#/$defs/size" }, "stripes": { - "title": "Number of stripes", - "type": "integer", - "minimum": 1, - "maximum": 128 + "$ref": "#/$defs/lvStripes" }, "stripeSize": { - "title": "Stripe size", - "$ref": "#/$defs/sizeValue" + "$ref": "#/$defs/lvStripeSize" }, "encryption": { "$ref": "#/$defs/encryption" @@ -594,14 +621,10 @@ "$ref": "#/$defs/size" }, "stripes": { - "title": "Number of stripes", - "type": "integer", - "minimum": 1, - "maximum": 128 + "$ref": "#/$defs/lvStripes" }, "stripeSize": { - "title": "Stripe size", - "$ref": "#/$defs/sizeValue" + "$ref": "#/$defs/lvStripeSize" }, "encryption": { "$ref": "#/$defs/encryption" @@ -1206,11 +1229,48 @@ } } }, + "generateVolumes": { + "title": "Generate volumes automatically", + "description": "Creates the default or mandatory volumes configured by the selected product.", + "type": "object", + "additionalProperties": false, + "required": ["generate"], + "properties": { + "generate": { + "enum": ["default", "mandatory"] + } + } + }, "partitions": { "title": "Partitions", "type": "array", "items": { "anyOf": [ + { + "$ref": "#/$defs/generateVolumes" + }, + { + "title": "Generate partitions", + "description": "Creates the default or mandatory partitions configured by the selected product, allowing to customize some properties.", + "type": "object", + "additionalProperties": false, + "required": ["generate"], + "properties": { + "generate": { + "type": "object", + "additionalProperties": false, + "required": ["partitions"], + "properties": { + "partitions": { + "enum": ["default", "mandatory"] + }, + "encryption": { + "$ref": "#/$defs/encryption" + } + } + } + } + }, { "title": "Partition to create or reuse", "type": "object", @@ -1279,6 +1339,16 @@ } ] } + }, + "lvStripes": { + "title": "Number of stripes", + "type": "integer", + "minimum": 1, + "maximum": 128 + }, + "lvStripeSize": { + "title": "Stripe size", + "$ref": "#/$defs/sizeValue" } } } diff --git a/service/lib/agama/storage/config_conversions/from_json.rb b/service/lib/agama/storage/config_conversions/from_json.rb index 6dbc12f93..26e210765 100644 --- a/service/lib/agama/storage/config_conversions/from_json.rb +++ b/service/lib/agama/storage/config_conversions/from_json.rb @@ -22,17 +22,20 @@ require "agama/config" require "agama/storage/config_builder" require "agama/storage/config_conversions/from_json_conversions/config" +require "agama/storage/config_json_solver" module Agama module Storage module ConfigConversions # Config conversion from JSON hash according to schema. class FromJSON + # TODO: Replace product_config param by a ProductDefinition. + # # @param config_json [Hash] # @param product_config [Agama::Config, nil] def initialize(config_json, product_config: nil) - # TODO: Replace product_config param by a ProductDefinition. - @config_json = config_json + # Copies the JSON hash to avoid changes in the given parameter, see {ConfigJSONSolver}. + @config_json = json_dup(config_json) @product_config = product_config || Agama::Config.new end @@ -41,6 +44,14 @@ def initialize(config_json, product_config: nil) # @return [Storage::Config] def convert # TODO: Raise error if config_json does not match the JSON schema. + # Implementation idea: ConfigJSONChecker class which reports issues if: + # * The JSON does not match the schema. + # * The JSON contains more than one "generate" for partitions and logical volumes. + # * The JSON contains invalid aliases (now checked by ConfigChecker). + ConfigJSONSolver + .new(product_config) + .solve(config_json) + FromJSONConversions::Config .new(config_json, config_builder: config_builder) .convert @@ -54,6 +65,14 @@ def convert # @return [Agama::Config] attr_reader :product_config + # Deep dup of the given JSON. + # + # @param json [Hash] + # @return [Hash] + def json_dup(json) + Marshal.load(Marshal.dump(json)) + end + # @return [ConfigBuilder] def config_builder @config_builder ||= ConfigBuilder.new(product_config) diff --git a/service/lib/agama/storage/config_json_solver.rb b/service/lib/agama/storage/config_json_solver.rb new file mode 100644 index 000000000..cd210b662 --- /dev/null +++ b/service/lib/agama/storage/config_json_solver.rb @@ -0,0 +1,270 @@ +# 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/config" +require "agama/storage/volume_templates_builder" + +module Agama + module Storage + # Class for solving a storage JSON config. + # + # A storage JSON config can contain a "generate" section for automatically generating partitions + # or logical volumes according to the product definition. That section is solved by replacing it + # with the corresponding configs. The solver takes into account other paths already present in + # the rest of the config. + # + # @example + # config_json = { + # drives: [ + # { + # generate: "default" + # }, + # { + # filesystem: { path: "swap" } + # } + # ] + # } + # + # ConfigJSONSolver.new(product_config).solve(config_json) + # config_json # => { + # drives: [ + # { + # filesystem: { path: "/" } + # }, + # { + # filesystem: { path: "/home" } + # }, + # { + # filesystem: { path: "swap" } + # } + # ] + # } + class ConfigJSONSolver + # @param product_config [Agama::Config] + def initialize(product_config = nil) + @product_config = product_config || Agama::Config.new + end + + # Solves the generate section within a given JSON config. + # + # @note The config_json object is modified. + # + # @param config_json [Hash] + def solve(config_json) + @config_json = config_json + + solve_generate + end + + private + + # @return [Hash] + attr_reader :config_json + + # @return [Agama::Config] + attr_reader :product_config + + def solve_generate + configs = configs_with_generate + return unless configs.any? + + expand_generate(configs.first) + configs.each { |c| remove_generate(c) } + end + + # @param config [Hash] Drive or volume group config (e.g., { partitions: [...] }). + def expand_generate(config) + configs = volume_configs(config) + index = configs.index { |v| with_generate?(v) } + + return unless index + + generate_config = configs[index] + configs[index] = volumes_from_generate(generate_config) + configs.flatten! + end + + # @param config [Hash] e.g., { partitions: [...] } + def remove_generate(config) + volume_configs(config).delete_if { |c| with_generate?(c) } + end + + # @param config [Hash] Generate config (e.g., { generate: "default" }). + def volumes_from_generate(config) + if with_generate_default?(config) + missing_default_volumes(config) + elsif with_generate_mandatory?(config) + missing_mandatory_volumes(config) + end + end + + # @param config [Hash] e.g., { generate: "default" } + # @return [Array] + def missing_default_volumes(config) + missing_default_paths.map { |p| volume_from_generate(config, p) } + end + + # @return [Array] + def missing_default_paths + default_paths - current_paths + end + + # @return [Array] + def default_paths + product_config.data.dig("storage", "volumes") || [] + end + + # @return [Array] + def current_paths + configs_with_filesystem + .select { |c| c.is_a?(Hash) } + .map { |c| c.dig(:filesystem, :path) } + .compact + end + + # @param config [Hash] e.g., { generate: "default" } + # @return [Array] + def missing_mandatory_volumes(config) + missing_mandatory_paths.map { |p| volume_from_generate(config, p) } + end + + # @return [Array] + def missing_mandatory_paths + mandatory_paths - current_paths + end + + # @return [Array] + def mandatory_paths + default_paths.select { |p| mandatory_path?(p) } + end + + # @param path [String] + # @return [Volume] + def mandatory_path?(path) + volume_builder.for(path).outline.required? + end + + # @param config [Hash] e.g., { generate: "default" } + # @param path [String] + # + # @return [Hash] + def volume_from_generate(config, path) + volume = { filesystem: { path: path } } + + return volume unless config[:generate].is_a?(Hash) + + generate = config[:generate] + generate.delete(:partitions) + generate.delete(:logicalVolumes) + + volume.merge(generate) + end + + # @return [Array] + def configs_with_generate + configs = drive_configs + volume_group_configs + configs.select { |c| with_volume_generate?(c) } + end + + # @return [Array] + def configs_with_filesystem + drive_configs + partition_configs + logical_volume_configs + end + + # @return [Array] + def drive_configs + config_json[:drives] || [] + end + + # @return [Array] + def volume_group_configs + config_json[:volumeGroups] || [] + end + + # @return [Array] + def partition_configs + drive_configs = config_json[:drives] + return [] unless drive_configs + + drive_configs + .flat_map { |c| c[:partitions] } + .compact + end + + # @return [Array] + def logical_volume_configs + volume_group_configs = config_json[:volumeGroups] + return [] unless volume_group_configs + + volume_group_configs + .flat_map { |c| c[:logicalVolumes] } + .compact + end + + # @param config [Hash] e.g., { partitions: [...] } + # @return [Array] + def volume_configs(config) + config[:partitions] || config[:logicalVolumes] || [] + end + + # @param config [Hash] e.g., { partitions: [...] } + # @return [Boolean] + def with_volume_generate?(config) + volume_configs(config).any? { |c| with_generate?(c) } + end + + # @param config [Hash] + # @return [Booelan] + def with_generate?(config) + !config[:generate].nil? + end + + # @param config [Hash] + # @return [Booelan] + def with_generate_default?(config) + with_generate_value?(config, "default") + end + + # @param config [Hash] + # @return [Booelan] + def with_generate_mandatory?(config) + with_generate_value?(config, "mandatory") + end + + # @param config [Hash] + # @param value [String] + # + # @return [Booelan] + def with_generate_value?(config, value) + generate = config[:generate] + + return generate == value unless generate.is_a?(Hash) + + generate[:partitions] == value || generate[:logicalVolumes] == value + end + + # @return [VolumeTemplatesBuilder] + def volume_builder + @volume_builder ||= VolumeTemplatesBuilder.new_from_config(product_config) + end + end + end +end diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 4a65f5a91..24704a441 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Fri Sep 27 14:15:16 UTC 2024 - José Iván López González + +- Storage: add support for automatically generating 'default' and + 'mandatory' partitions or logical volumes in the storage config + (gh#openSUSE/agama#1634). + ------------------------------------------------------------------- Fri Sep 27 09:23:40 UTC 2024 - Imobach Gonzalez Sosa @@ -7,7 +14,7 @@ Fri Sep 27 09:23:40 UTC 2024 - Imobach Gonzalez Sosa ------------------------------------------------------------------- Mon Sep 23 14:55:53 UTC 2024 - José Iván López González -- storage: add support for resizing partitions using its current +- Storage: add support for resizing partitions using its current size as min or max limit (gh#openSUSE/agama#1617). ------------------------------------------------------------------- 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 29faa215a..a7f5b8631 100644 --- a/service/test/agama/storage/config_conversions/from_json_test.rb +++ b/service/test/agama/storage/config_conversions/from_json_test.rb @@ -1036,5 +1036,466 @@ include_examples "size limits", result end + + context "using 'generate' with 'default' for partitions in a drive" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" } + ] + } + ] + } + end + + it "includes the default partitions defined by the product" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(2) + + root = partitions.find { |p| p.filesystem.path == "/" } + expect(root.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(root.size.default?).to eq(true) + + swap = partitions.find { |p| p.filesystem.path == "swap" } + expect(swap.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::SWAP) + expect(swap.size.default?).to eq(true) + end + + context "if the drive already defines some of the default paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" }, + { + filesystem: { path: "swap" }, + size: "2 GiB" + } + ] + } + ] + } + end + + it "only includes the missing default partitions" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(2) + + root = partitions.find { |p| p.filesystem.path == "/" } + expect(root.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(root.size.default?).to eq(true) + + swap = partitions.find { |p| p.filesystem.path == "swap" } + expect(swap.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::SWAP) + expect(swap.size.default?).to eq(false) + expect(swap.size.min).to eq(2.GiB) + expect(swap.size.max).to eq(2.GiB) + end + end + + context "if there are more than one 'generate'" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" }, + { generate: "default" } + ] + } + ] + } + end + + it "does not include the same partition twice" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(2) + + root = partitions.find { |p| p.filesystem.path == "/" } + expect(root).to_not be_nil + + swap = partitions.find { |p| p.filesystem.path == "swap" } + expect(swap).to_not be_nil + end + end + + context "if there is a 'generate' with 'mandatory'" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" }, + { generate: "mandatory" } + ] + } + ] + } + end + + it "does not include the same partition twice" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(2) + + root = partitions.find { |p| p.filesystem.path == "/" } + expect(root).to_not be_nil + + swap = partitions.find { |p| p.filesystem.path == "swap" } + expect(swap).to_not be_nil + end + end + + context "if other drive already defines some of the default paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" } + ] + }, + { + partitions: [ + { + filesystem: { path: "swap" }, + size: "2 GiB" + } + ] + } + ] + } + end + + it "only includes the missing default partitions" do + config = subject.convert + partitions0 = config.drives[0].partitions + partitions1 = config.drives[1].partitions + + expect(partitions0.size).to eq(1) + + root = partitions0.first + expect(root.filesystem.path).to eq("/") + expect(root.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(root.size.default?).to eq(true) + + expect(partitions1.size).to eq(1) + + swap = partitions1.first + expect(swap.filesystem.path).to eq("swap") + expect(swap.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::SWAP) + expect(swap.size.default?).to eq(false) + expect(swap.size.min).to eq(2.GiB) + expect(swap.size.max).to eq(2.GiB) + end + end + + context "if other drive also contains a 'generate'" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" } + ] + }, + { + partitions: [ + { generate: "default" } + ] + } + ] + } + end + + it "only includes the default partitions in the first drive" do + config = subject.convert + partitions0 = config.drives[0].partitions + partitions1 = config.drives[1].partitions + + expect(partitions0.size).to eq(2) + + root = partitions0.find { |p| p.filesystem.path == "/" } + expect(root).to_not be_nil + + swap = partitions0.find { |p| p.filesystem.path == "swap" } + expect(swap).to_not be_nil + + expect(partitions1.size).to eq(0) + end + end + end + + context "using 'generate' with more properties for partitions in a drive" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + generate: { + partitions: "default", + encryption: { + luks1: { password: "12345" } + } + } + } + ] + } + ] + } + end + + it "includes the default partitions defined by the product with the given properties" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(2) + + root = partitions.find { |p| p.filesystem.path == "/" } + expect(root.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(root.size.default?).to eq(true) + expect(root.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) + expect(root.encryption.password).to eq("12345") + + swap = partitions.find { |p| p.filesystem.path == "swap" } + expect(swap.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::SWAP) + expect(swap.size.default?).to eq(true) + expect(swap.encryption.method).to eq(Y2Storage::EncryptionMethod::LUKS1) + expect(swap.encryption.password).to eq("12345") + end + end + + context "using 'generate' with 'mandatory' for partitions in a drive" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "mandatory" } + ] + } + ] + } + end + + it "includes the mandatory partitions defined by the product" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(1) + + root = partitions.find { |p| p.filesystem.path == "/" } + expect(root.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(root.size.default?).to eq(true) + end + + context "if other device already defines some of the mandatory paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "mandatory" } + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + { + filesystem: { path: "/" } + } + ] + } + ] + } + end + + it "does not include the already defined mandatory paths" do + config = subject.convert + partitions = config.drives.first.partitions + logical_volumes = config.volume_groups.first.logical_volumes + + expect(partitions.size).to eq(0) + end + end + end + + context "using 'generate' with 'default' for logical volumes" do + let(:config_json) do + { + volumeGroups: [ + { + logicalVolumes: [ + { generate: "default" } + ] + } + ] + } + end + + it "includes the default logical volumes defined by the product" do + config = subject.convert + logical_volumes = config.volume_groups.first.logical_volumes + + expect(logical_volumes.size).to eq(2) + + root = logical_volumes.find { |v| v.filesystem.path == "/" } + expect(root.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(root.size.default?).to eq(true) + + swap = logical_volumes.find { |v| v.filesystem.path == "swap" } + expect(swap.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::SWAP) + expect(swap.size.default?).to eq(true) + end + + context "if other device already defines any of the default paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + { generate: "default" } + ] + } + ] + } + end + + it "does not include the already defined default paths" do + config = subject.convert + logical_volumes = config.volume_groups.first.logical_volumes + + expect(logical_volumes.size).to eq(1) + + swap = logical_volumes.first + expect(swap.filesystem.path).to eq("swap") + end + end + end + + context "using 'generate' with 'mandatory' for logical volumes" do + let(:config_json) do + { + volumeGroups: [ + { + logicalVolumes: [ + { generate: "mandatory" } + ] + } + ] + } + end + + it "includes the mandatory logical volumes defined by the product" do + config = subject.convert + logical_volumes = config.volume_groups.first.logical_volumes + + expect(logical_volumes.size).to eq(1) + + root = logical_volumes.first + expect(root.filesystem.path).to eq("/") + expect(root.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::BTRFS) + expect(root.size.default?).to eq(true) + end + + context "if other device already defines any of the mandatory paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/" } + } + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + { generate: "mandatory" } + ] + } + ] + } + end + + it "does not include the already defined mandatory paths" do + config = subject.convert + logical_volumes = config.volume_groups.first.logical_volumes + + expect(logical_volumes.size).to eq(0) + end + end + end + + context "using both 'generate' with 'default' and with 'mandatory'" do + let(:config_json) do + { + drives: [ + { + partitions: [ + first_generate, + second_generate + ] + } + ] + } + end + + context "if 'default' appears first" do + let(:first_generate) { { generate: "default" } } + let(:second_generate) { { generate: "mandatory" } } + + it "includes the default partitions defined by the product" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(2) + + root = partitions.find { |p| p.filesystem.path == "/" } + swap = partitions.find { |p| p.filesystem.path == "swap" } + + expect(root).to_not be_nil + expect(swap).to_not be_nil + end + end + + context "if 'mandatory' appears first" do + let(:first_generate) { { generate: "mandatory" } } + let(:second_generate) { { generate: "default" } } + + it "includes the mandatory partitions defined by the product" do + config = subject.convert + partitions = config.drives.first.partitions + + expect(partitions.size).to eq(1) + + root = partitions.find { |p| p.filesystem.path == "/" } + expect(root).to_not be_nil + end + end + end end end diff --git a/service/test/y2storage/agama_proposal_test.rb b/service/test/y2storage/agama_proposal_test.rb index da1922159..e31e7902a 100644 --- a/service/test/y2storage/agama_proposal_test.rb +++ b/service/test/y2storage/agama_proposal_test.rb @@ -219,6 +219,43 @@ def partition_config(name: nil, filesystem: nil, size: nil) end end + context "when only 'default' partitions is specified" do + let(:scenario) { "empty-hd-50GiB.yaml" } + + let(:config_json) do + { + drives: [ + { + partitions: [ + { generate: "default" } + ] + } + ] + } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + expect(devicegraph.partitions.size).to eq(3) + + boot = devicegraph.find_by_name("/dev/sda1") + expect(boot.id).to eq(Y2Storage::PartitionId::BIOS_BOOT) + expect(boot.filesystem).to be_nil + expect(boot.size).to eq(8.MiB) + + root = devicegraph.find_by_name("/dev/sda2") + expect(root.filesystem.mount_path).to eq("/") + expect(root.filesystem.type.is?(:btrfs)).to eq(true) + expect(root.size).to be_between(44.99.GiB, 45.GiB) + + swap = devicegraph.find_by_name("/dev/sda3") + expect(swap.filesystem.mount_path).to eq("swap") + expect(swap.filesystem.type.is?(:swap)).to eq(true) + expect(swap.size).to be_between(4.99.GiB, 5.GiB) + end + end + context "when the config has 2 drives" do let(:scenario) { "disks.yaml" }