From bd9ab6f7804c7af19c292653579d18d63940b182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Iv=C3=A1n=20L=C3=B3pez=20Gonz=C3=A1lez?= Date: Thu, 26 Sep 2024 09:28:14 +0100 Subject: [PATCH] storage: resolve JSON keywords --- .../storage/config_conversions/from_json.rb | 10 + .../lib/agama/storage/config_json_solver.rb | 263 +++++++++++ .../config_conversions/from_json_test.rb | 412 ++++++++++++++++++ service/test/y2storage/agama_proposal_test.rb | 37 ++ 4 files changed, 722 insertions(+) create mode 100644 service/lib/agama/storage/config_json_solver.rb diff --git a/service/lib/agama/storage/config_conversions/from_json.rb b/service/lib/agama/storage/config_conversions/from_json.rb index 6dbc12f93..4dc21f225 100644 --- a/service/lib/agama/storage/config_conversions/from_json.rb +++ b/service/lib/agama/storage/config_conversions/from_json.rb @@ -22,6 +22,7 @@ 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 @@ -41,6 +42,15 @@ 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 both "default" and "mandatory" for partitions or logical volumes. + # * The JSON contains "default" or "mandatory" more than once. + # * 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 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..a12a5351b --- /dev/null +++ b/service/lib/agama/storage/config_json_solver.rb @@ -0,0 +1,263 @@ +# 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 keywords like "default" or "mandatory" for automatically + # generating partitions or logical volumes according to the product definition. That keywords + # are solved by replacing them by the corresponding configs. The solver takes into account other + # paths already present in the rest of the config. + # + # @example + # config_json = { + # drives: [ + # "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 all the keywords within a given config. + # + # @note The config_json object is modified. + # + # @param config_json [Hash] + def solve(config_json) + @config_json = config_json + + solve_keywords + end + + private + + # @return [Hash] + attr_reader :config_json + + # @return [Agama::Config] + attr_reader :product_config + + def solve_keywords + drives_with_keyword.each { |c| solve_partitions_keyword(c) } + volume_groups_with_keyword.each { |c| solve_logical_volumes_keyword(c) } + end + + # @param config [Hash] + def solve_partitions_keyword(config) + partitions = config[:partitions] + return unless partitions + + solve_keyword(partitions) + end + + # @param config [Hash] + def solve_logical_volumes_keyword(config) + logical_volumes = config[:logicalVolumes] + return unless logical_volumes + + solve_keyword(logical_volumes) + end + + # @param configs [Array] + def solve_keyword(configs) + if with_default_keyword?(configs) + solve_default_keyword(configs) + elsif with_mandatory_keyword?(configs) + solve_mandatory_keyword(configs) + end + end + + # @param configs [Array] + def solve_default_keyword(configs) + configs.delete("default") + configs.delete("mandatory") + configs.concat(missing_default_configs) + end + + # @param configs [Array] + def solve_mandatory_keyword(configs) + configs.delete("mandatory") + configs.concat(missing_mandatory_configs) + end + + # @return [Array] + def missing_default_configs + missing_default_paths.map { |p| volume_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 + + # @return [Array] + def missing_mandatory_configs + missing_mandatory_paths.map { |p| volume_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 path [String] + # @return [Hash] + def volume_config(path) + { filesystem: { path: path } } + end + + # @return [Array] + def drives_with_keyword + drive_configs.select { |c| with_partitions_keyword?(c) } + end + + # @return [Array] + def volume_groups_with_keyword + volume_group_configs.select { |c| with_logical_volumes_keyword?(c) } + end + + # @param config [Hash] + # @return [Boolean] + def with_partitions_keyword?(config) + partitions = config[:partitions] + return false unless partitions + + with_keyword?(partitions) + end + + # @param config [Hash] + # @return [Boolean] + def with_logical_volumes_keyword?(config) + logical_volumes = config[:logicalVolumes] + return false unless logical_volumes + + with_keyword?(logical_volumes) + end + + # @param configs [Array] + # @return [Boolean] + def with_keyword?(configs) + with_default_keyword?(configs) || with_mandatory_keyword?(configs) + end + + # @param configs [Array] + # @return [Boolean] + def with_default_keyword?(configs) + configs.include?("default") + end + + # @param configs [Array] + # @return [Boolean] + def with_mandatory_keyword?(configs) + configs.include?("mandatory") + 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 + + # @return [VolumeTemplatesBuilder] + def volume_builder + @volume_builder ||= VolumeTemplatesBuilder.new_from_config(product_config) + end + 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 29faa215a..dd0d9def1 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,417 @@ include_examples "size limits", result end + + context "using the 'default' keyword for partitions in a drive" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "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 any of the default paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "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 the drive contains the 'default' keyword several times" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "default", + "default", + "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 the drive also contains the 'mandatory' keyword" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "default", + "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 any of the default paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "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 the 'default' keyword" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "default" + ] + }, + { + partitions: [ + "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 + + context "if other device already defines any of the default paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "default" + ] + } + ], + volumeGroups: [ + { + logicalVolumes: [ + { + filesystem: { path: "swap" }, + size: "2 GiB" + } + ] + } + ] + } + end + + it "only includes the missing default partitions" do + config = subject.convert + partitions = config.drives.first.partitions + logical_volumes = config.volume_groups.first.logical_volumes + + expect(partitions.size).to eq(1) + expect(logical_volumes.size).to eq(1) + + root = partitions.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 + end + end + + context "using the 'mandatory' keyword for partitions in a drive" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "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 any of the mandatory paths" do + let(:config_json) do + { + drives: [ + { + partitions: [ + "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 the 'default' keyword for logical volumes" do + let(:config_json) do + { + volumeGroups: [ + { + logicalVolumes: [ + "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: [ + "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 the 'mandatory' keyword for logical volumes" do + let(:config_json) do + { + volumeGroups: [ + { + logicalVolumes: [ + "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: [ + "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 end end diff --git a/service/test/y2storage/agama_proposal_test.rb b/service/test/y2storage/agama_proposal_test.rb index da1922159..dafde8f6c 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: [ + "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" }