diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index d6802c9c13..45a8efe234 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -70,6 +70,7 @@ jobs: - name: Install required packages run: zypper --non-interactive install clang-devel + dbus-1-daemon jq libopenssl-3-devel openssl-3 diff --git a/doc/auto_storage.md b/doc/auto_storage.md index e7cd92ebea..e3cd635f8e 100644 --- a/doc/auto_storage.md +++ b/doc/auto_storage.md @@ -3,19 +3,28 @@ This document describes Agama's approach to configure storage using a profile for unattended installation. -## Legacy AutoYaST Specification +## Agama and AutoYaST -The Agama profile can contain either a `storage` section or a `legacyAutoyastStorage` one. +AutoYaST profiles can be used with Agama offering a 100% backward compatibility. -The rest of this document describes the `storage` section. +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. -That `legacyAutoyastStorage` is a 1:1 representation of the XML specification of AutoYaST. No -JSON validation will be performed for it. +~~~json +{ + "legacyAutoyastStorage": [ + { + "use": "all", + "partitions": [] + } + ] +} +~~~ ### 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 +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 @@ -27,7 +36,153 @@ specify the size of a partition or to determine the default list of subvolumes w 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. -## Basic Structure of the Storage Section +### Problems with the AutoYaST Schema + +The AutoYaST schema is far from ideal and it presents some problems. + +#### Everything Is a Drive or a Partition Section + +This could seem a minor detail, but it has several implications: + +* A `` property is required to indicated the type of device (*RAID*, *LVM*, etc). +* Some properties could be meaningless for the selected type. +* Having a `` section for describing logical volumes is weird. + +~~~xml + + + CT_LVM + gpt + + + 131 + + + + +~~~ + +#### Directly Formatting Devices is Hammered + +A `` section is still needed for directly formatting a device, which shows the abuse of +the schema. + +~~~xml + + + none + + + btrfs + + + + +~~~ + +#### Selecting Devices is Difficult and Limited + +The AutoYaST schema allows selecting specific devices by using the `` property. This +forces to use inverse logic when looking for a device. For example, if you want to select a disk +bigger than 1 GiB, then you have to skip the smaller disks: + +~~~xml + + + + + + size_k + 1048576 + true + + + + +~~~ + +The partitions to remove are selected by means of the `` property, which is very limited. It +only allows removing everything, nothing, specific partition numbers or linux partitions. + +~~~xml + + + /dev/sdc + 2 + + ... + + + +~~~ + +The property `` is used for reusing a partition. Again, this option is very limited, +allowing selecting a partition only by its number. + +~~~xml + + + /dev/sdc + + + 1 + + + + +~~~ + +Note that you could indicate the same partition number for deleting (``) and for reusing (``). + +#### Devices Are Created in a Indirect Way + +For creating new LVM volume groups, RAIDS, etc, it is necessary to indicate which devices to use as +logical volumes or as RAID members. In AutoYaST, the partitions have to indicate the device they are +going to be used by. + +~~~xml + + + /dev/sda + + + /dev/md/0 + + + + + /dev/sdb + + + /dev/md/0 + + + + + /dev/md/0 + + +~~~ + +It would be more natural to indicate the used devices directly in the RAID or logical volume drive. + +## The New Storage Schema + +Agama offers its own storage schema which is more semantic, comprehensive and flexible than the +AutoYaST one. + +The new schema allows: + +* To clearly distinguish between different types of devices and their properties. +* To perform more advanced searches for disks, partitions, etc. +* To indicate deleting and resizing on demand. + +The Agama schema is used by a new Agama specific proposal. This decouples the algorithm from the +AutoYaST one, making much easier to support new use cases and avoiding backward compatibility with +fringe AutoYaST scenarios. It also supports some features that are not available in the AutoYaST +proposal like deleting or resizing partitions on demand. + +### Basic Structure of the Storage Section A formal specification of the outer level of the `storage` section would look like this. @@ -37,14 +192,14 @@ Storage volumeGroups mdRaids btrfsRaids - bcacheDevices nfsMounts - guided + boot [BootSettings] + encryption [EncryptionSettings] ``` Thus, a `storage` section can contain several entries describing how to configure the corresponding -storage devices and an extra entry used to execute the Guided Proposal in top of the scenario -described by the device entries. +storage devices and a couple of extra entries to setup some general aspects that influence the final +layout. Each volume group, RAID, bcache device or NFS share can represent a new logical device to be created or an existing device from the system to be processed. Entries below `drives` represent devices @@ -55,22 +210,33 @@ found at the system, since Agama cannot create that kind of devices. In fact, a single entry can represent several devices from the system. That is explained in depth at the section "searching existing devices" of this document. -## Entries for Describing the Devices +On the first versions of Agama, an alternative syntax will be accepted including only one `guided` +entry. + +``` +Storage + guided +``` + +That allows to rely on the YaST component known as `GuidedProposal`. That alternative will be +removed as soon as all the capabilities of that `GuidedProposal` could be expressed in terms of a +regular storage configuration like the one explained above. + +### Entries for Describing the Devices The formal specification of the previous section can be extended as we dive into the structure. ``` Drive - search: [] + search [] alias [] - encrypt [] - format [] - mount [] + encryption [] + filesystem [] ptableType [] partitions [] VolumeGroup - search: [] + search [] alias [] name [] peSize [] @@ -79,21 +245,20 @@ VolumeGroup delete [] MdRaid - search: [] + search [] alias [] name level [] chunkSize [] devices [<[]>] - encrypt [] - format [] - mount [] + encryption [] + filesystem [Filesystem] ptableType [] partitions [] delete [] BtrfsRaid - search: [] + search [] alias [] dataRaidLevel metadataRaidLevel @@ -109,15 +274,14 @@ NFS mount [] Partition - search: [] + search [] alias [] id [] - type [] size [] - encrypt [EncryptAction] - format [] - mount [] + encryption [Encryption] + filesystem [] delete [] + deleteIfNeeded [] LogicalVolume search [] @@ -128,25 +292,36 @@ LogicalVolume usedPool [] stripes [] stripSize [] - encrypt [] - format [] - mount [] + encryption [Encryption] + filesystem [] delete [] + deleteIfNeeded [] +Encryption + reuse + type -EncryptAction - method - key [] - pdkdf [] - label [] +EncryptionType + +EncryptionLUKS1 + password + keySize [] cipher [] + +EncryptionLUKS2 + password keySize [] + cipher [] + pdkdf [] + label [] + +EncryptionPervasiveLUKS2 + password -FormatAction - filesystem +Filesystem + reuse + type label [] mkfsOptions [] - -MountAction path mountOptions [] mountBy [] @@ -162,6 +337,17 @@ Size <'default'|string|SizeRange> SizeRange min max + +BootSettings + configure + device + +EncryptionSettings + method + key [] + pdkdf [] + cipher [] + keySize [] ``` To illustrate how all that fits together, let's see the following example in which the first disk of @@ -173,13 +359,12 @@ it) to allocate two file systems. "drives": [ { "partitions": [ - { + { "alias": "pv", "id": "lvm", "size": { "min": "12 GiB" }, - "encrypt": { - "method": "luks2", - "key": "my secret passphrase" + "encryption": { + "luks2": { "password": "my secret passphrase" } } } ] @@ -192,13 +377,11 @@ it) to allocate two file systems. "logicalVolumes": [ { "size": { "min": "10 GiB" }, - "format": { "filesystem": "btrfs" }, - "mount": { "path": "/" } + "filesystem": { "path": "/", "type": "btrfs" } }, { "size": "2 GiB", - "format": { "filesystem": "swap" }, - "mount": { "path": "swap" } + "filesystem": { "path": "swap", "type": "swap" } } ] } @@ -206,7 +389,7 @@ it) to allocate two file systems. } ``` -## Specifying the Size of a Device +### Specifying the Size of a Device When creating some kinds of devices or resizing existing ones (if possible) it may be necessary to specify the desired size. As seen in the specification above, that can be done in several ways. @@ -228,7 +411,11 @@ into the following: - If the product does not specify a default volume, the behavior is still not defined (there are several reasonable options). -### Under Discussion +It is also possible to specify "current" as a size value for partitions and logical volumes that +already exist in the system. The usage of "current" and how it affects resizing the corresponding +devices is explained at a separate section below. + +#### Under Discussion As explained, it should be possible to specify the sizes as "default", as a range or as a fixed value. But in the last two cases, a parseable string like "40 GiB" may not be the only option to @@ -237,7 +424,13 @@ represent a size. The following two possibilities are also under consideration. - `{ "gib": 40 }` - `{ "value": 40, "units": "gib" }` -## Searching Existing Devices +### Partitions Needed for Booting + +Using a `boot` entry makes it possible to configure whether (and where, using an alias) Agama +should calculate and create the extra partitions needed for booting. If the device is not +specified, Agama will take the location of the root file system as a reference. + +### Searching Existing Devices Many sections in the profile are used to describe how some devices must be created, modified or even deleted. In the last two cases, it's important to match the description with one or more devices @@ -304,7 +497,7 @@ within them and create new partitions of type RAID. }, "delete": true }, - { + { "alias": "newRaidPart", "id": "raid", "size": { "min": "1 GiB" } @@ -380,7 +573,7 @@ drive, it will be considered to contain the following one. } ``` -### Under Discussion +#### Under Discussion Very often, `search` will be used to find a device by its name. In that case, the syntax could be simplified to just contain the device name as string. @@ -420,7 +613,7 @@ above, it would be possible to use the key as name of the property, resulting in } ``` -## Referencing Other Devices +### Referencing Other Devices Sometimes is necessary to reference other devices as part of the specification of an LVM volume group or RAID. Those can be existing system devices or devices that will be created as response to @@ -495,7 +688,7 @@ to be a reference to all the devices. As a consequence, this two examples are eq } ``` -### Under Discussion +#### Under Discussion In addition to aliases, a `search` section could be accepted in all the places in which an alias can be used. In that case, the scope of the search would always be the whole set of devices in the @@ -516,23 +709,153 @@ system (so the same conditions can be matched by a disk, a partition, an LVM dev } ``` -## Partitions needed for Booting +### Keeping an Existing File System or Encryption Layer + +The entries for both `encryption` and `filesystem` contain a flag `reuse` with a default value of +false. It can be used in combination with `search` to specify the device must not be re-encrypted +or re-formatted. + +### Deleting and Shrinking Existing Devices + +The storage proposal must make possible to define what to do with existing partitions and logical +volumes. Even with existing MD RAIDs or LVM volume groups. + +A `search` section allows to match the definition of a partition or an LVM logical volume with one +(or several) devices existing in the system. In order to provide the same capabilities than the +Guided proposal (see below) it must be possible to specify that a given partition or volume must be: + + - Deleted if needed to make space for the newly defined devices + - Deleted in all cases + - Shrunk to the necessary size to make space for new devices + - Shrunk or extended to a given size, maybe a range (not really possible in the current Guided + Proposal) + +It is even possible to express some combinations of the above, like "try to shrink it to make space +but proceed to delete it if shrinking it is not enough". + +Deletion can be achieved with the corresponding `delete` flag or the alternative `deleteIfNeeded`. +If any of those flags are active for a partition, it makes no sense to specify any other usage +(like declaring a file system on it). -When relying on the Agama proposal (see below), there are some options to configure whether (and -where) Agama should calculate and create the extra partitions needed for booting. +The following example deletes the partition with the label "root" in all cases and, if needed, keeps +deleting other partitions as needed to make space for the new partition of 30 GiB. -If the proposal is not used, Agama will always try to calculate and create those partitions taking -the location of the root file system as a reference. That's the same approach that AutoYaST has -followed for years. +```json +"storage": { + "drives": [ + { + "partitions": [ + { + "search": { + "condition": { "property": "fsLabel", "value": "root" } + }, + "delete": true + }, + { "search": {}, "deleteIfNeeded": true }, + { "size": "30 GiB" } + ] + } + ] +} +``` -## Using the Automatic Proposal +Often some partitions or logical volumes are shrunk only to make space for the declared devices. But +since resizing is not a destructive operation, it can also make sense to declare a given partition +must be resized (shrunk or extended) and then formatted and/or mounted. -Agama can rely on the process known as Guided Proposal to calculate all the needed partitions, LVM -devices and file systems based on some general product settings and some user preferences. That -mechanism can also be used as part of the profile and will be executed as a last step, after -processing all the explicit sections that describe devices. +In any case, note that resizing a partition can be limited depending on its content, the filesystem +type, etc. -The `guided` section conforms to the following specification. +Combining `search` and `resize` is enough to indicate Agama is expected to resize a given partition +if possible. The keyword "current" can be used eveywhere a size is expected and it is always +equivalent to the exact original size of the device. The simplest way to use "current" is to just +specify that the matched device should keep its original size. That's the default for searched (and +found) devices if `size` is completely omitted. + +```json +"storage": { + "drives": [ + { + "partitions": [ + { + "search": { + "condition": { "property": "fsLabel", "value": "reuse" } + }, + "size": "current" + } + ] + } + ] +} +``` + +Using "current" for the min and max values of a size allows to specify how a device could be resized +if possible. See the following examples with explanatory filesystem labels. + +```json +"storage": { + "drives": [ + { + "partitions": [ + { + "search": { + "condition": { "property": "fsLabel", "value": "shrinkIfNeeded" } + }, + "size": { "min": 0, "max": "current" } + }, + { + "search": { + "condition": { "property": "fsLabel", "value": "resizeToFixedSize" } + }, + "size": "15 GiB" + }, + { + "search": { + "condition": { "property": "fsLabel", "value": "resizeByRange" } + }, + "size": { "min": "10 GiB", "max": "50 GiB" } + }, + { + "search": { + "condition": { "property": "fsLabel", "value": "growAsMuchAsPossible" } + }, + "size": { "min": "current" } + }, + ] + } + ] +} +``` + +Of course, when the size limits are specified as a combination of "current" and a fixed value, the +user must still make sure that the resulting min is not bigger than the resulting max. + +Both `deleteIfNeeded` and a size range can be combined to indicate that Agama should try to make +space first by shrinking the partitions and deleting them only if shrinking is not enough. + +```json +"storage": { + "drives": [ + { + "partitions": [ + { + "search": {}, + "size": { "min": 0, "max": "current" }, + "deleteIfNeeded": true + } + ] + } + ] +} +``` + +### Using the Automatic Proposal + +On the first implementations, Agama can rely on the process known as Guided Proposal to calculate +all the needed partitions, LVM devices and file systems based on some general product settings and +some user preferences. That mechanism is offered as a temporary alternative to the more descriptive +syntax explained at previous sections of this document and it's implemented via a `guided` section +that conforms to the following specification. ``` Guided @@ -545,22 +868,13 @@ Guided TargetDevice TargetDisk - disk - + disk + TargetNewLvm - newLvmVg <[]> + newLvmVg TargetReusedLvm - reusedLvmVg - -BootSettings - configure - device - -EncryptionSettings - password - method - pbkdFunction + reusedLvmVg Volume mountPath @@ -575,16 +889,16 @@ Volume VolumeTarget <'default'|NewPartition|NewVg|UseDevice|UseFilesystem> NewPartition - newPartition - + newPartition + NewVg - newVg - + newVg + UseDevice - device + device UseFilesystem - filesystem + filesystem ``` The `device` can be specified in several ways. The simplest one is using one of the strings "disk" @@ -606,16 +920,13 @@ And this will do the same, but creating a new LVM volume group on that first can } ``` -It's also possible to use an alias to specify a concrete disk... +It's also possible to use a device name to specify a concrete disk... ```json "storage": { - "drives": [ - { "alias": "target" } - ], "guided": { "device": { - "disk": "target" + "disk": "/dev/sda" } } } @@ -625,39 +936,16 @@ or to specify the set of disks where the LVM physical volumes can be created. ```json "storage": { - "drives": [ - { - "alias": "nvme", - "search": { "condition": { "property": "driver", "value": "nvme" } } - } - ], "guided": { "device": { - "newLvmVg": ["nvme"] + "newLvmVg": ["/dev/vda", "/dev/vdb"] } } } ``` -The alias can correspond to devices that are created by Agama itself. - -```json -"storage": { - "mdRaids": [ - { - "alias": "newMd" - "devices": [ "..." ], - "level": "raid1" - } - ], - "guided": { - "device": { "disk": "newMd" } - } -} -``` - -Apart from specifying the main target device, aliases can be used wherever a device is expected, eg. -when indicating a special target for a given volume. +Apart from specifying the main target device, device names must be used wherever a device is +expected, eg. when indicating a special target for a given volume. In principle, the list of volumes will have the same format than the existing HTTP API used by the UI for calculating the storage proposal. That is, if the list is not provided the default @@ -666,37 +954,9 @@ with default values. In the future we may consider more advanced mechanisms to i some given volumes or to customize a single volume without having to provide the full list of volume mount paths. -Combining the `guided` section with other possible sections in the profile makes it possible to -achieve the same results than using the Agama user interface with only one exception. The Agama UI -allows to indicate that a given set of partitions can be resized if needed to allocate the volumes, -without actually indicating how much those partitions should be resized. The Guided Proposal -algorithm decides whether to resize and how much based on the other settings. Currently there is no -way to express that in the auto-installation profile. - -### Under Discussion - -It could also be possible to accept a `search` element in all places in which an alias can be used. - -```json -"storage": { - "guided": { - "device": { - "newLvmVg": [ - { "search": { "condition": { "property": "driver", "value": "nvme" } } } - ] - } - } -} -``` - -Even combining that with the string-based syntax suggested for `search`. - -```json -"storage": { - "guided": { - "device": { - "disk": { "search": "/dev/sda" } - } - } -} -``` +The `guided` section makes it possible to achieve the same results than using the Agama user +interface with only one exception. The Agama UI allows to indicate that a given set of partitions +can be resized if needed to allocate the volumes, without actually indicating how much those +partitions should be resized. The Guided Proposal algorithm decides whether to resize and how much +based on the other settings. Currently there is no way to express that in the auto-installation +profile. diff --git a/live/root/root/.mozilla/firefox/profile/user.js.template b/live/root/root/.mozilla/firefox/profile/user.js.template index 41166d8ab7..df01dc2ffa 100644 --- a/live/root/root/.mozilla/firefox/profile/user.js.template +++ b/live/root/root/.mozilla/firefox/profile/user.js.template @@ -5,6 +5,9 @@ user_pref("signon.management.page.breach-alerts.enabled", false); user_pref("signon.rememberSignons", false); user_pref("signon.generation.enabled", false); +// disable the initial configuration workflow +user_pref("browser.aboutwelcome.enabled", false); + // start always in the custom homepage user_pref("browser.startup.page", 1); // custom homepage: the value is expected to be replaced with the login URL by the startup script diff --git a/live/src/agama-installer.changes b/live/src/agama-installer.changes index 2dedebab85..fac9045999 100644 --- a/live/src/agama-installer.changes +++ b/live/src/agama-installer.changes @@ -1,3 +1,27 @@ +------------------------------------------------------------------- +Wed Sep 4 07:08:30 UTC 2024 - Ladislav Slezák + +- Install Firefox on all architectures, install + MozillaFirefox-branding-SLE in the SLE image + (gh#openSUSE/agama#1574) + +------------------------------------------------------------------- +Tue Sep 3 14:50:58 UTC 2024 - Ladislav Slezák + +- Firefox: disable the initial configuration workflow + (gh#openSUSE/agama#1573) + +------------------------------------------------------------------- +Fri Aug 30 13:05:53 UTC 2024 - Thomas Blume + +- remove memcheck workaround in images.sh (bsc#1228621) + +------------------------------------------------------------------- +Mon Aug 26 10:05:01 UTC 2024 - Imobach Gonzalez Sosa + +- Include the procps instead of the procps4 package + (jsc#PED-8669, gh#openSUSE/agama#1554). + ------------------------------------------------------------------- Mon Aug 19 17:54:55 UTC 2024 - Josef Reidinger diff --git a/live/src/agama-installer.kiwi b/live/src/agama-installer.kiwi index 9fa1568ff6..10fa3e357e 100644 --- a/live/src/agama-installer.kiwi +++ b/live/src/agama-installer.kiwi @@ -95,7 +95,7 @@ - + @@ -133,8 +133,7 @@ - - + @@ -168,6 +167,7 @@ + @@ -177,6 +177,7 @@ + diff --git a/live/src/images.sh b/live/src/images.sh deleted file mode 100644 index a225f1470b..0000000000 --- a/live/src/images.sh +++ /dev/null @@ -1,19 +0,0 @@ -#! /bin/bash - -echo 'add memory size check for livenetroot installation' - -[[ -f /usr/lib/dracut/modules.d/90livenet/livenetroot.sh ]] || exit 1 - -echo 'let memsize=$(($(sed -n "s/MemTotal: *\([[:digit:]]*\).*/\1/p" /proc/meminfo) / 1024))' > /tmp/livenetroot-mod -echo 'let imgsize=$(($(curl -sI "$liveurl" | sed -n "s/Content-Length: *\([[:digit:]]*\).*/\1/p") / (1024 * 1024)))' >> /tmp/livenetroot-mod -echo '' >> /tmp/livenetroot-mod -echo 'if [ $((memsize - imgsize)) -lt 1024 ]; then' >> /tmp/livenetroot-mod -echo " sed -i 'N;/echo \"\$RDSOSREPORT\"/s/echo$/echo\\" >> /tmp/livenetroot-mod -echo ' echo Warning!!!\' >> /tmp/livenetroot-mod -echo ' echo The memory size of your system is too small for this live image.\' >> /tmp/livenetroot-mod -echo ' echo Expect killed processes due to out of memory conditions.\' >> /tmp/livenetroot-mod -echo " echo /' usr/bin/dracut-emergency" >> /tmp/livenetroot-mod -echo ' emergency_shell' >> /tmp/livenetroot-mod -echo 'fi' >> /tmp/livenetroot-mod - -sed -i '/info "fetching $liveurl"$/ r /tmp/livenetroot-mod' /usr/lib/dracut/modules.d/90livenet/livenetroot.sh diff --git a/products.d/agama-products.changes b/products.d/agama-products.changes index d817595222..76c719dd65 100644 --- a/products.d/agama-products.changes +++ b/products.d/agama-products.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Tue Sep 3 10:24:30 UTC 2024 - Lubos Kocman + +- Add kde to Leap 16.0, remove Xfce until it's fully submitted to 16.0 + initial set of ~800 KIDE related packages were just merged to Leap 16.0 + ------------------------------------------------------------------- Wed Aug 21 19:07:28 UTC 2024 - Lubos Kocman diff --git a/products.d/leap_160.yaml b/products.d/leap_160.yaml index b26db1ab92..bd780bc9cd 100644 --- a/products.d/leap_160.yaml +++ b/products.d/leap_160.yaml @@ -28,6 +28,8 @@ translations: последней версии SUSE Linux Enterprise Server. sv: Leap 16.0 är den senaste versionen av en distribution skapad av gemenskapen som baseras på den senaste SUSE Linux Enterprise Server. + tr: Leap 16.0, en son SUSE Linux Enterprise Server'ı temel alan bir topluluk + dağıtımının en son sürümüdür. zh_Hans: Leap 16.0 是基于 SUSE Linux Enterprise Server 构建的社区发行版的最新版本。 software: installation_repositories: @@ -38,8 +40,8 @@ software: optional_patterns: null # no optional pattern shared user_patterns: - basic_desktop - - xfce - gnome + - kde - yast2_basis - yast2_desktop - yast2_server diff --git a/products.d/sles_160.yaml b/products.d/sles_160.yaml index 39b249a4a7..3b14ae23b0 100644 --- a/products.d/sles_160.yaml +++ b/products.d/sles_160.yaml @@ -38,6 +38,11 @@ translations: affärskontinuitet. Detta är det säkra och anpassningsbara operativsystemet för långsiktigt stöd, innovationsfärdig infrastruktur som kör affärskritiska arbetsbelastningar på plats, i molnet och vid kanten. + tr: SUSE Linux Enterprise Server, kuruluşun iş sürekliliğini garanti eden açık, + güvenilir, uyumlu ve geleceğe yönelik Linux Sunucu seçeneğidir. Şirket + içinde, bulutta ve uçta iş açısından kritik iş yüklerini çalıştıran uzun + vadeli desteklenen, inovasyona hazır altyapı için güvenli ve uyarlanabilir + işletim sistemidir. software: installation_repositories: - url: http://download.suse.de/ibs/SUSE:/SLFO:/Products:/SLES:/16.0:/TEST/product/repo/SLES-Packages-16.0-x86_64/ diff --git a/rust/agama-lib/share/examples/autoyast.json b/rust/agama-lib/share/examples/autoyast.json new file mode 100644 index 0000000000..4eb5099b8f --- /dev/null +++ b/rust/agama-lib/share/examples/autoyast.json @@ -0,0 +1,8 @@ +{ + "legacyAutoyastStorage": [ + { + "device": "/dev/vdc", + "use": "all" + } + ] +} diff --git a/rust/agama-lib/share/examples/profile_tw.json b/rust/agama-lib/share/examples/profile_tw.json index ab3f5d564f..c783a9972d 100644 --- a/rust/agama-lib/share/examples/profile_tw.json +++ b/rust/agama-lib/share/examples/profile_tw.json @@ -26,7 +26,7 @@ }, "root": { "password": "nots3cr3t", - "sshKey": "..." + "sshPublicKey": "..." }, "network": { "connections": [ diff --git a/rust/agama-lib/share/examples/storage-guided.json b/rust/agama-lib/share/examples/storage-guided.json new file mode 100644 index 0000000000..573aed64d2 --- /dev/null +++ b/rust/agama-lib/share/examples/storage-guided.json @@ -0,0 +1,63 @@ +{ + "storage": { + "guided": { + "target": { + "disk": "/dev/vdc" + }, + "boot": { + "configure": true, + "device": "/dev/vda" + }, + "encryption": { + "password": "notsecret", + "method": "luks2", + "pbkdFunction": "argon2i" + }, + "space": { + "policy": "custom", + "actions": [ + { "resize": "/dev/vda" }, + { "forceDelete": "/dev/vdb1" } + ] + }, + "volumes": [ + { + "mount": { + "path": "/", + "options": ["ro"] + }, + "filesystem": { + "btrfs": { + "snapshots": true + } + }, + "size": [1024, "5 Gib"], + "target": "default" + }, + { + "mount": { + "path": "/home" + }, + "filesystem": "xfs", + "size": { + "min": "5 GiB", + "max": "20 GiB" + }, + "target": { + "newVg": "/dev/vda" + } + }, + { + "mount": { + "path": "swap" + }, + "filesystem": "swap", + "size": "8 GiB", + "target": { + "newPartition": "/dev/vda" + } + } + ] + } + } +} diff --git a/rust/agama-lib/share/examples/storage.json b/rust/agama-lib/share/examples/storage.json index 573aed64d2..833768a83b 100644 --- a/rust/agama-lib/share/examples/storage.json +++ b/rust/agama-lib/share/examples/storage.json @@ -1,63 +1,96 @@ { "storage": { - "guided": { - "target": { - "disk": "/dev/vdc" - }, - "boot": { - "configure": true, - "device": "/dev/vda" - }, - "encryption": { - "password": "notsecret", - "method": "luks2", - "pbkdFunction": "argon2i" - }, - "space": { - "policy": "custom", - "actions": [ - { "resize": "/dev/vda" }, - { "forceDelete": "/dev/vdb1" } - ] - }, - "volumes": [ - { - "mount": { - "path": "/", - "options": ["ro"] + "boot": { + "configure": true, + "device": "/dev/vda" + }, + "drives": [ + { + "search": "/dev/vda", + "ptableType": "gpt", + "partitions": [ + { + "search": { + "ifNotFound": "skip" + }, + "delete": true }, - "filesystem": { - "btrfs": { - "snapshots": true + { + "id": "linux", + "size": "10 GiB", + "encryption": { + "luks1": { + "password": "notsecret" + } + }, + "filesystem": { + "reuse": false, + "type": { + "btrfs": { + "snapshots": true + } + }, + "path": "/", + "mountBy": "uuid", + "mountOptions": ["ro"] } }, - "size": [1024, "5 Gib"], - "target": "default" - }, - { - "mount": { - "path": "/home" - }, - "filesystem": "xfs", - "size": { - "min": "5 GiB", - "max": "20 GiB" + { + "encryption": { + "luks2": { + "password": "notsecret", + "label": "home" + } + }, + "filesystem": { + "type": "xfs", + "path": "/home" + } }, - "target": { - "newVg": "/dev/vda" + { + "encryption": "random_swap", + "filesystem": { + "type": "swap", + "path": "swap" + }, + "size": "2 GiB" } - }, - { - "mount": { - "path": "swap" + ] + }, + { + "search": "/dev/vdb", + "partitions": [ + { + "search": { + "condition": { "name": "/dev/vdb1" }, + "ifNotFound": "skip" + }, + "deleteIfNeeded": true + }, + { + "search": { + "ifNotFound": "skip" + }, + "delete": true }, - "filesystem": "swap", - "size": "8 GiB", - "target": { - "newPartition": "/dev/vda" + { + "filesystem": { + "type": "xfs", + "path": "/data" + }, + "size": { "min": "50 GiB" } } + ] + }, + { + "search": { + "ifNotFound": "skip" + }, + "filesystem": { + "type": "ext4", + "path": "/var/log" } - ] - } + } + ] } } diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 29890553ae..e8dea9610c 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -304,41 +304,95 @@ "type": "object", "additionalProperties": false, "properties": { + "boot": { + "$ref": "#/$defs/boot" + }, + "drives": { + "title": "Drive devices", + "description": "Section describing drives (disks, BIOS RAIDs and multipath devices).", + "type": "array", + "items": { + "anyOf": [ + { + "title": "Unpartitioned drive", + "description": "Drive without a partition table (e.g., directly formatted).", + "type": "object", + "additionalProperties": false, + "properties": { + "search": { + "description": "The search is limited to drives scope.", + "$ref": "#/$defs/search" + }, + "encryption": { + "$ref": "#/$defs/encryption" + }, + "filesystem": { + "description": "The partition table (if any) is deleted.", + "$ref": "#/$defs/filesystem" + } + } + }, + { + "title": "Partitioned drive", + "type": "object", + "additionalProperties": false, + "properties": { + "search": { + "description": "The search is limited to drives scope.", + "$ref": "#/$defs/search" + }, + "ptableType": { + "title": "Partition table type", + "description": "The partition table is created only if all the current partitions are deleted.", + "enum": ["gpt", "msdos", "dasd"] + }, + "partitions": { + "$ref": "#/$defs/partitions" + } + } + } + ] + } + }, "guided": { - "title": "Settings to execute a Guided Proposal", + "title": "Guided proposal settings", + "$comment": "This guided section will be extracted to a separate schema. Only storage and legacyAutoyastStorage will be offered as valid schemas for the storage config.", "type": "object", "additionalProperties": false, "properties": { "target": { - "title": "Target device", "anyOf": [ { + "title": "Target for installing", + "description": "Indicates whether to install in a disk or a new LVM.", "enum": ["disk", "newLvmVg"] }, { - "title": "Disk device for installing", + "title": "Target disk", + "description": "Indicates to install in a specific disk device.", "type": "object", "additionalProperties": false, "required": ["disk"], "properties": { "disk": { - "title": "Disk device name", + "title": "Device name", "type": "string", "examples": ["/dev/vda"] } } }, { - "title": "New LVM for installing", + "title": "New LVM", + "description": "Indicates to install in a new LVM created over some specific devices.", "type": "object", "additionalProperties": false, "required": ["newLvmVg"], "properties": { "newLvmVg": { - "title": "Devices in which to create the physical volumes", + "description": "List of devices in which to create the physical volumes.", "type": "array", "items": { - "title": "Disk device name", + "title": "Device name", "type": "string", "examples": ["/dev/vda"] } @@ -348,45 +402,31 @@ ] }, "boot": { - "title": "Configuration of the boot settings", - "type": "object", - "additionalProperties": false, - "required": ["configure"], - "properties": { - "configure": { - "title": "Whether to configure partitions for booting", - "type": "boolean" - }, - "device": { - "title": "Device to use for booting", - "description": "The installation device is used by default for booting", - "type": "string", - "examples": ["/dev/vda"] - } - } + "$ref": "#/$defs/boot" }, "encryption": { - "title": "Encryption settings", + "title": "Encryption", + "description": "Indicates the options for encrypting the new partitions.", "type": "object", "additionalProperties": false, "required": ["password"], "properties": { "password": { - "title": "Passphrase to use when creating new encryption devices", - "type": "string" + "$ref": "#/$defs/encryptionPassword" }, "method": { - "title": "Method used to create the encryption devices", + "title": "Encryption method", + "description": "Method used to encrypt the devices.", "enum": ["luks2", "tpm_fde"] }, "pbkdFunction": { - "title": "Password-based key derivation function to use for LUKS2", - "enum": ["pbkdf2", "argon2i", "argon2id"] + "$ref": "#/$defs/encryptionPbkdFunction" } } }, "space": { - "title": "Policy to find space for the new partitions", + "title": "Space policy", + "description": "Indicates how to find space for the new partitions.", "type": "object", "additionalProperties": false, "properties": { @@ -406,19 +446,20 @@ "required": ["policy", "actions"], "properties": { "actions": { - "title": "Actions to find space if policy is 'custom'", + "title": "Custom actions", + "description": "Indicates what to do with specific devices.", "type": "array", "items": { "anyOf": [ { - "title": "Delete device", - "description": "Force device deletion", + "title": "Force delete", + "description": "Indicates to delete a specific device.", "type": "object", "required": ["forceDelete"], "additionalProperties": false, "properties": { "forceDelete": { - "title": "Device to delete", + "description": "Name of the device to delete.", "type": "string", "examples": ["/dev/vda"] } @@ -426,13 +467,13 @@ }, { "title": "Allow shinking", - "description": "Indicate the device can be shrunk in needed", + "description": "Indicates whether a specific device can be shrunk if needed.", "type": "object", "required": ["resize"], "additionalProperties": false, "properties": { "resize": { - "title": "Device to allow resizing", + "description": "Name of the shrinkable device.", "type": "string", "examples": ["/dev/vda"] } @@ -454,7 +495,8 @@ } }, "volumes": { - "title": "Set of volumes (file systems) to create", + "title": "System volumes", + "description": "List of volumes (file systems) to create.", "type": "array", "items": { "type": "object", @@ -462,126 +504,64 @@ "required": ["mount"], "properties": { "mount": { - "title": "Mount point", + "title": "Mount properties", "type": "object", "additionalProperties": false, "required": ["path"], "properties": { "path": { "title": "Mount path", - "type": "string" + "type": "string", + "examples": ["/dev/vda"] }, "options": { - "title": "Options to add to the fourth field of fstab", + "title": "Mount options", + "description": "Options to add to the fourth field of fstab.", "type": "array", - "items": { "type": "string" } + "items": { + "type": "string" + } } } }, "filesystem": { - "title": "File system of the volume", - "anyOf": [ - { - "title": "File system type", - "enum": [ - "bcachefs", "btrfs", "exfat", "ext2", "ext3", "ext4", "f2fs", "jfs", - "nfs", "nilfs2", "ntfs", "reiserfs", "swap", "tmpfs", "vfat", "xfs" - ] - }, - { - "title": "Btrfs file system", - "description": "Indicates properties of the Btrfs file system", - "type": "object", - "additionalProperties": false, - "required": ["btrfs"], - "properties": { - "btrfs": { - "title": "Specification of a Btrfs file system", - "type": "object", - "additionalProperties": false, - "properties": { - "snapshots": { - "title": "Whether Btrfs snapshots should be configured", - "type": "boolean" - } - } - } - } - } - ] + "$ref": "#/$defs/filesystemType" }, "size": { - "title": "Size limits", - "description": "Options to indicate the size of a device", - "anyOf": [ - { - "title": "Automatic size", - "description": "The size is auto calculated according to the product", - "const": "auto" - }, - { - "title": "Size unit", - "$ref": "#/$defs/sizeValue" - }, - { - "title": "Size range (e.g., [1024, '2 GiB'])", - "description": "Lower size limit and optionally upper size limit", - "type": "array", - "items": { - "$ref": "#/$defs/sizeValue" - }, - "minItems": 1, - "maxItems": 2, - "examples": [[1024, "2 GiB"]] - }, - { - "title": "Size range", - "type": "object", - "additionalProperties": false, - "properties": { - "min": { - "title": "Mandatory lower size limit", - "$ref": "#/$defs/sizeValue" - }, - "max": { - "title": "Optional upper size limit", - "$ref": "#/$defs/sizeValue" - } - }, - "required": ["min"] - } - ] + "$ref": "#/$defs/size" }, "target": { - "title": "Location of the file system", - "description": "Options to indicate the location of a file system", + "title": "Volume target", + "description": "Options to indicate the location of a volume.", "anyOf": [ { + "title": "Default target", + "description": "The volume is created in the target device for installing.", "const": "default" }, { "title": "New partition", - "description": "The file system is created over a new partition", + "description": "The volume is created over a new partition in a specific disk.", "type": "object", "required": ["newPartition"], "additionalProperties": false, "properties": { "newPartition": { - "title": "Name of a disk device", + "description": "Name of a disk device.", "type": "string", "examples": ["/dev/vda"] - } + } } }, { "title": "Dedicated LVM volume group", - "description": "The file system is created over a dedicated LVM", + "description": "The volume is created over a new dedicated LVM.", "type": "object", "additionalProperties": false, "required": ["newVg"], "properties": { "newVg": { - "title": "Name of a disk device", + "description": "Name of a disk device.", "type": "string", "examples": ["/dev/vda"] } @@ -589,13 +569,13 @@ }, { "title": "Re-used existing device", - "description": "The file system is created over an existing device", + "description": "The volume is created over an existing device.", "type": "object", "additionalProperties": false, "required": ["device"], "properties": { "device": { - "title": "Name of a device", + "description": "Name of a device.", "type": "string", "examples": ["/dev/vda1"] } @@ -603,13 +583,13 @@ }, { "title": "Re-used existing file system", - "description": "An existing file system is reused (without formatting)", + "description": "An existing file system is reused (without formatting).", "type": "object", "additionalProperties": false, "required": ["filesystem"], "properties": { "filesystem": { - "title": "Name of a device containing the file system", + "description": "Name of a device containing the file system.", "type": "string", "examples": ["/dev/vda1"] } @@ -626,13 +606,16 @@ }, "legacyAutoyastStorage": { "title": "Legacy AutoYaST storage settings", - "description": "Accepts all options of the AutoYaST profile (XML to JSON)", - "type": "object" + "description": "Accepts all options of the AutoYaST partitioning section (i.e., XML to JSON)", + "type": "array", + "items": { + "type": "object" + } } }, "$defs": { "sizeString": { - "title": "Human readable size (e.g., '2 GiB')", + "title": "Human readable size", "type": "string", "pattern": "^[0-9]+(\\.[0-9]+)?(\\s*([KkMmGgTtPpEeZzYy][iI]?)?[Bb])?$", "examples": ["2 GiB", "1.5 TB", "1TIB", "1073741824 b", "1073741824"] @@ -640,13 +623,361 @@ "sizeInteger": { "title": "Size in bytes", "type": "integer", - "minimum": 0 + "minimum": 0, + "examples": [1024, 2048] }, "sizeValue": { "anyOf": [ { "$ref": "#/$defs/sizeString" }, { "$ref": "#/$defs/sizeInteger" } ] + }, + "size": { + "title": "Size options", + "anyOf": [ + { + "title": "Automatic size", + "description": "The size is auto calculated according to the product.", + "const": "auto" + }, + { + "$ref": "#/$defs/sizeValue" + }, + { + "title": "Size range (tuple syntax)", + "description": "Lower size limit and optionally upper size limit.", + "type": "array", + "items": { + "$ref": "#/$defs/sizeValue" + }, + "minItems": 1, + "maxItems": 2, + "examples": [[1024, 2048], ["1 GiB", "5 GiB"], [1024, "2 GiB"], ["2 GiB"]] + }, + { + "title": "Size range", + "type": "object", + "additionalProperties": false, + "required": ["min"], + "properties": { + "min": { + "title": "Mandatory lower size limit", + "$ref": "#/$defs/sizeValue" + }, + "max": { + "title": "Optional upper size limit", + "$ref": "#/$defs/sizeValue" + } + } + } + ] + }, + "searchName": { + "title": "Device name", + "type": "string", + "examples": ["/dev/vda", "/dev/disk/by-id/ata-WDC_WD3200AAKS-75L9"] + }, + "searchByName": { + "title": "Search by name condition", + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { + "$ref": "#/$defs/searchName" + } + } + }, + "search": { + "anyOf": [ + { + "$ref": "#/$defs/searchName" + }, + { + "title": "Search options", + "type": "object", + "additionalProperties": false, + "properties": { + "condition": { + "$ref": "#/$defs/searchByName" + }, + "ifNotFound": { + "title": "Not found action", + "description": "How to handle the section if the device is not found.", + "enum": ["skip", "error"], + "default": "error" + } + } + } + ] + }, + "boot": { + "title": "Boot options", + "description": "Allows configuring boot partitions automatically.", + "type": "object", + "additionalProperties": false, + "required": ["configure"], + "properties": { + "configure": { + "title": "Configure boot", + "description": "Whether to configure partitions for booting.", + "type": "boolean" + }, + "device": { + "title": "Boot device", + "description": "The target installation device is used by default.", + "type": "string", + "examples": ["/dev/vda"] + } + } + }, + "encryptionPassword": { + "title": "Encryption password", + "description": "Password to use when creating a new encryption device.", + "type": "string" + }, + "encryptionCipher": { + "title": "LUKS cipher", + "description": "The value must be compatible with the --cipher argument of the command cryptsetup.", + "type": "string" + }, + "encryptionKeySize": { + "title": "LUKS key size", + "description": "The value (in bits) has to be a multiple of 8. The possible key sizes are limited by the used cipher.", + "type": "integer" + }, + "encryptionPbkdFunction": { + "title": "LUKS2 password-based key derivation", + "enum": ["pbkdf2", "argon2i", "argon2id"] + }, + "encryptionLUKS1": { + "title": "LUKS1 encryption", + "type": "object", + "additionalProperties": false, + "required": ["luks1"], + "properties": { + "luks1": { + "type": "object", + "additionalProperties": false, + "required": ["password"], + "properties": { + "password": { + "$ref": "#/$defs/encryptionPassword" + }, + "cipher": { + "$ref": "#/$defs/encryptionCipher" + }, + "keySize": { + "$ref": "#/$defs/encryptionKeySize" + } + } + } + } + }, + "encryptionLUKS2": { + "title": "LUKS2 encryption", + "type": "object", + "additionalProperties": false, + "required": ["luks2"], + "properties": { + "luks2": { + "type": "object", + "additionalProperties": false, + "required": ["password"], + "properties": { + "password": { + "$ref": "#/$defs/encryptionPassword" + }, + "cipher": { + "$ref": "#/$defs/encryptionCipher" + }, + "keySize": { + "$ref": "#/$defs/encryptionKeySize" + }, + "pbkdFunction": { + "$ref": "#/$defs/encryptionPbkdFunction" + }, + "label": { + "title": "LUKS2 label", + "type": "string" + } + } + } + } + }, + "encryptionPervasiveLUKS2": { + "title": "LUKS2 pervasive encryption", + "type": "object", + "additionalProperties": false, + "required": ["pervasiveLuks2"], + "properties": { + "pervasiveLuks2": { + "type": "object", + "additionalProperties": false, + "required": ["password"], + "properties": { + "password": { + "$ref": "#/$defs/encryptionPassword" + } + } + } + } + }, + "encryptionSwap": { + "title": "Swap encryptions", + "enum": ["protected_swap", "secure_swap", "random_swap"] + }, + "encryption": { + "anyOf": [ + { "$ref": "#/$defs/encryptionLUKS1" }, + { "$ref": "#/$defs/encryptionLUKS2" }, + { "$ref": "#/$defs/encryptionPervasiveLUKS2" }, + { "$ref": "#/$defs/encryptionSwap" } + ] + }, + "filesystemType": { + "anyOf": [ + { + "title": "File system type", + "enum": [ + "bcachefs", "btrfs", "exfat", "ext2", "ext3", "ext4", "f2fs", "jfs", + "nfs", "nilfs2", "ntfs", "reiserfs", "swap", "tmpfs", "vfat", "xfs" + ] + }, + { + "title": "Btrfs file system", + "type": "object", + "additionalProperties": false, + "required": ["btrfs"], + "properties": { + "btrfs": { + "type": "object", + "additionalProperties": false, + "properties": { + "snapshots": { + "title": "Btrfs snapshots", + "description": "Whether to configrue Btrfs snapshots.", + "type": "boolean" + } + } + } + } + } + ] + }, + "filesystem": { + "title": "File system options", + "type": "object", + "additionalProperties": false, + "properties": { + "reuse": { + "title": "Reuse file system", + "description": "Whether to reuse the existing file system (if any).", + "type": "boolean", + "default": false + }, + "type": { + "$ref": "#/$defs/filesystemType" + }, + "label": { + "title": "File system label", + "type": "string" + }, + "path": { + "title": "Mount path", + "type": "string", + "examples": ["/dev/vda"] + }, + "mountBy": { + "title": "How to mount the device", + "enum": ["device", "id", "label", "path", "uuid"] + }, + "mkfsOptions": { + "title": "mkfs options", + "description": "Options for creating the file system.", + "type": "array", + "items": { + "type": "string" + } + }, + "mountOptions": { + "title": "Mount options", + "description": "Options to add to the fourth field of fstab.", + "type": "array", + "items": { "type": "string" } + } + } + }, + "partitions": { + "title": "Partitions", + "type": "array", + "items": { + "anyOf": [ + { + "title": "Partition to create or reuse", + "type": "object", + "additionalProperties": false, + "properties": { + "search": { + "description": "The search is limited to the partitions of the selected device scope.", + "$ref": "#/$defs/search" + }, + "id": { + "title": "Partition ID", + "enum": ["linux", "swap", "lvm", "raid", "esp", "prep", "bios_boot"] + }, + "size": { + "title": "Partition size", + "$ref": "#/$defs/size" + }, + "encryption": { + "$ref": "#/$defs/encryption" + }, + "filesystem": { + "$ref": "#/$defs/filesystem" + } + } + }, + { + "title": "Partition to delete if needed", + "type": "object", + "additionalProperties": false, + "required": ["deleteIfNeeded", "search"], + "properties": { + "deleteIfNeeded": { + "title": "Delete if needed", + "description": "Delete the partition if needed to make space.", + "const": true + }, + "search": { + "description": "The search is limited to the partitions of the selected device scope.", + "$ref": "#/$defs/search" + }, + "size": { + "title": "Partition size", + "$ref": "#/$defs/size" + } + } + }, + { + "title": "Partition to delete", + "type": "object", + "additionalProperties": false, + "required": ["delete", "search"], + "properties": { + "delete": { + "title": "Delete", + "description": "Delete the partition.", + "const": true + }, + "search": { + "description": "The search is limited to the partitions of the selected device scope.", + "$ref": "#/$defs/search" + } + } + } + ] + } } } } diff --git a/rust/agama-lib/src/jobs.rs b/rust/agama-lib/src/jobs.rs new file mode 100644 index 0000000000..e7ef66ff85 --- /dev/null +++ b/rust/agama-lib/src/jobs.rs @@ -0,0 +1,36 @@ +//! This module implements support for the so-called Jobs. It is a concept hat represents running +//! an external command that may take some time, like formatting a DASD device. It is exposed via +//! D-Bus and, at this time, only the storage service makes use of it. + +use std::collections::HashMap; + +use serde::Serialize; +use zbus::zvariant::OwnedValue; + +use crate::{dbus::get_property, error::ServiceError}; + +pub mod client; + +/// Represents a job. +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Job { + /// Artificial job identifier. + pub id: String, + /// Whether the job is running. + pub running: bool, + /// Job exit code. + pub exit_code: u32, +} + +impl TryFrom<&HashMap> for Job { + type Error = ServiceError; + + fn try_from(value: &HashMap) -> Result { + Ok(Job { + running: get_property(value, "Running")?, + exit_code: get_property(value, "ExitCode")?, + ..Default::default() + }) + } +} diff --git a/rust/agama-lib/src/jobs/client.rs b/rust/agama-lib/src/jobs/client.rs new file mode 100644 index 0000000000..c7f02d5ad9 --- /dev/null +++ b/rust/agama-lib/src/jobs/client.rs @@ -0,0 +1,53 @@ +//! Implements a client to access Agama's Jobs API. + +use zbus::{fdo::ObjectManagerProxy, zvariant::OwnedObjectPath, Connection}; + +use crate::error::ServiceError; + +use super::Job; + +#[derive(Clone)] +pub struct JobsClient<'a> { + object_manager_proxy: ObjectManagerProxy<'a>, +} + +impl<'a> JobsClient<'a> { + pub async fn new( + connection: Connection, + destination: &'static str, + path: &'static str, + ) -> Result { + let object_manager_proxy = ObjectManagerProxy::builder(&connection) + .destination(destination)? + .path(path)? + .build() + .await?; + + Ok(Self { + object_manager_proxy, + }) + } + + pub async fn jobs(&self) -> Result, ServiceError> { + let managed_objects = self.object_manager_proxy.get_managed_objects().await?; + + let mut jobs = vec![]; + for (path, ifaces) in managed_objects { + let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.Job") else { + continue; + }; + + match Job::try_from(properties) { + Ok(mut job) => { + job.id = path.to_string(); + jobs.push((path, job)); + } + Err(error) => { + log::warn!("Not a valid job: {}", error); + } + } + } + + Ok(jobs) + } +} diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 1bbc4c48c8..2a02f0cc63 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -27,6 +27,7 @@ pub mod auth; pub mod base_http_client; pub mod error; pub mod install_settings; +pub mod jobs; pub mod localization; pub mod manager; pub mod network; diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs index 240327ab59..19157b2f10 100644 --- a/rust/agama-lib/src/proxies.rs +++ b/rust/agama-lib/src/proxies.rs @@ -192,3 +192,29 @@ trait Locale { /// SetLocale method fn set_locale(&self, locale: &str) -> zbus::Result<()>; } + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.Job", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1/jobs" +)] +trait Job { + #[dbus_proxy(property)] + fn running(&self) -> zbus::Result; + + #[dbus_proxy(property)] + fn exit_code(&self) -> zbus::Result; + + #[dbus_proxy(signal)] + fn finished(&self, exit_code: u32) -> zbus::Result<()>; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.DASD.Format", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1/jobs/1" +)] +trait FormatJob { + #[dbus_proxy(property)] + fn summary(&self) -> zbus::Result>; +} diff --git a/rust/agama-lib/src/software.rs b/rust/agama-lib/src/software.rs index 9231b0e5ba..98f06fd0f9 100644 --- a/rust/agama-lib/src/software.rs +++ b/rust/agama-lib/src/software.rs @@ -1,10 +1,13 @@ //! Implements support for handling the software settings mod client; +mod http_client; +pub mod model; pub mod proxies; mod settings; mod store; pub use client::{Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy}; +pub use http_client::SoftwareHTTPClient; pub use settings::SoftwareSettings; pub use store::SoftwareStore; diff --git a/rust/agama-lib/src/software/http_client.rs b/rust/agama-lib/src/software/http_client.rs new file mode 100644 index 0000000000..b8c3ea624b --- /dev/null +++ b/rust/agama-lib/src/software/http_client.rs @@ -0,0 +1,63 @@ +use crate::software::model::SoftwareConfig; +use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; +use std::collections::HashMap; + +pub struct SoftwareHTTPClient { + client: BaseHTTPClient, +} + +impl SoftwareHTTPClient { + pub fn new() -> Result { + Ok(Self { + client: BaseHTTPClient::new()?, + }) + } + + pub fn new_with_base(base: BaseHTTPClient) -> Self { + Self { client: base } + } + + pub async fn get_config(&self) -> Result { + self.client.get("/software/config").await + } + + pub async fn set_config(&self, config: &SoftwareConfig) -> Result<(), ServiceError> { + // FIXME: test how errors come out: + // unknown pattern name, + // D-Bus client returns + // Err(ServiceError::UnknownPatterns(wrong_patterns)) + // CLI prints: + // Anyhow(Backend call failed with status 400 and text '{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}') + self.client.put_void("/software/config", config).await + } + + /// Returns the ids of patterns selected by user + pub async fn user_selected_patterns(&self) -> Result, ServiceError> { + // TODO: this way we unnecessarily ask D-Bus (via web.rs) also for the product and then ignore it + let config = self.get_config().await?; + + let Some(patterns_map) = config.patterns else { + return Ok(vec![]); + }; + + let patterns: Vec = patterns_map + .into_iter() + .filter_map(|(name, is_selected)| if is_selected { Some(name) } else { None }) + .collect(); + + Ok(patterns) + } + + /// Selects patterns by user + pub async fn select_patterns( + &self, + patterns: HashMap, + ) -> Result<(), ServiceError> { + let config = SoftwareConfig { + product: None, + // TODO: SoftwareStore only passes true bools, false branch is untested + patterns: Some(patterns), + }; + self.set_config(&config).await + } +} diff --git a/rust/agama-lib/src/software/model.rs b/rust/agama-lib/src/software/model.rs new file mode 100644 index 0000000000..868afe8097 --- /dev/null +++ b/rust/agama-lib/src/software/model.rs @@ -0,0 +1,11 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Software service configuration (product, patterns, etc.). +#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SoftwareConfig { + /// A map where the keys are the pattern names and the values whether to install them or not. + pub patterns: Option>, + /// Name of the product to install. + pub product: Option, +} diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs index 9601b96837..f94ae4d0e9 100644 --- a/rust/agama-lib/src/software/settings.rs +++ b/rust/agama-lib/src/software/settings.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; /// Software settings for installation -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SoftwareSettings { /// List of patterns to install. If empty use default. diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs index 3c72d952c2..2323c553a8 100644 --- a/rust/agama-lib/src/software/store.rs +++ b/rust/agama-lib/src/software/store.rs @@ -2,19 +2,18 @@ use std::collections::HashMap; -use super::{SoftwareClient, SoftwareSettings}; +use super::{SoftwareHTTPClient, SoftwareSettings}; use crate::error::ServiceError; -use zbus::Connection; /// Loads and stores the software settings from/to the D-Bus service. -pub struct SoftwareStore<'a> { - software_client: SoftwareClient<'a>, +pub struct SoftwareStore { + software_client: SoftwareHTTPClient, } -impl<'a> SoftwareStore<'a> { - pub async fn new(connection: Connection) -> Result, ServiceError> { +impl SoftwareStore { + pub fn new() -> Result { Ok(Self { - software_client: SoftwareClient::new(connection.clone()).await?, + software_client: SoftwareHTTPClient::new()?, }) } @@ -34,3 +33,106 @@ impl<'a> SoftwareStore<'a> { Ok(()) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::base_http_client::BaseHTTPClient; + use httpmock::prelude::*; + use std::error::Error; + use tokio::test; // without this, "error: async functions cannot be used for tests" + + fn software_store(mock_server_url: String) -> SoftwareStore { + let mut bhc = BaseHTTPClient::default(); + bhc.base_url = mock_server_url; + let client = SoftwareHTTPClient::new_with_base(bhc); + SoftwareStore { + software_client: client, + } + } + + #[test] + async fn test_getting_software() -> Result<(), Box> { + let server = MockServer::start(); + let software_mock = server.mock(|when, then| { + when.method(GET).path("/api/software/config"); + then.status(200) + .header("content-type", "application/json") + .body( + r#"{ + "patterns": {"xfce":true}, + "product": "Tumbleweed" + }"#, + ); + }); + let url = server.url("/api"); + + let store = software_store(url); + let settings = store.load().await?; + + let expected = SoftwareSettings { + patterns: vec!["xfce".to_owned()], + }; + // main assertion + assert_eq!(settings, expected); + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + software_mock.assert(); + Ok(()) + } + + #[test] + async fn test_setting_software_ok() -> Result<(), Box> { + let server = MockServer::start(); + let software_mock = server.mock(|when, then| { + when.method(PUT) + .path("/api/software/config") + .header("content-type", "application/json") + .body(r#"{"patterns":{"xfce":true},"product":null}"#); + then.status(200); + }); + let url = server.url("/api"); + + let store = software_store(url); + let settings = SoftwareSettings { + patterns: vec!["xfce".to_owned()], + }; + + let result = store.store(&settings).await; + + // main assertion + result?; + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + software_mock.assert(); + Ok(()) + } + + #[test] + async fn test_setting_software_err() -> Result<(), Box> { + let server = MockServer::start(); + let software_mock = server.mock(|when, then| { + when.method(PUT) + .path("/api/software/config") + .header("content-type", "application/json") + .body(r#"{"patterns":{"no_such_pattern":true},"product":null}"#); + then.status(400) + .body(r#"'{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}"#); + }); + let url = server.url("/api"); + + let store = software_store(url); + let settings = SoftwareSettings { + patterns: vec!["no_such_pattern".to_owned()], + }; + + let result = store.store(&settings).await; + + // main assertion + assert!(result.is_err()); + + // Ensure the specified mock was called exactly one time (or fail with a detailed error description). + software_mock.assert(); + Ok(()) + } +} diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 9998cc61aa..37b53a6936 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -14,6 +14,7 @@ use zbus::fdo::ObjectManagerProxy; use zbus::names::{InterfaceName, OwnedInterfaceName}; use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::Connection; +pub mod dasd; pub mod iscsi; type DBusObject = ( diff --git a/rust/agama-lib/src/storage/client/dasd.rs b/rust/agama-lib/src/storage/client/dasd.rs new file mode 100644 index 0000000000..d1ab9c61f0 --- /dev/null +++ b/rust/agama-lib/src/storage/client/dasd.rs @@ -0,0 +1,110 @@ +//! Implements a client to access Agama's D-Bus API related to DASD management. + +use zbus::{ + fdo::ObjectManagerProxy, + zvariant::{ObjectPath, OwnedObjectPath}, + Connection, +}; + +use crate::{ + error::ServiceError, + storage::{model::dasd::DASDDevice, proxies::DASDManagerProxy}, +}; + +/// Client to connect to Agama's D-Bus API for DASD management. +#[derive(Clone)] +pub struct DASDClient<'a> { + manager_proxy: DASDManagerProxy<'a>, + object_manager_proxy: ObjectManagerProxy<'a>, +} + +impl<'a> DASDClient<'a> { + pub async fn new(connection: Connection) -> Result, ServiceError> { + let manager_proxy = DASDManagerProxy::new(&connection).await?; + let object_manager_proxy = ObjectManagerProxy::builder(&connection) + .destination("org.opensuse.Agama.Storage1")? + .path("/org/opensuse/Agama/Storage1")? + .build() + .await?; + Ok(Self { + manager_proxy, + object_manager_proxy, + }) + } + + pub async fn supported(&self) -> Result { + let introspect = self.manager_proxy.introspect().await?; + // simply check if introspection contain given interface + Ok(introspect.contains("org.opensuse.Agama.Storage1.DASD.Manager")) + } + + pub async fn probe(&self) -> Result<(), ServiceError> { + Ok(self.manager_proxy.probe().await?) + } + + pub async fn devices(&self) -> Result, ServiceError> { + let managed_objects = self.object_manager_proxy.get_managed_objects().await?; + + let mut devices: Vec<(OwnedObjectPath, DASDDevice)> = vec![]; + for (path, ifaces) in managed_objects { + if let Some(properties) = ifaces.get("org.opensuse.Agama.Storage1.DASD.Device") { + match DASDDevice::try_from(properties) { + Ok(device) => { + devices.push((path, device)); + } + Err(error) => { + log::warn!("Not a valid DASD device: {}", error); + } + } + } + } + Ok(devices) + } + + pub async fn format(&self, ids: &[&str]) -> Result { + let selected = self.find_devices(ids).await?; + let references = selected.iter().collect::>>(); + let (exit_code, job_path) = self.manager_proxy.format(&references).await?; + if exit_code != 0 { + return Err(ServiceError::UnsuccessfulAction("DASD format".to_string())); + } + + Ok(job_path.to_string()) + } + + pub async fn enable(&self, ids: &[&str]) -> Result<(), ServiceError> { + let selected = self.find_devices(ids).await?; + let references = selected.iter().collect::>>(); + self.manager_proxy.enable(&references).await?; + Ok(()) + } + + pub async fn disable(&self, ids: &[&str]) -> Result<(), ServiceError> { + let selected = self.find_devices(ids).await?; + let references = selected.iter().collect::>>(); + self.manager_proxy.disable(&references).await?; + Ok(()) + } + + pub async fn set_diag(&self, ids: &[&str], diag: bool) -> Result<(), ServiceError> { + let selected = self.find_devices(ids).await?; + let references = selected.iter().collect::>>(); + self.manager_proxy.set_diag(&references, diag).await?; + Ok(()) + } + + async fn find_devices(&self, ids: &[&str]) -> Result>, ServiceError> { + let devices = self.devices().await?; + let selected: Vec = devices + .into_iter() + .filter_map(|(path, device)| { + if ids.contains(&device.id.as_str()) { + Some(path.into_inner()) + } else { + None + } + }) + .collect(); + Ok(selected) + } +} diff --git a/rust/agama-lib/src/storage/model.rs b/rust/agama-lib/src/storage/model.rs index da49d35cc6..81c3b0f65e 100644 --- a/rust/agama-lib/src/storage/model.rs +++ b/rust/agama-lib/src/storage/model.rs @@ -5,6 +5,8 @@ use zbus::zvariant::{OwnedValue, Value}; use crate::dbus::{get_optional_property, get_property}; +pub mod dasd; + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DeviceSid(u32); diff --git a/rust/agama-lib/src/storage/model/dasd.rs b/rust/agama-lib/src/storage/model/dasd.rs new file mode 100644 index 0000000000..c40bf09804 --- /dev/null +++ b/rust/agama-lib/src/storage/model/dasd.rs @@ -0,0 +1,46 @@ +//! Implements a data model for DASD devices management. +use std::collections::HashMap; + +use serde::Serialize; +use zbus::zvariant::OwnedValue; + +use crate::{dbus::get_property, error::ServiceError}; + +/// Represents a DASD device (specific to s390x systems). +#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DASDDevice { + pub id: String, + pub enabled: bool, + pub device_name: String, + pub formatted: bool, + pub diag: bool, + pub status: String, + pub device_type: String, + pub access_type: String, + pub partition_info: String, +} +#[derive(Clone, Debug, Serialize, Default, utoipa::ToSchema)] +pub struct DASDFormatSummary { + pub total: u32, + pub step: u32, + pub done: bool, +} + +impl TryFrom<&HashMap> for DASDDevice { + type Error = ServiceError; + + fn try_from(value: &HashMap) -> Result { + Ok(DASDDevice { + id: get_property(value, "Id")?, + enabled: get_property(value, "Enabled")?, + device_name: get_property(value, "DeviceName")?, + formatted: get_property(value, "Formatted")?, + diag: get_property(value, "Diag")?, + status: get_property(value, "Status")?, + device_type: get_property(value, "Type")?, + access_type: get_property(value, "AccessType")?, + partition_info: get_property(value, "PartitionInfo")?, + }) + } +} diff --git a/rust/agama-lib/src/storage/proxies.rs b/rust/agama-lib/src/storage/proxies.rs index ebb9396ac7..c5d2de158b 100644 --- a/rust/agama-lib/src/storage/proxies.rs +++ b/rust/agama-lib/src/storage/proxies.rs @@ -156,3 +156,75 @@ trait Node { #[dbus_proxy(property)] fn target(&self) -> zbus::Result; } + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.DASD.Manager", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait DASDManager { + /// Disable method + fn disable(&self, devices: &[&zbus::zvariant::ObjectPath<'_>]) -> zbus::Result; + + /// Enable method + fn enable(&self, devices: &[&zbus::zvariant::ObjectPath<'_>]) -> zbus::Result; + + /// Format method + fn format( + &self, + devices: &[&zbus::zvariant::ObjectPath<'_>], + ) -> zbus::Result<(u32, zbus::zvariant::OwnedObjectPath)>; + + /// Probe method + fn probe(&self) -> zbus::Result<()>; + + /// SetDiag method + fn set_diag( + &self, + devices: &[&zbus::zvariant::ObjectPath<'_>], + diag: bool, + ) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.DASD.Device", + default_service = "org.opensuse.Agama.Storage1", + assume_defaults = true +)] +trait DASDDevice { + /// AccessType property + #[dbus_proxy(property)] + fn access_type(&self) -> zbus::Result; + + /// DeviceName property + #[dbus_proxy(property)] + fn device_name(&self) -> zbus::Result; + + /// Diag property + #[dbus_proxy(property)] + fn diag(&self) -> zbus::Result; + + /// Enabled property + #[dbus_proxy(property)] + fn enabled(&self) -> zbus::Result; + + /// Formatted property + #[dbus_proxy(property)] + fn formatted(&self) -> zbus::Result; + + /// Id property + #[dbus_proxy(property)] + fn id(&self) -> zbus::Result; + + /// PartitionInfo property + #[dbus_proxy(property)] + fn partition_info(&self) -> zbus::Result; + + /// Status property + #[dbus_proxy(property)] + fn status(&self) -> zbus::Result; + + /// Type property + #[dbus_proxy(property)] + fn type_(&self) -> zbus::Result; +} diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index addf98f8ae..036d6cfc9b 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -19,7 +19,7 @@ pub struct Store<'a> { users: UsersStore, network: NetworkStore, product: ProductStore<'a>, - software: SoftwareStore<'a>, + software: SoftwareStore, storage: StorageStore<'a>, localization: LocalizationStore, } @@ -34,7 +34,7 @@ impl<'a> Store<'a> { users: UsersStore::new()?, network: NetworkStore::new(http_client).await?, product: ProductStore::new(connection.clone()).await?, - software: SoftwareStore::new(connection.clone()).await?, + software: SoftwareStore::new()?, storage: StorageStore::new(connection).await?, }) } diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs index 5f298f4360..ab8526b151 100644 --- a/rust/agama-server/src/software/web.rs +++ b/rust/agama-server/src/software/web.rs @@ -16,6 +16,7 @@ use agama_lib::{ error::ServiceError, product::{proxies::RegistrationProxy, Product, ProductClient, RegistrationRequirement}, software::{ + model::SoftwareConfig, proxies::{Software1Proxy, SoftwareProductProxy}, Pattern, SelectedBy, SoftwareClient, UnknownSelectedBy, }, @@ -37,15 +38,6 @@ struct SoftwareState<'a> { software: SoftwareClient<'a>, } -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct SoftwareConfig { - /// A map where the keys are the pattern names and the values whether to install them or not. - patterns: Option>, - /// Name of the product to install. - product: Option, -} - /// Returns an stream that emits software related events coming from D-Bus. /// /// It emits the Event::ProductChanged and Event::PatternsChanged events. diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs index 4f0de7fab4..0fc935f346 100644 --- a/rust/agama-server/src/storage/web.rs +++ b/rust/agama-server/src/storage/web.rs @@ -21,13 +21,19 @@ use axum::{ use serde::{Deserialize, Serialize}; use tokio_stream::{Stream, StreamExt}; +pub mod dasd; pub mod iscsi; use crate::{ error::Error, - storage::web::iscsi::{iscsi_service, iscsi_stream}, + storage::web::{ + dasd::{dasd_service, dasd_stream}, + iscsi::{iscsi_service, iscsi_stream}, + }, web::{ - common::{issues_router, progress_router, service_status_router, EventStreams}, + common::{ + issues_router, jobs_service, progress_router, service_status_router, EventStreams, + }, Event, }, }; @@ -38,8 +44,10 @@ pub async fn storage_streams(dbus: zbus::Connection) -> Result { pub async fn storage_service(dbus: zbus::Connection) -> Result { const DBUS_SERVICE: &str = "org.opensuse.Agama.Storage1"; const DBUS_PATH: &str = "/org/opensuse/Agama/Storage1"; + const DBUS_DESTINATION: &str = "org.opensuse.Agama.Storage1"; let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let issues_router = issues_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; let iscsi_router = iscsi_service(&dbus).await?; + let dasd_router = dasd_service(&dbus).await?; + let jobs_router = jobs_service(&dbus, DBUS_DESTINATION, DBUS_PATH).await?; let client = StorageClient::new(dbus.clone()).await?; let state = StorageState { client }; @@ -90,8 +101,10 @@ pub async fn storage_service(dbus: zbus::Connection) -> Result Result { + let stream: EventStreams = vec![ + ("dasd_devices", Box::pin(DASDDeviceStream::new(dbus).await?)), + ( + "format_jobs", + Box::pin(DASDFormatJobStream::new(dbus).await?), + ), + ]; + Ok(stream) +} + +#[derive(Clone)] +struct DASDState<'a> { + client: DASDClient<'a>, +} + +pub async fn dasd_service(dbus: &zbus::Connection) -> Result, ServiceError> { + let client = DASDClient::new(dbus.clone()).await?; + let state = DASDState { client }; + let router = Router::new() + .route("/supported", get(supported)) + .route("/devices", get(devices)) + .route("/probe", post(probe)) + .route("/format", post(format)) + .route("/enable", post(enable)) + .route("/disable", post(disable)) + .route("/diag", put(set_diag)) + .with_state(state); + Ok(router) +} + +/// Returns whether DASD technology is supported or not +#[utoipa::path( + get, + path="/supported", + context_path="/api/storage/dasd", + responses( + (status = OK, description = "Returns whether DASD technology is supported") + ) +)] +async fn supported(State(state): State>) -> Result, Error> { + Ok(Json(state.client.supported().await?)) +} + +/// Returns the list of known DASD devices. +#[utoipa::path( + get, + path="/devices", + context_path="/api/storage/dasd", + responses( + (status = OK, description = "List of DASD devices", body = Vec) + ) +)] +async fn devices(State(state): State>) -> Result>, Error> { + let devices = state + .client + .devices() + .await? + .into_iter() + .map(|(_path, device)| device) + .collect(); + Ok(Json(devices)) +} + +/// Find DASD devices in the system. +#[utoipa::path( + post, + path="/probe", + context_path="/api/storage/dasd", + responses( + (status = OK, description = "The probing process ran successfully") + ) +)] +async fn probe(State(state): State>) -> Result, Error> { + Ok(Json(state.client.probe().await?)) +} + +/// Formats a set of devices. +#[utoipa::path( + post, + path="/format", + context_path="/api/storage/dasd", + responses( + (status = OK, description = "The formatting process started. The id of format job is in response.") + ) +)] +async fn format( + State(state): State>, + Json(devices): Json, +) -> Result, Error> { + let path = state.client.format(&devices.as_references()).await?; + Ok(Json(path)) +} + +/// Enables a set of devices. +#[utoipa::path( + post, + path="/enable", + context_path="/api/storage/dasd", + responses( + (status = OK, description = "The DASD devices are enabled.") + ) +)] +async fn enable( + State(state): State>, + Json(devices): Json, +) -> Result, Error> { + state.client.enable(&devices.as_references()).await?; + Ok(Json(())) +} + +/// Disables a set of devices. +#[utoipa::path( + post, + path="/disable", + context_path="/api/storage/dasd", + responses( + (status = OK, description = "The DASD devices are disabled.") + ) +)] +async fn disable( + State(state): State>, + Json(devices): Json, +) -> Result, Error> { + state.client.disable(&devices.as_references()).await?; + Ok(Json(())) +} + +/// Sets the diag property for a set of devices. +#[utoipa::path( + put, + path="/diag", + context_path="/api/storage/dasd", + responses( + (status = OK, description = "The DIAG properties are set.") + ) + )] +async fn set_diag( + State(state): State>, + Json(params): Json, +) -> Result, Error> { + state + .client + .set_diag(¶ms.devices.as_references(), params.diag) + .await?; + Ok(Json(())) +} + +#[derive(Deserialize)] +struct SetDiagParams { + #[serde(flatten)] + pub devices: DevicesList, + pub diag: bool, +} + +#[derive(Deserialize)] +struct DevicesList { + devices: Vec, +} + +impl DevicesList { + pub fn as_references(&self) -> Vec<&str> { + self.devices.iter().map(AsRef::as_ref).collect() + } +} diff --git a/rust/agama-server/src/storage/web/dasd/stream.rs b/rust/agama-server/src/storage/web/dasd/stream.rs new file mode 100644 index 0000000000..e41f5825ad --- /dev/null +++ b/rust/agama-server/src/storage/web/dasd/stream.rs @@ -0,0 +1,267 @@ +// FIXME: the code is pretty similar to iscsi::stream. Refactor the stream to reduce the repetition. + +use std::{collections::HashMap, sync::Arc, task::Poll}; + +use agama_lib::{ + dbus::get_optional_property, + error::ServiceError, + property_from_dbus, + storage::{ + client::dasd::DASDClient, + model::dasd::{DASDDevice, DASDFormatSummary}, + }, +}; +use futures_util::{ready, Stream}; +use pin_project::pin_project; +use thiserror::Error; +use tokio::sync::mpsc::unbounded_channel; +use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; +use zbus::{ + fdo::{PropertiesChanged, PropertiesChangedArgs}, + zvariant::{self, ObjectPath, OwnedObjectPath, OwnedValue}, + MatchRule, Message, MessageStream, MessageType, +}; + +use crate::{ + dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, + web::Event, +}; + +#[derive(Debug, Error)] +enum DASDDeviceStreamError { + #[error("Service error: {0}")] + Service(#[from] ServiceError), + #[error("Unknown DASD device: {0}")] + UnknownDevice(OwnedObjectPath), +} + +/// This stream listens for changes in the collection of DASD devices and emits +/// the updated objects. +/// +/// It relies on the [DBusObjectChangesStream] stream and uses a cache to avoid holding a bunch of +/// proxy objects. +#[pin_project] +pub struct DASDDeviceStream { + dbus: zbus::Connection, + cache: ObjectsCache, + #[pin] + inner: UnboundedReceiverStream, +} + +impl DASDDeviceStream { + /// Creates a new stream + /// + /// * `dbus`: D-Bus connection to listen on. + pub async fn new(dbus: &zbus::Connection) -> Result { + const MANAGER_PATH: &str = "/org/opensuse/Agama/Storage1"; + const NAMESPACE: &str = "/org/opensuse/Agama/Storage1/dasds"; + + let (tx, rx) = unbounded_channel(); + let mut stream = DBusObjectChangesStream::new( + dbus, + &ObjectPath::from_str_unchecked(MANAGER_PATH), + &ObjectPath::from_str_unchecked(NAMESPACE), + "org.opensuse.Agama.Storage1.DASD.Device", + ) + .await?; + + tokio::spawn(async move { + while let Some(change) = stream.next().await { + let _ = tx.send(change); + } + }); + let rx = UnboundedReceiverStream::new(rx); + + let mut cache: ObjectsCache = Default::default(); + let client = DASDClient::new(dbus.clone()).await?; + for (path, device) in client.devices().await? { + cache.add(path.into(), device); + } + + Ok(Self { + dbus: dbus.clone(), + cache, + inner: rx, + }) + } + + fn update_device<'a>( + cache: &'a mut ObjectsCache, + path: &OwnedObjectPath, + values: &HashMap, + ) -> Result<&'a DASDDevice, ServiceError> { + let device = cache.find_or_create(path); + property_from_dbus!(device, id, "Id", values, str); + property_from_dbus!(device, enabled, "Enabled", values, bool); + property_from_dbus!(device, device_name, "DeviceName", values, str); + property_from_dbus!(device, formatted, "Formatted", values, bool); + property_from_dbus!(device, diag, "Diag", values, bool); + property_from_dbus!(device, status, "Status", values, str); + property_from_dbus!(device, device_type, "Type", values, str); + property_from_dbus!(device, access_type, "AccessType", values, str); + property_from_dbus!(device, partition_info, "PartitionInfo", values, str); + Ok(device) + } + + fn remove_device( + cache: &mut ObjectsCache, + path: &OwnedObjectPath, + ) -> Result { + cache + .remove(path) + .ok_or_else(|| DASDDeviceStreamError::UnknownDevice(path.clone())) + } + + fn handle_change( + cache: &mut ObjectsCache, + change: &DBusObjectChange, + ) -> Result { + match change { + DBusObjectChange::Added(path, values) => { + let device = Self::update_device(cache, path, values)?; + Ok(Event::DASDDeviceAdded { + device: device.clone(), + }) + } + DBusObjectChange::Changed(path, updated) => { + let device = Self::update_device(cache, path, updated)?; + Ok(Event::DASDDeviceChanged { + device: device.clone(), + }) + } + DBusObjectChange::Removed(path) => { + let device = Self::remove_device(cache, path)?; + Ok(Event::DASDDeviceRemoved { device }) + } + } + } +} + +impl Stream for DASDDeviceStream { + type Item = Event; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut pinned = self.project(); + + Poll::Ready(loop { + let change = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match change { + Some(change) => { + if let Ok(event) = Self::handle_change(pinned.cache, &change) { + Some(event) + } else { + log::warn!("Could not process change {:?}", &change); + None + } + } + None => break None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} + +/// This stream listens for DASD progress changes and emits an [Event::DASDFormatJobChanged] event. +#[pin_project] +pub struct DASDFormatJobStream { + #[pin] + inner: MessageStream, +} + +impl DASDFormatJobStream { + pub async fn new(connection: &zbus::Connection) -> Result { + let rule = MatchRule::builder() + .msg_type(MessageType::Signal) + .path_namespace("/org/opensuse/Agama/Storage1/jobs")? + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .build(); + let inner = MessageStream::for_match_rule(rule, connection, None).await?; + Ok(Self { inner }) + } + + fn handle_change(message: Result, zbus::Error>) -> Option { + let Ok(message) = message else { + return None; + }; + let properties = PropertiesChanged::from_message(message)?; + let args = properties.args().ok()?; + + if args.interface_name.as_str() != "org.opensuse.Agama.Storage1.DASD.Format" { + return None; + } + + let id = properties.path()?.to_string(); + let event = Self::to_event(id, &args); + if event.is_none() { + log::warn!("Could not decode the DASDFormatJobChanged event"); + } + event + } + + fn to_event(path: String, properties_changed: &PropertiesChangedArgs) -> Option { + let dict = properties_changed + .changed_properties() + .get("Summary")? + .downcast_ref::()?; + + // the key is the D-Bus path of the DASD device and the value is the progress + // of the related formatting process + let map = >>::try_from(dict.clone()).ok()?; + let mut format_summary = HashMap::new(); + + for (dasd_id, summary) in map { + let summary_values = summary.downcast_ref::()?; + let fields = summary_values.fields(); + let total: &u32 = fields.get(0)?.downcast_ref()?; + let step: &u32 = fields.get(1)?.downcast_ref()?; + let done: &bool = fields.get(2)?.downcast_ref()?; + format_summary.insert( + dasd_id.to_string(), + DASDFormatSummary { + total: *total, + step: *step, + done: *done, + }, + ); + } + + Some(Event::DASDFormatJobChanged { + job_id: path.to_string(), + summary: format_summary, + }) + } +} + +impl Stream for DASDFormatJobStream { + type Item = Event; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut pinned = self.project(); + + Poll::Ready(loop { + let item = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match item { + Some(change) => { + if let Some(event) = Self::handle_change(change) { + Some(event) + } else { + None + } + } + None => break None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 3025f17b9b..07926d98e4 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -13,7 +13,7 @@ use crate::{ software::web::{software_service, software_streams}, storage::web::{storage_service, storage_streams}, users::web::{users_service, users_streams}, - web::common::{issues_stream, progress_stream, service_status_stream}, + web::common::{issues_stream, jobs_stream, progress_stream, service_status_stream}, }; use axum::Router; @@ -141,6 +141,16 @@ async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Res ) .await?, ); + stream.insert( + "storage-jobs", + jobs_stream( + dbus.clone(), + "org.opensuse.Agama.Storage1", + "/org/opensuse/Agama/Storage1", + "/org/opensuse/Agama/Storage1/jobs", + ) + .await?, + ); stream.insert( "software-status", service_status_stream( diff --git a/rust/agama-server/src/web/common.rs b/rust/agama-server/src/web/common.rs index 814a811811..430fa50c06 100644 --- a/rust/agama-server/src/web/common.rs +++ b/rust/agama-server/src/web/common.rs @@ -15,6 +15,9 @@ use zbus::PropertyStream; use crate::error::Error; +mod jobs; +pub use jobs::{jobs_service, jobs_stream}; + use super::Event; pub type EventStreams = Vec<(&'static str, Pin + Send>>)>; diff --git a/rust/agama-server/src/web/common/jobs.rs b/rust/agama-server/src/web/common/jobs.rs new file mode 100644 index 0000000000..1e7484ebf2 --- /dev/null +++ b/rust/agama-server/src/web/common/jobs.rs @@ -0,0 +1,186 @@ +use std::{collections::HashMap, pin::Pin, task::Poll}; + +use agama_lib::{ + dbus::get_optional_property, + error::ServiceError, + jobs::{client::JobsClient, Job}, + property_from_dbus, +}; +use axum::{extract::State, routing::get, Json, Router}; +use futures_util::{ready, Stream}; +use pin_project::pin_project; +use tokio::sync::mpsc::unbounded_channel; +use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt}; +use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue}; + +use crate::{ + dbus::{DBusObjectChange, DBusObjectChangesStream, ObjectsCache}, + error::Error, + web::Event, +}; + +/// Builds a router for the jobs objects. +pub async fn jobs_service( + dbus: &zbus::Connection, + destination: &'static str, + path: &'static str, +) -> Result, ServiceError> { + let client = JobsClient::new(dbus.clone(), destination, path).await?; + let state = JobsState { client }; + Ok(Router::new().route("/jobs", get(jobs)).with_state(state)) +} + +#[derive(Clone)] +struct JobsState<'a> { + client: JobsClient<'a>, +} + +async fn jobs(State(state): State>) -> Result>, Error> { + let jobs = state + .client + .jobs() + .await? + .into_iter() + .map(|(_path, job)| job) + .collect(); + Ok(Json(jobs)) +} + +/// Returns the stream of jobs-related events. +/// +/// The stream combines the following events: +/// +/// * Changes on the DASD devices collection. +/// +/// * `dbus`: D-Bus connection to use. +pub async fn jobs_stream( + dbus: zbus::Connection, + destination: &'static str, + manager: &'static str, + namespace: &'static str, +) -> Result + Send>>, Error> { + let stream = JobsStream::new(&dbus, destination, manager, namespace).await?; + Ok(Box::pin(stream)) +} + +#[pin_project] +pub struct JobsStream { + dbus: zbus::Connection, + cache: ObjectsCache, + #[pin] + inner: UnboundedReceiverStream, +} + +#[derive(Debug, thiserror::Error)] +enum JobsStreamError { + #[error("Service error: {0}")] + Service(#[from] ServiceError), + #[error("Unknown job: {0}")] + UnknownJob(OwnedObjectPath), +} + +impl JobsStream { + pub async fn new( + dbus: &zbus::Connection, + destination: &'static str, + manager: &'static str, + namespace: &'static str, + ) -> Result { + let (tx, rx) = unbounded_channel(); + let mut stream = DBusObjectChangesStream::new( + dbus, + &ObjectPath::from_static_str(manager)?, + &ObjectPath::from_static_str(namespace)?, + "org.opensuse.Agama.Storage1.Job", + ) + .await?; + + tokio::spawn(async move { + while let Some(change) = stream.next().await { + let _ = tx.send(change); + } + }); + let rx = UnboundedReceiverStream::new(rx); + + let mut cache: ObjectsCache = Default::default(); + let client = JobsClient::new(dbus.clone(), destination, manager).await?; + for (path, job) in client.jobs().await? { + cache.add(path, job); + } + + Ok(Self { + dbus: dbus.clone(), + cache, + inner: rx, + }) + } + + fn update_job<'a>( + cache: &'a mut ObjectsCache, + path: &OwnedObjectPath, + values: &HashMap, + ) -> Result<&'a Job, ServiceError> { + let job = cache.find_or_create(path); + property_from_dbus!(job, running, "Running", values, bool); + property_from_dbus!(job, exit_code, "ExitCode", values, u32); + Ok(job) + } + + fn remove_job( + cache: &mut ObjectsCache, + path: &OwnedObjectPath, + ) -> Result { + cache + .remove(path) + .ok_or_else(|| JobsStreamError::UnknownJob(path.clone())) + } + + fn handle_change( + cache: &mut ObjectsCache, + change: &DBusObjectChange, + ) -> Result { + match change { + DBusObjectChange::Added(path, values) => { + let job = Self::update_job(cache, path, values)?; + Ok(Event::JobAdded { job: job.clone() }) + } + DBusObjectChange::Changed(path, updated) => { + let job = Self::update_job(cache, path, updated)?; + Ok(Event::JobChanged { job: job.clone() }) + } + DBusObjectChange::Removed(path) => { + let job = Self::remove_job(cache, path)?; + Ok(Event::JobRemoved { job }) + } + } + } +} + +impl Stream for JobsStream { + type Item = Event; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let mut pinned = self.project(); + + Poll::Ready(loop { + let change = ready!(pinned.inner.as_mut().poll_next(cx)); + let next_value = match change { + Some(change) => { + if let Ok(event) = Self::handle_change(pinned.cache, &change) { + Some(event) + } else { + log::warn!("Could not process change {:?}", &change); + None + } + } + None => break None, + }; + if next_value.is_some() { + break next_value; + } + }) + } +} diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index b913f3974c..c28827f949 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -106,7 +106,7 @@ use utoipa::OpenApi; schemas(agama_lib::questions::model::PasswordAnswer), schemas(agama_lib::questions::model::Question), schemas(agama_lib::questions::model::QuestionWithPassword), - schemas(crate::software::web::SoftwareConfig), + schemas(agama_lib::software::model::SoftwareConfig), schemas(crate::software::web::SoftwareProposal), schemas(crate::storage::web::ProductParams), schemas(crate::storage::web::iscsi::DiscoverParams), diff --git a/rust/agama-server/src/web/event.rs b/rust/agama-server/src/web/event.rs index 20075ce00a..a9ce2d1460 100644 --- a/rust/agama-server/src/web/event.rs +++ b/rust/agama-server/src/web/event.rs @@ -1,7 +1,13 @@ use crate::network::model::NetworkChange; use agama_lib::{ - localization::model::LocaleConfig, manager::InstallationPhase, - product::RegistrationRequirement, progress::Progress, software::SelectedBy, storage::ISCSINode, + jobs::Job, + localization::model::LocaleConfig, + manager::InstallationPhase, + product::RegistrationRequirement, + progress::Progress, + software::SelectedBy, + storage::model::dasd::{DASDDevice, DASDFormatSummary}, + storage::ISCSINode, users::FirstUser, }; use serde::Serialize; @@ -77,6 +83,29 @@ pub enum Event { name: Option, ibft: Option, }, + DASDDeviceAdded { + device: DASDDevice, + }, + DASDDeviceChanged { + device: DASDDevice, + }, + DASDDeviceRemoved { + device: DASDDevice, + }, + JobAdded { + job: Job, + }, + JobChanged { + job: Job, + }, + JobRemoved { + job: Job, + }, + DASDFormatJobChanged { + #[serde(rename = "jobId")] + job_id: String, + summary: HashMap, + }, } pub type EventsSender = Sender; diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 92207709a0..feeef29836 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,21 @@ +------------------------------------------------------------------- +Wed Aug 28 12:37:34 UTC 2024 - Imobach Gonzalez Sosa + +- Expose the DASD D-Bus API through HTTP (gh#openSUSE/agama#1532). + +------------------------------------------------------------------- +Tue Aug 27 13:57:35 UTC 2024 - José Iván López González + +- Schema definition for basic storage settings + (gh#openSUSE/agama#1455). + +------------------------------------------------------------------- +Mon Aug 26 11:19:27 UTC 2024 - Martin Vidner + +- For CLI, use HTTP clients instead of D-Bus clients, + for Software (gh#openSUSE/agama#1548) + - added SoftwareHTTPClient + ------------------------------------------------------------------- Wed Aug 21 19:07:28 UTC 2024 - Lubos Kocman @@ -189,7 +207,7 @@ Fri Jun 7 05:58:48 UTC 2024 - Michal Filka - self-signed certificate contains hostname - self-signed certificate is stored into default location - before creating new self-signed certificate a default location - (/etc/agama.d/ssl) is checked for a certificate + (/etc/agama.d/ssl) is checked for a certificate - gh#openSUSE/agama#1228 ------------------------------------------------------------------- diff --git a/service/lib/agama/config_reader.rb b/service/lib/agama/config_reader.rb index e1dde8ded5..debad5b1c1 100644 --- a/service/lib/agama/config_reader.rb +++ b/service/lib/agama/config_reader.rb @@ -68,7 +68,7 @@ def config_from_file(path = nil) Config.from_file(path, logger) end - # Return an arry with the different {Config} objects read from the different locations + # Return an array with the different {Config} objects read from the different locations # # TODO: handle precedence correctly # @@ -82,7 +82,7 @@ def configs @configs end - # Return a {Config} oject + # Return a {Config} object # @return [Config] resultant Config after merging all the configurations def config config = configs.first || Config.new(nil, logger) @@ -97,7 +97,7 @@ def config # @param location [String] File location. It might be an URL-like string (e.g., # "http://example.net/example.yml"). # @param target [String] Path to copy the file to. - # @return [Boolean] Whether the file was sucessfully copied or not + # @return [Boolean] Whether the file was successfully copied or not def copy_file(location, target) url = Yast::URL.Parse(location) diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index b92a1b9c9e..0514b159b5 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 # rubocop:disable Metrics/ClassLength + class Manager < BaseObject include WithISCSIAuth include WithServiceStatus include ::DBus::ObjectManager @@ -91,30 +91,31 @@ def probe busy_while { backend.probe } end - # Calculates a proposal (guided or AutoYaST) from a given storage config. + # Applies the given serialized config according to the JSON schema. # - # @raise If config is not valid. + # The JSON schema supports two different variants: + # { "storage": ... } or { "legacyAutoyastStorage": ... }. # - # @param serialized_config [String] Serialized storage config. It can be storage or legacy - # AutoYaST settings: { "storage": ... } vs { "legacyAutoyastStorage": ... }. - def apply_storage_config(serialized_config) - config_json = JSON.parse(serialized_config, symbolize_names: true) + # @note The guided settings ({ "storage": { "guided": ... } }) are supported too, but it + # will be removed from the JSON schema. + # + # @raise If the config is not valid. + # + # @param serialized_config [String] Serialized storage config. + # @return [Integer] 0 success; 1 error + def apply_config(serialized_config) + logger.info("Setting storage config from D-Bus: #{serialized_config}") - if (settings_json = config_json.dig(:storage, :guided)) - calculate_guided_proposal(settings_json) - elsif (settings_json = config_json[:legacyAutoyastStorage]) - calculate_autoyast_proposal(settings_json) - else - raise "Invalid config: #{serialized_config}" - end + config_json = JSON.parse(serialized_config, symbolize_names: true) + proposal.calculate_from_json(config_json) + proposal.success? ? 0 : 1 end - # Serialized storage config. It can contain storage or legacy AutoYaST settings: - # { "storage": ... } vs { "legacyAutoyastStorage": ... } + # Gets and serializes the storage config used to calculate the current proposal. # - # @return [String] - def serialized_storage_config - JSON.pretty_generate(storage_config) + # @return [String] Serialized config according to the JSON schema. + def recover_config + JSON.pretty_generate(proposal.config_json) end def install @@ -135,9 +136,9 @@ def deprecated_system dbus_interface STORAGE_INTERFACE do dbus_method(:Probe) { probe } dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config| - busy_while { apply_storage_config(serialized_config) } + busy_while { apply_config(serialized_config) } end - dbus_method(:GetConfig, "out config:s") { serialized_storage_config } + dbus_method(:GetConfig, "out serialized_config:s") { recover_config } dbus_method(:Install) { install } dbus_method(:Finish) { finish } dbus_reader(:deprecated_system, "b") @@ -179,10 +180,23 @@ def update_actions dbus_reader_attr_accessor :actions, "aa{sv}" end - # @todo Rename as "org.opensuse.Agama.Storage1.Proposal". PROPOSAL_CALCULATOR_INTERFACE = "org.opensuse.Agama.Storage1.Proposal.Calculator" private_constant :PROPOSAL_CALCULATOR_INTERFACE + # Calculates a guided proposal. + # + # @param settings_dbus [Hash] + # @return [Integer] 0 success; 1 error + def calculate_guided_proposal(settings_dbus) + logger.info("Calculating guided storage proposal from D-Bus: #{settings_dbus}") + + settings = ProposalSettingsConversion.from_dbus(settings_dbus, + config: config, logger: logger) + + proposal.calculate_guided(settings) + proposal.success? ? 0 : 1 + end + # List of disks available for installation # # Each device is represented by an array containing the name of the device and the label to @@ -220,58 +234,6 @@ def default_volume(mount_path) VolumeConversion.to_dbus(volume) end - module ProposalStrategy - GUIDED = "guided" - AUTOYAST = "autoyast" - end - - # Calculates a guided proposal. - # @deprecated - # - # @param dbus_settings [Hash] - # @return [Integer] 0 success; 1 error - def calculate_proposal(dbus_settings) - settings = ProposalSettingsConversion.from_dbus(dbus_settings, - config: config, logger: logger) - - logger.info( - "Calculating guided storage proposal from D-Bus.\n " \ - "D-Bus settings: #{dbus_settings}\n" \ - "Agama settings: #{settings.inspect}" - ) - - success = proposal.calculate_guided(settings) - success ? 0 : 1 - end - - # Whether a proposal was calculated. - # - # @return [Boolean] - def proposal_calculated? - proposal.calculated? - end - - # Proposal result, including information about success, strategy and settings. - # - # @return [Hash] Empty if there is no proposal yet. - def proposal_result - return {} unless proposal.calculated? - - if proposal.strategy?(ProposalStrategy::GUIDED) - { - "success" => proposal.success?, - "strategy" => ProposalStrategy::GUIDED, - "settings" => proposal.settings.to_json_settings.to_json - } - else - { - "success" => proposal.success?, - "strategy" => ProposalStrategy::AUTOYAST, - "settings" => proposal.settings.to_json - } - end - end - dbus_interface PROPOSAL_CALCULATOR_INTERFACE do dbus_reader :available_devices, "ao" @@ -284,16 +246,12 @@ def proposal_result [default_volume(mount_path)] end - # @todo Rename as CalculateGuided + # @todo Receive guided json settings. # # result: 0 success; 1 error - dbus_method(:Calculate, "in settings:a{sv}, out result:u") do |settings| - busy_while { calculate_proposal(settings) } + dbus_method(:Calculate, "in settings_dbus:a{sv}, out result:u") do |settings_dbus| + busy_while { calculate_guided_proposal(settings_dbus) } end - - dbus_reader :proposal_calculated?, "b", dbus_name: "Calculated" - - dbus_reader :proposal_result, "a{sv}", dbus_name: "Result" end ISCSI_INITIATOR_INTERFACE = "org.opensuse.Agama.Storage1.ISCSI.Initiator" @@ -396,61 +354,6 @@ def proposal backend.proposal end - # Calculates a guided proposal. - # - # @param settings_json [Hash] JSON settings according to schema. - # @return [Integer] 0 success; 1 error - def calculate_guided_proposal(settings_json) - proposal_settings = Agama::Storage::ProposalSettings - .new_from_json(settings_json, config: config) - - logger.info( - "Calculating guided storage proposal from D-Bus.\n" \ - "Input settings: #{settings_json}\n" \ - "Agama settings: #{proposal_settings.inspect}" - ) - - success = proposal.calculate_guided(proposal_settings) - success ? 0 : 1 - end - - # Calculates an AutoYaST proposal. - # - # @param settings_json [Hash] AutoYaST settings. - # @return [Integer] 0 success; 1 error - def calculate_autoyast_proposal(settings_json) - # Ensures keys are strings. - autoyast_settings = JSON.parse(settings_json.to_json) - - logger.info( - "Calculating AutoYaST storage proposal from D-Bus.\n" \ - "Input settings: #{settings_json}\n" \ - "AutoYaST settings: #{autoyast_settings}" - ) - - success = proposal.calculate_autoyast(autoyast_settings) - success ? 0 : 1 - end - - # Storage config from the current proposal, if any. - # - # @return [Hash] Storage config according to JSON schema. - def storage_config - if proposal.strategy?(ProposalStrategy::GUIDED) - { - storage: { - guided: proposal.settings.to_json_settings - } - } - elsif proposal.strategy?(ProposalStrategy::AUTOYAST) - { - autoyastLegacyStorage: proposal.settings - } - else - {} - end - end - def register_storage_callbacks backend.on_issues_change { issues_properties_changed } backend.on_deprecated_system_change { storage_properties_changed } @@ -510,7 +413,7 @@ def export_proposal @dbus_proposal = nil end - return unless proposal.strategy?(ProposalStrategy::GUIDED) + return unless proposal.guided? @dbus_proposal = DBus::Storage::Proposal.new(proposal, logger) @service.export(@dbus_proposal) diff --git a/service/lib/agama/dbus/storage/proposal.rb b/service/lib/agama/dbus/storage/proposal.rb index fd9c45428f..9050c8a057 100644 --- a/service/lib/agama/dbus/storage/proposal.rb +++ b/service/lib/agama/dbus/storage/proposal.rb @@ -52,9 +52,9 @@ def initialize(backend, logger) # # @return [Hash] def settings - return {} unless backend.settings + return {} unless backend.guided? - ProposalSettingsConversion.to_dbus(backend.settings) + ProposalSettingsConversion.to_dbus(backend.guided_settings) end # List of sorted actions in D-Bus format. diff --git a/service/lib/agama/storage/config.rb b/service/lib/agama/storage/config.rb new file mode 100644 index 0000000000..27ad3a4f42 --- /dev/null +++ b/service/lib/agama/storage/config.rb @@ -0,0 +1,196 @@ +# 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/configs" + +module Agama + module Storage + # Settings used to calculate an storage proposal. + class Config + # Boot settings. + # + # @return [Configs::Boot] + attr_accessor :boot + + # @return [Array] + attr_accessor :drives + + # @return [Array] + attr_accessor :volume_groups + + # @return [Array] + attr_accessor :md_raids + + # @return [Array] + attr_accessor :btrfs_raids + + # @return [Array] + attr_accessor :nfs_mounts + + def initialize + @boot = Configs::Boot.new + @drives = [] + @volume_groups = [] + @md_raids = [] + @btrfs_raids = [] + @nfs_mounts = [] + end + + # Creates a config from JSON hash according to schema. + # + # @param config_json [Hash] + # @param product_config [Agama::Config] + # + # @return [Storage::Config] + def self.new_from_json(config_json, product_config:) + ConfigConversions::FromJSON.new(config_json, product_config: product_config).convert + end + + # Name of the device that will presumably be used to boot the target system + # + # @return [String, nil] nil if there is no enough information to infer a possible boot disk + def boot_device + explicit_boot_device || implicit_boot_device + end + + # Device used for booting the target system + # + # @return [String, nil] nil if no disk is explicitly chosen + def explicit_boot_device + return nil unless boot.configure? + + boot.device + end + + # Device that seems to be expected to be used for booting, according to the drive definitions + # + # @return [String, nil] nil if the information cannot be inferred from the list of drives + def implicit_boot_device + # NOTE: preliminary implementation with very simplistic checks + root_drive = drives.find do |drive| + drive.partitions.any? { |p| p.filesystem&.root? } + end + + root_drive&.found_device&.name + end + + # Sets min and max sizes for all partitions and logical volumes with default size + # + # @param volume_builder [VolumeTemplatesBuilder] used to check the configuration of the + # product volume templates + def calculate_default_sizes(volume_builder) + default_size_devices.each do |dev| + dev.size.min = default_size(dev, :min, volume_builder) + dev.size.max = default_size(dev, :max, volume_builder) + end + end + + private + + # return [Array] + def filesystems + (drives + partitions).map(&:filesystem).compact + end + + # return [Array] + def partitions + drives.flat_map(&:partitions) + end + + # return [Array] + def default_size_devices + partitions.select { |p| p.size&.default? } + end + + # Min or max size that should be used for the given partition or logical volume + # + # @param device [Configs::Partition] device configured to have a default size + # @param attr [Symbol] :min or :max + # @param builder [VolumeTemplatesBuilder] see {#calculate_default_sizes} + def default_size(device, attr, builder) + path = device.filesystem&.path || "" + vol = builder.for(path) + return fallback_size(attr) unless vol + + # Theoretically, neither Volume#min_size or Volume#max_size can be nil + # At most they will be zero or unlimited, respectively + return vol.send(:"#{attr}_size") unless vol.auto_size? + + outline = vol.outline + size = size_with_fallbacks(outline, attr, builder) + size = size_with_ram(size, outline) + size_with_snapshots(size, device, outline) + end + + # TODO: these are the fallbacks used when constructing volumes, not sure if repeating them + # here is right + def fallback_size(attr) + return Y2Storage::DiskSize.zero if attr == :min + + Y2Storage::DiskSize.unlimited + end + + # @see #default_size + def size_with_fallbacks(outline, attr, builder) + fallback_paths = outline.send(:"#{attr}_size_fallback_for") + missing_paths = fallback_paths.reject { |p| proposed_path?(p) } + + size = outline.send(:"base_#{attr}_size") + missing_paths.inject(size) { |total, p| total + builder.for(p).send(:"#{attr}_size") } + end + + # @see #default_size + def size_with_ram(initial_size, outline) + return initial_size unless outline.adjust_by_ram? + + [initial_size, ram_size].max + end + + # @see #default_size + def size_with_snapshots(initial_size, device, outline) + return initial_size unless device.filesystem.btrfs_snapshots? + return initial_size unless outline.snapshots_affect_sizes? + + if outline.snapshots_size && outline.snapshots_size > DiskSize.zero + initial_size + outline.snapshots_size + else + multiplicator = 1.0 + (outline.snapshots_percentage / 100.0) + initial_size * multiplicator + end + end + + # Whether there is a separate filesystem configured for the given path + # + # @param path [String, Pathname] + # @return [Boolean] + def proposed_path?(path) + filesystems.any? { |fs| fs.path?(path) } + end + + # Return the total amount of RAM as DiskSize + # + # @return [DiskSize] current RAM size + def ram_size + @ram_size ||= Y2Storage::DiskSize.new(Y2Storage::StorageManager.instance.arch.ram_size) + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions.rb b/service/lib/agama/storage/config_conversions.rb new file mode 100644 index 0000000000..0412053670 --- /dev/null +++ b/service/lib/agama/storage/config_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/block_device" +require "agama/storage/config_conversions/drive" +require "agama/storage/config_conversions/encryption" +require "agama/storage/config_conversions/filesystem" +require "agama/storage/config_conversions/from_json" +require "agama/storage/config_conversions/partition" +require "agama/storage/config_conversions/partitionable" +require "agama/storage/config_conversions/search" +require "agama/storage/config_conversions/size" + +module Agama + module Storage + # Conversions for the storage config. + module ConfigConversions + end + end +end diff --git a/service/lib/agama/storage/config_conversions/block_device.rb b/service/lib/agama/storage/config_conversions/block_device.rb new file mode 100644 index 0000000000..7fcbb01077 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/block_device.rb @@ -0,0 +1,32 @@ +# 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/block_device/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for block device. + module BlockDevice + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/block_device/from_json.rb b/service/lib/agama/storage/config_conversions/block_device/from_json.rb new file mode 100644 index 0000000000..fdf1d30f16 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/block_device/from_json.rb @@ -0,0 +1,130 @@ +# 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/encryption/from_json" +require "agama/storage/config_conversions/filesystem/from_json" +require "agama/storage/config_conversions/filesystem_type/from_json" +require "agama/storage/configs/encryption" +require "agama/storage/configs/filesystem" +require "agama/storage/configs/filesystem_type" + +module Agama + module Storage + module ConfigConversions + module BlockDevice + # Block device conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param blk_device_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(blk_device_json, settings:, volume_builder:) + @blk_device_json = blk_device_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Drive, Configs::Partition] + # @return [Configs::Drive, Configs::Partition] + def convert(default) + default.dup.tap do |config| + config.encryption = convert_encrypt + config.filesystem = convert_filesystem + end + end + + private + + # @return [Hash] + attr_reader :blk_device_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @return [Configs::Encrypt, nil] + def convert_encrypt + encrypt_json = blk_device_json[:encryption] + return unless encrypt_json + + Encryption::FromJSON.new(encrypt_json, default: default_encrypt_config).convert + end + + # @return [Configs::Filesystem, nil] + def convert_filesystem + filesystem_json = blk_device_json[:filesystem] + return if filesystem_json.nil? + + default = default_filesystem_config(filesystem_json&.dig(:path) || "") + + # @todo Check whether the given filesystem can be used for the mount point. + # @todo Check whether snapshots can be configured and restore to default if needed. + + Filesystem::FromJSON.new(filesystem_json).convert(default) + end + + # @todo Recover values from ProductDefinition instead of ProposalSettings. + # + # Default encryption config from the product definition. + # + # @return [Configs::Encryption] + def default_encrypt_config + Configs::Encryption.new.tap do |config| + config.password = settings.encryption.password + config.method = settings.encryption.method + config.pbkd_function = settings.encryption.pbkd_function + end + end + + # Default format config from the product definition. + # + # @param mount_path [String] + # @return [Configs::Filesystem] + def default_filesystem_config(mount_path) + Configs::Filesystem.new.tap do |config| + config.type = default_fstype_config(mount_path) + end + end + + # @todo Recover values from ProductDefinition instead of VolumeTemplatesBuilder. + # + # Default filesystem config from the product definition. + # + # @param mount_path [String] + # @return [Configs::FilesystemType] + def default_fstype_config(mount_path) + volume = volume_builder.for(mount_path) + + Configs::FilesystemType.new.tap do |config| + config.fs_type = volume.fs_type + config.btrfs = volume.btrfs + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/drive.rb b/service/lib/agama/storage/config_conversions/drive.rb new file mode 100644 index 0000000000..f42dd41358 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/drive.rb @@ -0,0 +1,32 @@ +# 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/drive/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for drive. + module Drive + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/drive/from_json.rb b/service/lib/agama/storage/config_conversions/drive/from_json.rb new file mode 100644 index 0000000000..94dbfafbac --- /dev/null +++ b/service/lib/agama/storage/config_conversions/drive/from_json.rb @@ -0,0 +1,105 @@ +# 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/block_device/from_json" +require "agama/storage/config_conversions/search/from_json" +require "agama/storage/config_conversions/partitionable/from_json" +require "agama/storage/configs/drive" + +module Agama + module Storage + module ConfigConversions + module Drive + # Drive conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param drive_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(drive_json, settings:, volume_builder:) + @drive_json = drive_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Drive, nil] + # @return [Configs::Drive] + def convert(default = nil) + default_config = default.dup || Configs::Drive.new + + convert_drive(default_config).tap do |config| + search = convert_search(config.search) + config.search = search if search + end + end + + private + + # @return [Hash] + attr_reader :drive_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @param config [Configs::Drive] + # @return [Configs::Drive] + def convert_drive(config) + convert_block_device( + convert_partitionable(config) + ) + end + + # @param config [Configs::Drive] + def convert_block_device(config) + converter = BlockDevice::FromJSON.new(drive_json, + settings: settings, volume_builder: volume_builder) + + converter.convert(config) + end + + # @param config [Configs::Drive] + def convert_partitionable(config) + converter = Partitionable::FromJSON.new(drive_json, + settings: settings, volume_builder: volume_builder) + + converter.convert(config) + end + + # @param config [Configs::Search] + # @return [Configs::Search, nil] + def convert_search(config) + search_json = drive_json[:search] + return unless search_json + + converter = Search::FromJSON.new(search_json) + converter.convert(config) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/encryption.rb b/service/lib/agama/storage/config_conversions/encryption.rb new file mode 100644 index 0000000000..288818bb96 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/encryption.rb @@ -0,0 +1,32 @@ +# 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/encryption/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for encryption. + module Encryption + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/encryption/from_json.rb b/service/lib/agama/storage/config_conversions/encryption/from_json.rb new file mode 100644 index 0000000000..c9e193d694 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/encryption/from_json.rb @@ -0,0 +1,146 @@ +# 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/configs/encryption" +require "y2storage/encryption_method" +require "y2storage/pbkd_function" + +module Agama + module Storage + module ConfigConversions + module Encryption + # Encryption conversion from JSON hash according to schema. + class FromJSON + # @param encryption_json [Hash, String] + # @param default [Configs::Encrypt] + def initialize(encryption_json, default: nil) + @encryption_json = encryption_json + @default_config = default || Configs::Encryption.new + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @return [Configs::Encryption] + def convert + default_config.dup.tap do |config| + convert_luks1(config) || + convert_luks2(config) || + convert_pervasive_luks2(config) || + convert_swap_encryption(config) + end + end + + private + + # @return [Hash, String] + attr_reader :encryption_json + + # @return [Configs::Encryption] + attr_reader :default_config + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match LUKS1 schema. + def convert_luks1(config) + luks1_json = encryption_json.is_a?(Hash) && encryption_json[:luks1] + return unless luks1_json + + key_size = convert_key_size(luks1_json) + cipher = convert_cipher(luks1_json) + + config.method = Y2Storage::EncryptionMethod::LUKS1 + config.password = convert_password(luks1_json) + config.key_size = key_size if key_size + config.cipher = cipher if cipher + end + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match LUKS2 schema. + def convert_luks2(config) + luks2_json = encryption_json.is_a?(Hash) && encryption_json[:luks2] + return unless luks2_json + + key_size = convert_key_size(luks2_json) + cipher = convert_cipher(luks2_json) + label = convert_label + pbkdf = convert_pbkd_function + + config.method = Y2Storage::EncryptionMethod::LUKS2 + config.password = convert_password(luks2_json) + config.key_size = key_size if key_size + config.cipher = cipher if cipher + config.label = label if label + config.pbkd_function = pbkdf if pbkdf + end + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match pervasive LUKS2 schema. + def convert_pervasive_luks2(config) + pervasive_json = encryption_json.is_a?(Hash) && encryption_json[:pervasive_luks2] + return unless pervasive_json + + config.method = Y2Storage::EncryptionMethod::PERVASIVE_LUKS2 + config.password = convert_password(pervasive_json) + end + + # @param config [Configs::Encryption] + # @return [Configs::Encryption, nil] nil if JSON does not match a swap encryption schema. + def convert_swap_encryption(config) + return unless encryption_json.is_a?(String) + + # @todo Report issue if the schema admits an unknown method. + method = Y2Storage::EncryptionMethod.find(encryption_json.to_sym) + return unless method + + config.method = method + end + + # @param method_json [Hash] + # @return [String, nil] + def convert_password(method_json) + method_json[:password] + end + + # @param method_json [Hash] + # @return [Integer, nil] + def convert_key_size(method_json) + method_json[:keySize] + end + + # @param method_json [Hash] + # @return [String, nil] + def convert_cipher(method_json) + method_json[:cipher] + end + + # @return [String, nil] + def convert_label + encryption_json.dig(:luks2, :label) + end + + # @return [Y2Storage::PbkdFunction, nil] + def convert_pbkd_function + Y2Storage::PbkdFunction.find(encryption_json.dig(:luks2, :pbkdFunction)) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem.rb b/service/lib/agama/storage/config_conversions/filesystem.rb new file mode 100644 index 0000000000..35828920a5 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem.rb @@ -0,0 +1,32 @@ +# 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/filesystem/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for filesystem. + module Filesystem + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem/from_json.rb b/service/lib/agama/storage/config_conversions/filesystem/from_json.rb new file mode 100644 index 0000000000..b3a6056f86 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem/from_json.rb @@ -0,0 +1,84 @@ +# 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/filesystem_type/from_json" +require "agama/storage/configs/filesystem" +require "y2storage/filesystems/mount_by_type" + +module Agama + module Storage + module ConfigConversions + module Filesystem + # Filesystem conversion from JSON hash according to schema. + class FromJSON + # @param filesystem_json [Hash] + def initialize(filesystem_json) + @filesystem_json = filesystem_json + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Filesystem, nil] + # @return [Configs::Filesystem] + def convert(default = nil) + default_config = default.dup || Configs::Filesystem.new + + default_config.tap do |config| + mount_by = convert_mount_by + type = convert_type(config.type) + label = filesystem_json[:label] + mkfs_options = filesystem_json[:mkfsOptions] + + config.path = filesystem_json[:path] + config.mount_options = filesystem_json[:mountOptions] || [] + config.mount_by = mount_by if mount_by + config.type = type if type + config.label = label if label + config.mkfs_options = mkfs_options if mkfs_options + end + end + + private + + # @return [Hash] + attr_reader :filesystem_json + + # @return [Y2Storage::Filesystems::MountByType, nil] + def convert_mount_by + value = filesystem_json[:mountBy] + return unless value + + Y2Storage::Filesystems::MountByType.find(value.to_sym) + end + + # @param default [Configs::FilesystemType, nil] + # @return [Configs::FilesystemType, nil] + def convert_type(default = nil) + filesystem_type_json = filesystem_json[:type] + return unless filesystem_type_json + + FilesystemType::FromJSON.new(filesystem_type_json).convert(default) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem_type.rb b/service/lib/agama/storage/config_conversions/filesystem_type.rb new file mode 100644 index 0000000000..ace22105b4 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem_type.rb @@ -0,0 +1,32 @@ +# 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/filesystem_type/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for filesystem types + module FilesystemType + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/filesystem_type/from_json.rb b/service/lib/agama/storage/config_conversions/filesystem_type/from_json.rb new file mode 100644 index 0000000000..960838f84c --- /dev/null +++ b/service/lib/agama/storage/config_conversions/filesystem_type/from_json.rb @@ -0,0 +1,81 @@ +# 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/configs/btrfs" +require "agama/storage/configs/filesystem_type" +require "y2storage/filesystems/type" + +module Agama + module Storage + module ConfigConversions + module FilesystemType + # Filesystem type conversion from JSON hash according to schema. + class FromJSON + # @param filesystem_type_json [Hash, String] + def initialize(filesystem_type_json) + @filesystem_type_json = filesystem_type_json + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::FilesystemType, nil] + # @return [Configs::FilesystemType] + def convert(default = nil) + default_config = default.dup || Configs::FilesystemType.new + + default_config.tap do |config| + btrfs = convert_btrfs(config.btrfs) + + config.fs_type = convert_type + config.btrfs = btrfs if btrfs + end + end + + private + + # @return [Hash, String] + attr_reader :filesystem_type_json + + # @return [Y2Storage::Filesystems::Type] + def convert_type + value = filesystem_type_json.is_a?(String) ? filesystem_type_json : "btrfs" + Y2Storage::Filesystems::Type.find(value.to_sym) + end + + # @param default [Configs::Btrfs, nil] + # @return [Configs::Btrfs, nil] + def convert_btrfs(default = nil) + return if filesystem_type_json.nil? || filesystem_type_json.is_a?(String) + + btrfs_json = filesystem_type_json[:btrfs] + default_config = default.dup || Configs::Btrfs.new + + default_config.tap do |config| + snapshots = btrfs_json[:snapshots] + + config.snapshots = snapshots unless snapshots.nil? + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/from_json.rb b/service/lib/agama/storage/config_conversions/from_json.rb new file mode 100644 index 0000000000..8fe8b0abfa --- /dev/null +++ b/service/lib/agama/storage/config_conversions/from_json.rb @@ -0,0 +1,122 @@ +# 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/drive/from_json" +require "agama/storage/configs/boot" +require "agama/storage/proposal_settings_reader" + +module Agama + module Storage + module ConfigConversions + # Config conversion from JSON hash according to schema. + # + # TODO: The approach for generating a Config from JSON could be improved: + # * All the FromJSON classes receive only a JSON and an optional default config to start + # converting from it. + # * There should be a "config generator" class which knows the ProductDefinition and creates + # config objects calling to the proper FromJSON classes, passing the default config for + # each case (drive, partition, etc). + # + # For example: + # + # def generate_drive(drive_json) + # default = default_drive(drive_json.dig(:filesystem, :path)) + # drive = Drive::FromJson.new(drive_json).convert(default) + # drive.partitions = drive_json[:partitions].map do |partition_json| + # default = default_partition(partition_json.dig(:fileystem, :path)) + # Partition::FromJSON.new(partition_json).convert(default) + # end + # drive + # end + # + # This improvement could be done at the time of introducing the ProductDefinition class. + class FromJSON + # @todo Replace product_config param by a ProductDefinition. + # + # @param config_json [Hash] + # @param product_config [Agama::Config] + def initialize(config_json, product_config:) + @config_json = config_json + @product_config = product_config + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @return [Storage::Config] + def convert + # @todo Raise error if config_json does not match the JSON schema. + Storage::Config.new.tap do |config| + boot = convert_boot + drives = convert_drives + + config.boot = boot if boot + config.drives = drives if drives + config.calculate_default_sizes(volume_builder) + end + end + + private + + # @return [Hash] + attr_reader :config_json + + # @return [Agama::Config] + attr_reader :product_config + + # @return [Configs::Boot, nil] + def convert_boot + boot_json = config_json[:boot] + return unless boot_json + + Configs::Boot.new.tap do |config| + config.configure = boot_json[:configure] + config.device = boot_json[:device] + end + end + + # @return [Array, nil] + def convert_drives + drives_json = config_json[:drives] + return unless drives_json + + drives_json.map { |d| convert_drive(d) } + end + + # @return [Configs::Drive] + def convert_drive(drive_json) + Drive::FromJSON.new(drive_json, + settings: settings, volume_builder: volume_builder).convert + end + + # @return [ProposalSettings] + def settings + @settings ||= ProposalSettingsReader.new(product_config).read + end + + # @return [VolumeTemplatesBuilder] + def volume_builder + @volume_builder ||= VolumeTemplatesBuilder.new_from_config(product_config) + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partition.rb b/service/lib/agama/storage/config_conversions/partition.rb new file mode 100644 index 0000000000..52b67d2f50 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partition.rb @@ -0,0 +1,32 @@ +# 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/partition/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for partition. + module Partition + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partition/from_json.rb b/service/lib/agama/storage/config_conversions/partition/from_json.rb new file mode 100644 index 0000000000..7784029cd0 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partition/from_json.rb @@ -0,0 +1,117 @@ +# 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/block_device/from_json" +require "agama/storage/config_conversions/search/from_json" +require "agama/storage/config_conversions/size/from_json" +require "agama/storage/configs/partition" +require "y2storage/partition_id" + +module Agama + module Storage + module ConfigConversions + module Partition + # Partition conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param partition_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(partition_json, settings:, volume_builder:) + @partition_json = partition_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Partition, nil] + # @return [Configs::Partition] + def convert(default = nil) + default_config = default.dup || Configs::Partition.new + + convert_block_device(default_config).tap do |config| + search = convert_search(config.search) + delete = partition_json[:delete] + delete_if_needed = partition_json[:deleteIfNeeded] + id = convert_id + size = convert_size(config.size) + + config.search = search if search + config.delete = delete unless delete.nil? + config.delete_if_needed = delete_if_needed unless delete_if_needed.nil? + config.id = id if id + config.size = size if size + end + end + + private + + # @return [Hash] + attr_reader :partition_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @param config [Configs::Partition] + # @return [Configs::Partition] + def convert_block_device(config) + converter = BlockDevice::FromJSON.new(partition_json, + settings: settings, volume_builder: volume_builder) + + converter.convert(config) + end + + # @param config [Configs::Search] + # @return [Configs::Search, nil] + def convert_search(config) + search_json = partition_json[:search] + return unless search_json + + converter = Search::FromJSON.new(search_json) + converter.convert(config) + end + + # @return [Y2Storage::PartitionId, nil] + def convert_id + value = partition_json[:id] + return unless value + + Y2Storage::PartitionId.find(value) + end + + # @param config [Configs::Size] + # @return [Configs::Size, nil] + def convert_size(config) + size_json = partition_json[:size] + return unless size_json + + Size::FromJSON.new(size_json).convert(config) + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partitionable.rb b/service/lib/agama/storage/config_conversions/partitionable.rb new file mode 100644 index 0000000000..95094cb0a4 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partitionable.rb @@ -0,0 +1,32 @@ +# 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/partitionable/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for partitionable. + module Partitionable + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/partitionable/from_json.rb b/service/lib/agama/storage/config_conversions/partitionable/from_json.rb new file mode 100644 index 0000000000..330ef37802 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/partitionable/from_json.rb @@ -0,0 +1,90 @@ +# 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/partition/from_json" +require "y2storage/partition_tables/type" + +module Agama + module Storage + module ConfigConversions + module Partitionable + # Partitionable device conversion from JSON hash according to schema. + class FromJSON + # @todo Replace settings and volume_builder params by a ProductDefinition. + # + # @param partitionable_json [Hash] + # @param settings [ProposalSettings] + # @param volume_builder [VolumeTemplatesBuilder] + def initialize(partitionable_json, settings:, volume_builder:) + @partitionable_json = partitionable_json + @settings = settings + @volume_builder = volume_builder + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Drive] + # @return [Configs::Drive] + def convert(default) + default.dup.tap do |config| + config.ptable_type = convert_ptable_type + config.partitions = convert_partitions + end + end + + private + + # @return [Hash] + attr_reader :partitionable_json + + # @return [ProposalSettings] + attr_reader :settings + + # @return [VolumeTemplatesBuilder] + attr_reader :volume_builder + + # @return [Y2Storage::PartitionTables::Type, nil] + def convert_ptable_type + value = partitionable_json[:ptableType] + return unless value + + Y2Storage::PartitionTables::Type.find(value) + end + + # @return [Array] + def convert_partitions + partitions_json = partitionable_json[:partitions] + return [] unless partitions_json + + partitions_json.map { |p| convert_partition(p) } + end + + # @param partition_json [Hash] + # @return [Configs::Partition] + def convert_partition(partition_json) + Partition::FromJSON.new(partition_json, + settings: settings, volume_builder: volume_builder).convert + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/search.rb b/service/lib/agama/storage/config_conversions/search.rb new file mode 100644 index 0000000000..5a6c6d44f3 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/search.rb @@ -0,0 +1,32 @@ +# 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/search/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for search. + module Search + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/search/from_json.rb b/service/lib/agama/storage/config_conversions/search/from_json.rb new file mode 100644 index 0000000000..a03be629d0 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/search/from_json.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 Search + # Search conversion from JSON hash according to schema. + class FromJSON + # @param search_json [Hash, String] + def initialize(search_json) + @search_json = search_json + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Search, nil] + # @return [Configs::Search] + def convert(default = nil) + default_config = default.dup || Configs::Search.new + + default_config.tap do |config| + name = convert_name + not_found = convert_not_found + + config.name = name if name + config.if_not_found = not_found if not_found + end + end + + private + + # @return [Hash, String] + attr_reader :search_json + + # @return [String, nil] + def convert_name + return search_json if search_json.is_a?(String) + + search_json.dig(:condition, :name) + end + + # @return [Symbol, nil] + def convert_not_found + return if search_json.is_a?(String) + + value = search_json[:ifNotFound] + return unless value + + value.to_sym + end + end + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/size.rb b/service/lib/agama/storage/config_conversions/size.rb new file mode 100644 index 0000000000..b85b9e734d --- /dev/null +++ b/service/lib/agama/storage/config_conversions/size.rb @@ -0,0 +1,32 @@ +# 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/size/from_json" + +module Agama + module Storage + module ConfigConversions + # Conversions for size. + module Size + end + end + end +end diff --git a/service/lib/agama/storage/config_conversions/size/from_json.rb b/service/lib/agama/storage/config_conversions/size/from_json.rb new file mode 100644 index 0000000000..02fe5a6102 --- /dev/null +++ b/service/lib/agama/storage/config_conversions/size/from_json.rb @@ -0,0 +1,79 @@ +# 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/configs/size" +require "y2storage/disk_size" + +module Agama + module Storage + module ConfigConversions + module Size + # Size conversion from JSON hash according to schema. + class FromJSON + # @param size_json [Hash] + def initialize(size_json) + @size_json = size_json + end + + # Performs the conversion from Hash according to the JSON schema. + # + # @param default [Configs::Size, nil] + # @return [Configs::Size] + def convert(default = nil) + default_config = default.dup || Configs::Size.new + + default_config.tap do |config| + config.default = false + config.min = convert_size(:min) + config.max = convert_size(:max) || Y2Storage::DiskSize.unlimited + end + end + + private + + # @return [Hash] + attr_reader :size_json + + # @return [Y2Storage::DiskSize, nil] + def convert_size(field) + value = case size_json + when Hash + size_json[field] + when Array + field == :max ? size_json[1] : size_json[0] + else + size_json + end + + return unless value + + begin + # This parses without legacy_units, ie. "1 GiB" != "1 GB" + Y2Storage::DiskSize.new(value) + rescue TypeError + # JSON schema validations should prevent this from happening + end + end + end + end + end + end +end diff --git a/service/lib/agama/storage/configs.rb b/service/lib/agama/storage/configs.rb new file mode 100644 index 0000000000..7f276374c0 --- /dev/null +++ b/service/lib/agama/storage/configs.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. + +module Agama + module Storage + # Namespace for all the supported settings to configure storage + module Configs + end + end +end + +require "agama/storage/configs/boot" +require "agama/storage/configs/btrfs" +require "agama/storage/configs/drive" +require "agama/storage/configs/encryption" +require "agama/storage/configs/filesystem" +require "agama/storage/configs/filesystem_type" +require "agama/storage/configs/partition" +require "agama/storage/configs/search" +require "agama/storage/configs/size" diff --git a/service/lib/agama/storage/btrfs_settings.rb b/service/lib/agama/storage/configs/boot.rb similarity index 52% rename from service/lib/agama/storage/btrfs_settings.rb rename to service/lib/agama/storage/configs/boot.rb index 51b990b330..88071b086d 100644 --- a/service/lib/agama/storage/btrfs_settings.rb +++ b/service/lib/agama/storage/configs/boot.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# Copyright (c) [2023] SUSE LLC +# Copyright (c) [2024] SUSE LLC # # All Rights Reserved. # @@ -21,29 +21,25 @@ module Agama module Storage - # Settings regarding Btrfs for a given Volume - class BtrfsSettings - # Whether the volume contains Btrfs snapshots - # - # @return [Boolean] - attr_accessor :snapshots - alias_method :snapshots?, :snapshots + module Configs + # Boot configuration. + class Boot + # Whether to configure partitions for booting. + # + # @return [Boolean] + attr_accessor :configure + alias_method :configure?, :configure - # @return [Boolean] - attr_accessor :read_only - alias_method :read_only?, :read_only + # Device to use for booting. + # + # @return [String, nil] if nil, then the proposal decides the booting device, normally the + # device for allocating root. + attr_accessor :device - # @return [Array, nil] if nil, a historical fallback list may - # be applied depending on the mount path of the volume - attr_accessor :subvolumes - - # @return [String] - attr_accessor :default_subvolume - - def initialize - @snapshots = false - @read_only = false - @default_subvolume = "" + # Constructor + def initialize + @configure = true + end end end end diff --git a/service/lib/agama/storage/configs/btrfs.rb b/service/lib/agama/storage/configs/btrfs.rb new file mode 100644 index 0000000000..1dd3cf4dec --- /dev/null +++ b/service/lib/agama/storage/configs/btrfs.rb @@ -0,0 +1,53 @@ +# 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 Configs + # Btrfs configuration. + class Btrfs + # Whether there are snapshots. + # + # @return [Boolean] + attr_accessor :snapshots + alias_method :snapshots?, :snapshots + + # @return [Boolean] + attr_accessor :read_only + alias_method :read_only?, :read_only + + # @return [Array, nil] if nil, a historical fallback list + # may be applied depending on the mount path of the volume + attr_accessor :subvolumes + + # @return [String] + attr_accessor :default_subvolume + + # Constructor + def initialize + @snapshots = false + @read_only = false + @default_subvolume = "" + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/drive.rb b/service/lib/agama/storage/configs/drive.rb new file mode 100644 index 0000000000..ff1b8260f5 --- /dev/null +++ b/service/lib/agama/storage/configs/drive.rb @@ -0,0 +1,70 @@ +# 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/configs/search" + +module Agama + module Storage + module Configs + # Section of the configuration representing a device that is expected to exist in the target + # system and that can be used as a regular disk. + class Drive + # @return [Search] + attr_accessor :search + + # @return [Encryption, nil] + attr_accessor :encryption + + # @return [Filesystem, nil] + attr_accessor :filesystem + + # @return [Y2Storage::PartitionTables::Type, nil] + attr_accessor :ptable_type + + # @return [Array] + attr_accessor :partitions + + # Constructor + def initialize + @partitions = [] + # All drives are expected to match a real device in the system, so let's ensure a search. + @search = Search.new + end + + # Assigned device according to the search. + # + # @see Y2Storage::Proposal::AgamaSearcher + # + # @return [Y2Storage::Device, nil] + def found_device + search.device + end + + # Whether the drive definition contains partition definitions + # + # @return [Boolean] + def partitions? + partitions.any? + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/encryption.rb b/service/lib/agama/storage/configs/encryption.rb new file mode 100644 index 0000000000..5b28ed4ea6 --- /dev/null +++ b/service/lib/agama/storage/configs/encryption.rb @@ -0,0 +1,62 @@ +# 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 "y2storage/secret_attributes" + +module Agama + module Storage + module Configs + # Configuration setting describing the desired encryption for a device + class Encryption + include Y2Storage::SecretAttributes + + # @return [Y2Storage::EncryptionMethod::Base] + attr_accessor :method + + # @!attribute password + # Password to use if the encryption method requires one + # @return [String, nil] nil if undetermined or not needed + secret_attr :password + + # PBKD function to use for LUKS2 + # + # @return [Y2Storage::PbkdFunction, nil] Can be nil for methods that are not LUKS2 + attr_accessor :pbkd_function + + # Optional LUKS2 label + # + # @return [String, nil] + attr_accessor :label + + # Optional cipher if LUKS is going to be used + # + # @return [String, nil] + attr_accessor :cipher + + # Specific key size (in bits) if LUKS is going to be used + # + # @return [Integer,nil] If nil, the default key size will be used. If an integer + # value is used, it has to be a multiple of 8 + attr_accessor :key_size + end + end + end +end diff --git a/service/lib/agama/storage/configs/filesystem.rb b/service/lib/agama/storage/configs/filesystem.rb new file mode 100644 index 0000000000..4b126ddd26 --- /dev/null +++ b/service/lib/agama/storage/configs/filesystem.rb @@ -0,0 +1,83 @@ +# 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 "pathname" + +module Agama + module Storage + module Configs + # File system configuration. + class Filesystem + # @return [Pathname] Object that represents the root path. + ROOT_PATH = Pathname.new("/").freeze + + # @return [String, nil] + attr_accessor :path + + # @return [Configs::FilesystemType, nil] + attr_accessor :type + + # @return [String, nil] + attr_accessor :label + + # @return [Array] + attr_accessor :mkfs_options + + # @return [Array] + attr_accessor :mount_options + + # @return [Y2Storage::Filesystems::MountByType, nil] + attr_accessor :mount_by + + def initialize + @mount_options = [] + @mkfs_options = [] + end + + # Whether the given path is equivalent to {#path} + # + # This method is more robust than a simple string comparison, since it takes + # into account trailing slashes and similar potential problems. + # + # @param other_path [String, Pathname] + # @return [Boolean] + def path?(other_path) + return false unless path + + Pathname.new(other_path).cleanpath == Pathname.new(path).cleanpath + end + + # Whether the mount point is root + # @return [Boolean] + def root? + path?(ROOT_PATH) + end + + # @return [Boolean] + def btrfs_snapshots? + return false unless type&.fs_type&.is?(:btrfs) + + type.btrfs&.snapshots? + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/filesystem_type.rb b/service/lib/agama/storage/configs/filesystem_type.rb new file mode 100644 index 0000000000..d9ef3cddf1 --- /dev/null +++ b/service/lib/agama/storage/configs/filesystem_type.rb @@ -0,0 +1,34 @@ +# 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 Configs + class FilesystemType + # @return [Y2Storage::Filesystems::Type, nil] + attr_accessor :fs_type + + # @return [Configs::Btrfs, nil] + attr_accessor :btrfs + end + end + end +end diff --git a/service/lib/agama/storage/configs/partition.rb b/service/lib/agama/storage/configs/partition.rb new file mode 100644 index 0000000000..83d048f46b --- /dev/null +++ b/service/lib/agama/storage/configs/partition.rb @@ -0,0 +1,69 @@ +# 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/configs/size" + +module Agama + module Storage + module Configs + # Section of the configuration representing a partition + class Partition + # @return [Search, nil] + attr_accessor :search + + # @return [Boolean] + attr_accessor :delete + alias_method :delete?, :delete + + # @return [Boolean] + attr_accessor :delete_if_needed + alias_method :delete_if_needed?, :delete_if_needed + + # @return [Y2Storage::PartitionId, nil] + attr_accessor :id + + # @return [Size] + attr_accessor :size + + # @return [Encryption, nil] + attr_accessor :encryption + + # @return [Filesystem, nil] + attr_accessor :filesystem + + def initialize + @size = Size.new + @delete = false + @delete_if_needed = false + end + + # Assigned device according to the search. + # + # @see Y2Storage::Proposal::AgamaSearcher + # + # @return [Y2Storage::Device, nil] + def found_device + search&.device + end + end + end + end +end diff --git a/service/lib/agama/storage/configs/search.rb b/service/lib/agama/storage/configs/search.rb new file mode 100644 index 0000000000..0f886c4cd9 --- /dev/null +++ b/service/lib/agama/storage/configs/search.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. + +module Agama + module Storage + module Configs + # Configuration used to match drives, partitions and other device definition with devices + # from the initial devicegraph + class Search + # Found device, if any + # @return [Y2Storage::Device, nil] + attr_reader :device + + # Name of the device to find. + # @return [String, nil] + attr_accessor :name + + # What to do if the search does not match with the expected number of devices + # @return [:create, :skip, :error] + attr_accessor :if_not_found + + # Constructor + def initialize + @if_not_found = :error + end + + # Whether the search does not define any specific condition. + # + # @return [Boolean] + def any_device? + name.nil? + end + + # Whether the search was already resolved. + # + # @return [Boolean] + def resolved? + !!@resolved + end + + # Resolves the search with the given device. + # + # @param device [Y2Storage::Device, nil] + def resolve(device = nil) + @device = device + @resolved = true + end + + # Whether the section containing the search should be skipped + # + # @return [Boolean] + def skip_device? + resolved? && device.nil? && if_not_found == :skip + end + end + end + end +end diff --git a/service/lib/agama/storage/boot_settings.rb b/service/lib/agama/storage/configs/size.rb similarity index 65% rename from service/lib/agama/storage/boot_settings.rb rename to service/lib/agama/storage/configs/size.rb index 228668280e..5c3d408d84 100644 --- a/service/lib/agama/storage/boot_settings.rb +++ b/service/lib/agama/storage/configs/size.rb @@ -21,21 +21,27 @@ module Agama module Storage - # Class for configuring the boot settings of the Agama storage proposal. - class BootSettings - # Whether to configure partitions for booting. - # - # @return [Boolean] - attr_accessor :configure - alias_method :configure?, :configure + module Configs + # Size configuration. + class Size + # @return [Boolean] + attr_accessor :default - # Device to use for booting. - # - # @return [String, nil] nil means use installation device. - attr_accessor :device + # @return [Y2Storage::DiskSize, nil] + attr_accessor :min - def initialize - @configure = true + # @return [Y2Storage::DiskSize, nil] + attr_accessor :max + + # Constructor + def initialize + @default = true + end + + # @return [Boolean] + def default? + !!@default + end end end end diff --git a/service/lib/agama/storage/encryption_settings.rb b/service/lib/agama/storage/encryption_settings.rb index eb755d5b62..2d6e2748e9 100644 --- a/service/lib/agama/storage/encryption_settings.rb +++ b/service/lib/agama/storage/encryption_settings.rb @@ -35,7 +35,7 @@ class EncryptionSettings ].freeze private_constant :METHODS - # @!attribute encryption_password + # @!attribute password # Password to use when creating new encryption devices # @return [String, nil] nil if undetermined secret_attr :password diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index eb826128b4..f8d014e04f 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -21,17 +21,20 @@ require "agama/issue" require "agama/storage/actions_generator" +require "agama/storage/config_conversions/from_json" +require "agama/storage/proposal_settings" require "agama/storage/proposal_strategies" +require "json" require "yast" require "y2storage" module Agama module Storage - # Backend class to calculate a storage proposal. + # Class used for calculating a storage proposal. class Proposal include Yast::I18n - # @param config [Config] Agama config + # @param config [Agama::Config] Agama config # @param logger [Logger] def initialize(config, logger: nil) textdomain "agama" @@ -73,36 +76,92 @@ def available_devices disk_analyzer&.candidate_disks || [] end - # Settings used to calculate the current proposal. + # Storage config from the current proposal, if any. # - # The type depends on the kind of proposal, see {#calculate_guided} and {#calculate_autoyast}. - # - # @return [Agama::Storage::ProposalSettings, Array] - def settings - return unless calculated? - - strategy_object.settings + # @return [Hash] Storage config according to JSON schema. + def config_json + return {} unless calculated? + + case strategy_object + when ProposalStrategies::Guided + { + storage: { + guided: strategy_object.settings.to_json_settings + } + } + when ProposalStrategies::Agama + # @todo Convert config to JSON if there is no raw config. + raw_config || {} + when ProposalStrategies::Autoyast + raw_config + else + {} + end end - # Calculates a new guided proposal. + # Calculates a new proposal using the guided strategy. # - # @param settings [Agama::Storage::ProposalSettings] settings to calculate the proposal. - # @return [Boolean] whether the proposal was correctly calculated. + # @param settings [Agama::Storage::ProposalSettings] + # @return [Boolean] Whether the proposal successes. def calculate_guided(settings) + logger.info("Calculating proposal with guided strategy: #{settings.inspect}") + @raw_config = nil @strategy_object = ProposalStrategies::Guided.new(config, logger, settings) calculate end - # Calculates a new legacy AutoYaST proposal. + # Calculates a new proposal using the agama strategy. + # + # @param storage_config [Agama::Storage::Config] + # @return [Boolean] Whether the proposal successes. + def calculate_agama(storage_config) + logger.info("Calculating proposal with agama strategy: #{storage_config.inspect}") + @raw_config = nil + @strategy_object = ProposalStrategies::Agama.new(config, logger, storage_config) + calculate + end + + # Calculates a new proposal using the autoyast strategy. # # @param partitioning [Array] Hash-based representation of the section - # of the AutoYaST profile - # @return [Boolean] whether the proposal was correctly calculated. + # of the AutoYaST profile. + # @return [Boolean] Whether the proposal successes. def calculate_autoyast(partitioning) + logger.info("Calculating proposal with autoyast strategy: #{partitioning}") + @raw_config = nil + # Ensures keys are strings. + partitioning = JSON.parse(partitioning.to_json) @strategy_object = ProposalStrategies::Autoyast.new(config, logger, partitioning) calculate end + # Calculates a new proposal using the given JSON config. + # + # @raise If the config is not valid. + # + # @param config_json [Hash] Storage config according to the JSON schema. + # @return [Boolean] Whether the proposal successes. + def calculate_from_json(config_json) + # @todo Validate config_json with JSON schema. + + guided_json = config_json.dig(:storage, :guided) + storage_json = config_json[:storage] + autoyast_json = config_json[:legacyAutoyastStorage] + + if guided_json + calculate_guided_from_json(guided_json) + elsif storage_json + calculate_agama_from_json(storage_json) + elsif autoyast_json + calculate_autoyast(autoyast_json) + else + raise "Invalid storage config: #{config_json}" + end + + @raw_config = config_json + success? + end + # Storage actions. # # @return [Array] @@ -115,29 +174,61 @@ def actions ActionsGenerator.new(probed, target).generate end - # Whether the current proposal was calculated the given strategy (:autoyast or :guided). + # Whether the guided strategy was used for calculating the current proposal. # - # @param id [#downcase] # @return [Boolean] - def strategy?(id) + def guided? return false unless calculated? - id.downcase.to_sym == strategy_object.id + strategy_object.is_a?(ProposalStrategies::Guided) + end + + # Settings used for calculating the guided proposal, if any. + # + # @return [ProposalSettings, nil] + def guided_settings + return unless guided? + + strategy_object.settings end private - # @return [Config] + # @return [Agama::Config] attr_reader :config # @return [Logger] attr_reader :logger + # @return [ProposalStrategies::Base] attr_reader :strategy_object - # Calculates a new proposal. + # @return [Hash] JSON config without processing. + attr_reader :raw_config + + # Calculates a proposal from guided JSON settings. + # + # @param guided_json [Hash] e.g., { "target": { "disk": "/dev/vda" } }. + # @return [Boolean] Whether the proposal successes. + def calculate_guided_from_json(guided_json) + settings = ProposalSettings.new_from_json(guided_json, config: config) + calculate_guided(settings) + end + + # Calculates a proposal from storage JSON settings. + # + # @param storage_json [Hash] e.g., { "drives": [] }. + # @return [Boolean] Whether the proposal successes. + def calculate_agama_from_json(storage_json) + storage_config = ConfigConversions::FromJSON + .new(storage_json, product_config: config) + .convert + calculate_agama(storage_config) + end + + # Calculates a new proposal with the assigned strategy. # - # @return [Boolean] whether the proposal was correctly calculated. + # @return [Boolean] Whether the proposal successes. def calculate return false unless storage_manager.probed? diff --git a/service/lib/agama/storage/proposal_settings.rb b/service/lib/agama/storage/proposal_settings.rb index 8ec12201ae..595d8da953 100644 --- a/service/lib/agama/storage/proposal_settings.rb +++ b/service/lib/agama/storage/proposal_settings.rb @@ -19,7 +19,7 @@ # 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/boot_settings" +require "agama/storage/configs/boot" require "agama/storage/device_settings" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings_conversions" @@ -34,9 +34,9 @@ class ProposalSettings # @return [DeviceSettings::Disk, DeviceSettings::NewLvmVg, DeviceSettings::ReusedLvmVg] attr_accessor :device - # Boot settings. + # Boot config. # - # @return [BootSettings] + # @return [Configs::Boot] attr_accessor :boot # Encryption settings. @@ -56,7 +56,7 @@ class ProposalSettings def initialize @device = DeviceSettings::Disk.new - @boot = BootSettings.new + @boot = Configs::Boot.new @encryption = EncryptionSettings.new @space = SpaceSettings.new @volumes = [] diff --git a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb b/service/lib/agama/storage/proposal_settings_conversions/from_json.rb index 6cd1a0b872..e709c98aa7 100644 --- a/service/lib/agama/storage/proposal_settings_conversions/from_json.rb +++ b/service/lib/agama/storage/proposal_settings_conversions/from_json.rb @@ -19,7 +19,7 @@ # 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/boot_settings" +require "agama/storage/configs/boot" require "agama/storage/device_settings" require "agama/storage/encryption_settings" require "agama/storage/proposal_settings_reader" @@ -87,7 +87,7 @@ def boot_conversion boot_json = settings_json[:boot] return unless boot_json - Agama::Storage::BootSettings.new.tap do |boot_settings| + Agama::Storage::Configs::Boot.new.tap do |boot_settings| boot_settings.configure = boot_json[:configure] boot_settings.device = boot_json[:device] end diff --git a/service/lib/agama/storage/proposal_settings_reader.rb b/service/lib/agama/storage/proposal_settings_reader.rb index e0c038fd6f..90cdda64a4 100644 --- a/service/lib/agama/storage/proposal_settings_reader.rb +++ b/service/lib/agama/storage/proposal_settings_reader.rb @@ -19,6 +19,7 @@ # 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/proposal_settings" require "agama/storage/device_settings" require "agama/storage/space_settings" require "agama/storage/volume_templates_builder" diff --git a/service/lib/agama/storage/proposal_strategies.rb b/service/lib/agama/storage/proposal_strategies.rb index c8efb53c08..8bfe8ade59 100644 --- a/service/lib/agama/storage/proposal_strategies.rb +++ b/service/lib/agama/storage/proposal_strategies.rb @@ -27,5 +27,6 @@ module ProposalStrategies end end -require "agama/storage/proposal_strategies/guided" +require "agama/storage/proposal_strategies/agama" require "agama/storage/proposal_strategies/autoyast" +require "agama/storage/proposal_strategies/guided" diff --git a/service/lib/agama/storage/proposal_strategies/agama.rb b/service/lib/agama/storage/proposal_strategies/agama.rb new file mode 100644 index 0000000000..c34e134f7c --- /dev/null +++ b/service/lib/agama/storage/proposal_strategies/agama.rb @@ -0,0 +1,78 @@ +# 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/proposal_strategies/base" +require "y2storage/agama_proposal" + +module Agama + module Storage + module ProposalStrategies + # Strategy for Agama proposal. + class Agama < Base + include Yast::I18n + + # @return [Agama::Storage::Config] + attr_reader :storage_config + alias_method :settings, :storage_config + + # @param config [Agama::Config] + # @param logger [Logger] + # @param storage_config [Agama::Storage::Config] + def initialize(config, logger, storage_config) + textdomain "agama" + + super(config, logger) + @storage_config = storage_config + end + + # @see Base#calculate + def calculate + @proposal = agama_proposal + @proposal.propose + ensure + storage_manager.proposal = @proposal + end + + # @see Base#issues + def issues + return [] unless proposal + + proposal.issues_list + end + + private + + # @return [Y2Storage::AgamaProposal, nil] Proposal used. + attr_reader :proposal + + # Instance of the Y2Storage proposal to be used to run the calculation. + # + # @return [Y2Storage::AgamaProposal] + def agama_proposal + Y2Storage::AgamaProposal.new(storage_config, + issues_list: [], + devicegraph: probed_devicegraph, + disk_analyzer: disk_analyzer) + end + end + end + end +end diff --git a/service/lib/agama/storage/proposal_strategies/base.rb b/service/lib/agama/storage/proposal_strategies/base.rb index 51f15ba528..5663a8cf4b 100644 --- a/service/lib/agama/storage/proposal_strategies/base.rb +++ b/service/lib/agama/storage/proposal_strategies/base.rb @@ -48,13 +48,6 @@ def calculate raise NotImplementedError end - # Identifier for the strategy. - # - # @return [Symbol] - def id - self.class.name.split("::").last.downcase.to_sym - end - # List of issues. # # @return [Array] diff --git a/service/lib/agama/storage/volume.rb b/service/lib/agama/storage/volume.rb index 023c4df78a..c1ce6e3614 100644 --- a/service/lib/agama/storage/volume.rb +++ b/service/lib/agama/storage/volume.rb @@ -22,7 +22,7 @@ require "forwardable" require "json" require "y2storage/disk_size" -require "agama/storage/btrfs_settings" +require "agama/storage/configs/btrfs" require "agama/storage/volume_conversions" require "agama/storage/volume_location" require "agama/storage/volume_outline" @@ -52,7 +52,7 @@ class Volume # # Only relevant if #fs_type is Btrfs # - # @return [BtrfsSettings] + # @return [Configs::Btrfs] attr_accessor :btrfs # @return [Array] @@ -91,7 +91,7 @@ def initialize(mount_path) @auto_size = false @min_size = Y2Storage::DiskSize.zero @max_size = Y2Storage::DiskSize.unlimited - @btrfs = BtrfsSettings.new + @btrfs = Configs::Btrfs.new @outline = VolumeOutline.new @location = VolumeLocation.new end diff --git a/service/lib/agama/storage/volume_templates_builder.rb b/service/lib/agama/storage/volume_templates_builder.rb index d955c691c7..062d94dcfe 100644 --- a/service/lib/agama/storage/volume_templates_builder.rb +++ b/service/lib/agama/storage/volume_templates_builder.rb @@ -21,9 +21,9 @@ require "pathname" require "y2storage" +require "agama/storage/configs/btrfs" require "agama/storage/volume" require "agama/storage/volume_outline" -require "agama/storage/btrfs_settings" module Agama module Storage @@ -102,7 +102,7 @@ def path_key(path) # Temporary method to avoid crashes if there is no default template def empty_data { - btrfs: BtrfsSettings.new, + btrfs: Configs::Btrfs.new, outline: VolumeOutline.new, mount_options: [], filesystem: Y2Storage::Filesystems::Type::EXT4 @@ -130,7 +130,7 @@ def values(data) def btrfs(data) btrfs_data = fetch(data, "btrfs", {}) - BtrfsSettings.new.tap do |btrfs| + Configs::Btrfs.new.tap do |btrfs| btrfs.snapshots = fetch(btrfs_data, "snapshots", false) btrfs.read_only = fetch(btrfs_data, "read_only", false) btrfs.default_subvolume = fetch(btrfs_data, "default_subvolume", "") diff --git a/service/lib/y2storage/agama_proposal.rb b/service/lib/y2storage/agama_proposal.rb new file mode 100644 index 0000000000..15ff647fd7 --- /dev/null +++ b/service/lib/y2storage/agama_proposal.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 "yast" +require "y2storage/proposal" +require "y2storage/proposal/agama_searcher" +require "y2storage/proposal/agama_space_maker" +require "y2storage/proposal/agama_devices_planner" +require "y2storage/proposal/agama_devices_creator" +require "y2storage/proposal/planned_devices_handler" +require "y2storage/exceptions" +require "y2storage/planned" + +module Y2Storage + # Class to calculate a storage proposal for auto-installation using Agama. + # + # @note The storage config (initial_settings param in constructor) is modified in several ways: + # * The search configs are resolved. + # * Every config with an unfound search (e.g., a drive config, a partition config) is removed if + # its search has #if_not_found set to skip. + # + # It would be preferable to work over a copy instead of modifying the given config. In some + # cases, the config object is needed to generate its JSON format. The JSON result would not + # be 100% accurate if some elements are removed. + # + # The original config without removing elements is needed if: + # * The current proposal is the initial proposal automatically calculated by Agama. In + # this case, the config is generated from the product definition. The config JSON format is + # obtained by converting the config object to JSON. + # * The current proposal was calculated from a settings following the guided schema. This + # usually happens when a proposal is calculated from the UI. In this case, a config is + # generated from the guided settings. The config JSON format is obtained by converting the + # config object to JSON. + # + # In those two cases (initial proposal and proposal from guided settings) no elements are + # removed from the config because it has no searches with skip: + # * The config from the product definition has a drive that fails with unfound search (i.e., + # there is no candidate device for installing the system). + # * The config from the guided settings has all drives and partitions with search set to + # error. The proposal fails if the selected devices are not found. + # + # In the future there could be any other scenario in which it would be needed to keep all the + # elements from an initial config containing searches with skip. + # + # @example Creating a proposal from the current Agama configuration + # config = Agama::Storage::Config.new_from_json(config_json) + # proposal = Y2Storage::AgamaProposal.new(config) + # proposal.proposed? # => false + # proposal.devices # => nil + # proposal.planned_devices # => nil + # + # proposal.propose # Performs the calculation + # + # proposal.proposed? # => true + # proposal.devices # => Proposed layout + # + class AgamaProposal < Proposal::Base + include Proposal::PlannedDevicesHandler + + # @return [Agama::Storage::Config] + attr_reader :settings + + # @return [Array] List of found issues + attr_reader :issues_list + + # Constructor + # + # @param initial_settings [Agama::Storage::Config] Agama storage settings + # @param devicegraph [Devicegraph] starting point. If nil, then probed devicegraph + # will be used + # @param disk_analyzer [DiskAnalyzer] by default, the method will create a new one + # based on the initial devicegraph or will use the one from the StorageManager if + # starting from probed (i.e. 'devicegraph' argument is also missing) + # @param issues_list [Array e + raise NotBootableError, e.message + end + + # Removes partition tables from candidate devices with empty partition table + # + # @param devicegraph [Devicegraph] the graph gets modified + # @return [Array] sid of devices where partition table was deleted from + def remove_empty_partition_tables(devicegraph) + devices = drives_with_empty_partition_table(devicegraph) + devices.each(&:delete_partition_table) + devices.map(&:sid) + end + + # All candidate devices with an empty partition table + # + # @param devicegraph [Y2Storage::Devicegraph] + # @return [Array] + def drives_with_empty_partition_table(devicegraph) + devices = settings.drives.map { |d| device_for(d, devicegraph) }.compact + devices.select { |d| d.partition_table && d.partitions.empty? } + end + + # Planned partitions that will hold the given planned devices + # + # @return [Array] + def partitions_for_clean + # The current logic is quite trivial, but this is implemented as a separate method because + # some extra logic is expected in the future (eg. considering partitions on pre-existing + # RAIDs and more stuff). See the equivalent method at DevicegraphGenerator. + planned_devices.partitions + end + + # Configures SpaceMaker#protected_sids according to the given list of planned devices + def protect_sids + space_maker.protected_sids = planned_devices.all.select(&:reuse?).map(&:reuse_sid) + end + + # Creates the planned devices on a given devicegraph + # + # @param devicegraph [Devicegraph] the graph gets modified + def create_devices(devicegraph) + devices_creator = Proposal::AgamaDevicesCreator.new(devicegraph, issues_list) + names = settings.drives.map(&:found_device).compact.map(&:name) + protect_sids + result = devices_creator.populated_devicegraph(planned_devices, names, space_maker) + end + + # Equivalent device at the given devicegraph for the given configuration setting (eg. drive) + # + # @param drive [Agama::Storage::Configs::Drive] + # @param devicegraph [Devicegraph] + # @return [Device] + def device_for(drive, devicegraph) + return unless drive.found_device + + devicegraph.find_device(drive.found_device.sid) + end + end +end diff --git a/service/lib/y2storage/proposal/agama_device_planner.rb b/service/lib/y2storage/proposal/agama_device_planner.rb new file mode 100644 index 0000000000..70818198bd --- /dev/null +++ b/service/lib/y2storage/proposal/agama_device_planner.rb @@ -0,0 +1,214 @@ +# 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 "y2storage/planned" +require "agama/issue" + +module Y2Storage + module Proposal + # Base class used by Agama planners. + class AgamaDevicePlanner + include Yast::I18n + + # @!attribute [r] devicegraph + # Devicegraph to be used as starting point. + # @return [Devicegraph] + attr_reader :devicegraph + + # @!attribute [r] issues_list + # List of issues to register any found problem + # @return [Array] + attr_reader :issues_list + + # Constructor + # + # @param devicegraph [Devicegraph] see {#devicegraph} + # @param issues_list [Array] see {#issues_list} + def initialize(devicegraph, issues_list) + textdomain "agama" + + @devicegraph = devicegraph + @issues_list = issues_list + end + + # Planned devices according to the given settings. + # + # @return [Array] Array of planned devices. + def planned_devices(_setting) + raise NotImplementedError + end + + private + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [#found_device] + def configure_reuse(planned, settings) + device = settings.found_device + return unless device + + planned.assign_reuse(device) + # TODO: Allow mounting without reformatting. + planned.reformat = true + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [#encryption, #filesystem] + def configure_device(planned, settings) + configure_encryption(planned, settings.encryption) if settings.encryption + configure_filesystem(planned, settings.filesystem) if settings.filesystem + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::Filesystem] + def configure_filesystem(planned, settings) + planned.mount_point = settings.path + planned.mount_by = settings.mount_by + planned.fstab_options = settings.mount_options + planned.mkfs_options = settings.mkfs_options.join(",") + planned.label = settings.label + configure_filesystem_type(planned, settings.type) if settings.type + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::FilesystemType] + def configure_filesystem_type(planned, settings) + planned.filesystem_type = settings.fs_type + configure_btrfs(planned, settings.btrfs) if settings.btrfs + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::Btrfs] + def configure_btrfs(planned, settings) + # TODO: we need to discuss what to do with transactional systems and the read_only + # property. We are not sure whether those things should be configurable by the user. + # planned.read_only = settings.read_only? + planned.snapshots = settings.snapshots? + planned.default_subvolume = settings.default_subvolume + planned.subvolumes = settings.subvolumes + end + + # @param planned [Planned::Disk, Planned::Partition] + # @param settings [Agama::Storage::Configs::Encryption] + def configure_encryption(planned, settings) + planned.encryption_password = settings.password + planned.encryption_method = settings.method + planned.encryption_pbkdf = settings.pbkd_function + planned.encryption_label = settings.label + planned.encryption_cipher = settings.cipher + planned.encryption_key_size = settings.key_size + + check_encryption(planned) + end + + # @see #configure_encryption + def check_encryption(dev) + issues_list << issue_missing_enc_password(dev) if missing_enc_password?(dev) + issues_list << issue_available_enc_method(dev) unless dev.encryption_method.available? + issues_list << issue_wrong_enc_method(dev) unless supported_enc_method?(dev) + end + + # @see #check_encryption + def missing_enc_password?(planned) + return false unless planned.encryption_method&.password_required? + + planned.encryption_password.nil? || planned.encryption_password.empty? + end + + # @see #check_encryption + def supported_enc_method?(planned) + planned.supported_encryption_method?(planned.encryption_method) + end + + # @see #check_encryption + def issue_missing_enc_password(planned) + msg = format( + # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like + # 'luks1' or 'random_swap'). + _("No passphrase provided (required for using the method '%{crypt_method}')."), + crypt_method: planned.encryption_method.id.to_s + ) + encryption_issue(msg) + end + + # @see #check_encryption + def issue_available_enc_method(planned) + msg = format( + # TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like + # 'luks1' or 'random_swap'). + _("Encryption method '%{crypt_method}' is not available in this system."), + crypt_method: planned.encryption_method.id.to_s + ) + encryption_issue(msg) + end + + # @see #check_encryption + def issue_wrong_enc_method(planned) + msg = format( + # TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like + # 'luks1' or 'random_swap'). + _("'%{crypt_method}' is not a suitable method to encrypt the device."), + crypt_method: planned.encryption_method.id.to_s + ) + encryption_issue(msg) + end + + # @see #check_encryption + def encryption_issue(message) + Agama::Issue.new( + message, + source: Agama::Issue::Source::CONFIG, + severity: Agama::Issue::Severity::ERROR + ) + end + + # @param planned [Planned::Partition] + # @param settings [Agama::Storage::Configs::Size] + def configure_size(planned, settings) + planned.min_size = settings.min + planned.max_size = settings.max + planned.weight = 100 + end + + # @param planned [Planned::Disk] + # @param config [Agama::Storage::Configs::Drive] + def configure_partitions(planned, config) + partition_configs = config.partitions + .reject(&:delete?) + .reject(&:delete_if_needed?) + + planned.partitions = partition_configs.map do |partition_config| + planned_partition(partition_config).tap { |p| p.disk = config.found_device.name } + end + end + + # @param config [Agama::Storage::Configs::Partition] + # @return [Planned::Partition] + def planned_partition(config) + Planned::Partition.new(nil, nil).tap do |planned| + planned.partition_id = config.id + configure_reuse(planned, config) + configure_device(planned, config) + configure_size(planned, config.size) + end + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_devices_creator.rb b/service/lib/y2storage/proposal/agama_devices_creator.rb new file mode 100644 index 0000000000..1c273535a1 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_devices_creator.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +# Copyright (c) [2017-2020] 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 "y2storage/proposal/agama_lvm_helper" +require "y2storage/exceptions" + +module Y2Storage + module Proposal + # Class to create and reuse devices during the Agama proposal + class AgamaDevicesCreator + include Yast::Logger + + # @return [Array] List of found issues + attr_reader :issues_list + + # Constructor + # + # @param original_graph [Devicegraph] Devicegraph to be used as starting point + # @param issues_list [Array] List of issues to register the problems + # found during devices creation + def initialize(original_graph, issues_list) + @original_graph = original_graph + @issues_list = issues_list + end + + # Devicegraph including all the specified planned devices + # + # @param planned_devices [Planned::DevicesCollection] Devices to create/reuse + # @param disk_names [Array] Disks to consider + # @param space_maker [SpaceMaker] + # + # @return [CreatorResult] Result with new devicegraph in which all the + # planned devices have been allocated + def populated_devicegraph(planned_devices, disk_names, space_maker) + # Process planned partitions + log.info "planned devices = #{planned_devices.to_a.inspect}" + log.info "disk names = #{disk_names.inspect}" + + reset + + @planned_devices = planned_devices + @disk_names = disk_names + @space_maker = space_maker + + process_devices + end + + protected + + # @return [Devicegraph] Original devicegraph + attr_reader :original_graph + + # @return [Planned::DevicesCollection] Devices to create/reuse + attr_reader :planned_devices + + # @return [Array] Disks to consider + attr_reader :disk_names + + # @return [SpaceMaker] space maker to use during operation + attr_reader :space_maker + + # @return [Proposal::CreatorResult] Current result containing the devices that have been + # created + attr_reader :creator_result + + # @return [Devicegraph] Current devicegraph + attr_reader :devicegraph + + private + + # Sets the current creator result + # + # The current devicegraph is properly updated. + # + # @param result [Proposal::CreatorResult] + def creator_result=(result) + @creator_result = result + @devicegraph = result.devicegraph + end + + # Resets values before create devices + # + # @see #populated_devicegraph + def reset + @creator_result = nil + @devicegraph = original_graph.duplicate + end + + # Reuses and creates planned devices + # + # @return [CreatorResult] Result with new devicegraph in which all the + # planned devices have been allocated + def process_devices + process_existing_partitionables + creator_result + end + + # @see #process_devices + def process_existing_partitionables + partitions = partitions_for_existing(planned_devices) + + # lvm_lvs = system_lvm_over_existing? ? system_lvs(planned_devices) : [] + lvm_lvs = [] + lvm_helper = AgamaLvmHelper.new(lvm_lvs) + + # Check whether there is any chance of getting an unwanted order for the planned partitions + # within a disk + space_result = provide_space(partitions, original_graph, lvm_helper) + + partition_creator = PartitionCreator.new(space_result[:devicegraph]) + self.creator_result = + partition_creator.create_partitions(space_result[:partitions_distribution]) + + # This may be here or before create_partitions. + # + # What about resizing if needed? + # Likely shrinking is fine and should be always handled at the SpaceMaker. + # But I'm not so sure if growing is so fine (we may need to make some space first). + # I don't think we have the growing case covered by SpaceMaker, the distribution + # calculator, etc. + # + planned_devices.each do |planned| + next unless planned.reuse? + + planned.reuse!(devicegraph) + end + + # This may be unexpected if the storage configuration provided by the user includes + # carefully crafted mount options but may be needed in weird situations for more automated + # proposals. Let's re-evaluate over time. + devicegraph.mount_points.each(&:adjust_mount_options) + end + + # @see #process_existing_partitionables + def provide_space(planned_partitions, devicegraph, lvm_helper) + result = space_maker.provide_space(devicegraph, planned_partitions, lvm_helper) + log.info "Found enough space" + result + end + + # @see #process_existing_partitionables + def partitions_for_existing(planned_devices) + # Maybe in the future this can include partitions on top of existing MDs + # NOTE: simplistic implementation + planned_devices.partitions.reject(&:reuse?) + end + + # Formats and/or mounts the disk-like block devices + # + # XEN partitions (StrayBlkDevice) are intentionally left out for now + # + # Add planned disks to reuse list so they can be considered for lvm and raids later on. + def process_disk_like_devs + # Do we do something about SpaceMaker here? I assume it was already done as mandatory + planned_devs = planned_devices.select { |d| d.is_a?(Planned::Disk) } + planned_devs.each { |d| d.reuse!(devicegraph) } + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_devices_planner.rb b/service/lib/y2storage/proposal/agama_devices_planner.rb new file mode 100644 index 0000000000..4476f66cc1 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_devices_planner.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. + +require "y2storage/proposal/agama_drive_planner" +require "y2storage/planned" + +module Y2Storage + module Proposal + # Devices planner for Agama. + class AgamaDevicesPlanner + include Yast::Logger + + # Settings used to calculate the planned devices. + # + # @return [Agama::Storage::Config] + attr_reader :settings + + # @param settings [Agama::Storage::Config] + # @param issues_list [Array] + def initialize(settings, issues_list) + @settings = settings + @issues_list = issues_list + end + + # List of devices that need to be created to satisfy the settings. Does not include + # devices needed for booting. + # + # For the time being, this implements only stuff coming from partitition elements within + # drive elements. + # + # @param devicegraph [Devicegraph] + # @return [Planned::DevicesCollection] + def initial_planned_devices(devicegraph) + # In the future this will also include planned devices that are equivalent to + # those typically generated by the Guided Proposal. For those, note that: + # - For dedicated VGs it creates a Planned VG containing a Planned LV, but no PVs + # - For LVM volumes it create a Planned LV but associated to no planned VG + # - For partition volumes, it creates a planned partition, of course + + devs = settings.drives.flat_map { |d| planned_for_drive(d, devicegraph) }.compact + Planned::DevicesCollection.new(devs) + end + + protected + + # @return [Array] List to register any found issue + attr_reader :issues_list + + # @see #initial_planned_devices + def planned_for_drive(drive, devicegraph) + planner = AgamaDrivePlanner.new(devicegraph, issues_list) + planner.planned_devices(drive) + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_drive_planner.rb b/service/lib/y2storage/proposal/agama_drive_planner.rb new file mode 100644 index 0000000000..2503cb8a92 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_drive_planner.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 "y2storage/proposal/agama_device_planner" + +module Y2Storage + module Proposal + # Drive planner for Agama. + class AgamaDrivePlanner < AgamaDevicePlanner + # @param settings [Agama::Storage::Configs::Drive] + # @return [Array] + def planned_devices(settings) + [planned_drive(settings)] + end + + private + + # Support for StrayBlkDevice is intentionally left out. As far as we know, the plan + # for SLE/Leap 16 is to drop XEN support + # + # @param settings [Agama::Storage::Configs::Drive] + # @return [Planned::Disk] + def planned_drive(settings) + return planned_full_drive(settings) unless settings.partitions? + + planned_partitioned_drive(settings) + end + + # @param settings [Agama::Storage::Configs::Drive] + # @return [Planned::Disk] + def planned_full_drive(settings) + Planned::Disk.new.tap do |planned| + configure_reuse(planned, settings) + configure_device(planned, settings) + end + end + + # @param settings [Agama::Storage::Configs::Drive] + # @return [Planned::Disk] + def planned_partitioned_drive(settings) + Planned::Disk.new.tap do |planned| + configure_reuse(planned, settings) + configure_partitions(planned, settings) + end + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_lvm_helper.rb b/service/lib/y2storage/proposal/agama_lvm_helper.rb new file mode 100644 index 0000000000..80784a13b4 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_lvm_helper.rb @@ -0,0 +1,52 @@ +# 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 "y2storage/proposal/lvm_helper" +require "y2storage/proposal_settings" + +module Y2Storage + module Proposal + # LVM helper for Agama. + class AgamaLvmHelper < LvmHelper + # Constructor + def initialize(lvm_lvs) + super(lvm_lvs, guided_settings) + end + + private + + # Method used by the constructor to somehow simulate a typical Guided Proposal + def guided_settings + # Despite the "current_product" part in the name of the constructor, it only applies + # generic default values that are independent of the product (there is no YaST + # ProductFeatures mechanism in place). + Y2Storage::ProposalSettings.new_for_current_product.tap do |target| + target.lvm_vg_strategy = :use_needed + target.lvm_vg_reuse = false + # TODO: Add encryption options. + target.encryption_password = nil + # target.encryption_pbkdf + # target.encryption_method + end + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_searcher.rb b/service/lib/y2storage/proposal/agama_searcher.rb new file mode 100644 index 0000000000..06d968cc9f --- /dev/null +++ b/service/lib/y2storage/proposal/agama_searcher.rb @@ -0,0 +1,177 @@ +# 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/issue" + +module Y2Storage + module Proposal + # Auxiliary class to handle the 'search' elements within a storage configuration + class AgamaSearcher + include Yast::Logger + include Yast::I18n + + # @param devicegraph [Devicegraph] used to find the corresponding devices that will get + # associated to each search element. + def initialize(devicegraph) + textdomain "agama" + + @devicegraph = devicegraph + end + + # Resolve all the 'search' elements within a given configuration + # + # The first argument (the storage configuration) gets modified in several ways: + # + # - All its 'search' elements get resolved, associating devices from the devicegraph + # (first argument) if some is found. + # - Some device definitions can get removed if configured to be skipped in absence of a + # corresponding device + # + # The second argument (the list of issues) gets modified by adding any found problem. + # + # @param config [Agama::Storage::Config] storage configuration containing device definitions + # like drives, volume groups, etc. + # @param issues_list [Array] + def search(config, issues_list) + @sids = [] + config.drives.each do |drive_config| + device = find_drive(drive_config.search) + drive_config.search.resolve(device) + + process_element(drive_config, config.drives, issues_list) + + next unless drive_config.found_device && drive_config.partitions? + + drive_config.partitions.each do |partition_config| + next unless partition_config.search + + partition = find_partition(partition_config.search, drive_config.found_device) + partition_config.search.resolve(partition) + process_element(partition_config, drive_config.partitions, issues_list) + end + end + end + + private + + # @return [Devicegraph] + attr_reader :devicegraph + + # @return [Array] SIDs of the devices that are already associated to another search. + attr_reader :sids + + # Finds a drive matching the given search config. + # + # @param search_config [Agama::Storage::Configs::Search] + # @return [Y2Storage::Device, nil] + def find_drive(search_config) + candidates = candidate_devices(search_config, default: devicegraph.blk_devices) + candidates.select! { |d| d.is?(:disk_device, :stray_blk_device) } + next_unassigned_device(candidates) + end + + # Finds a partitions matching the given search config. + # + # @param search_config [Agama::Storage::Configs::Search] + # @return [Y2Storage::Device, nil] + def find_partition(search_config, device) + candidates = candidate_devices(search_config, default: device.partitions) + candidates.select! { |d| d.is?(:partition) } + next_unassigned_device(candidates) + end + + # Candidate devices for the given search config. + # + # @param search_config [Agama::Storage::Configs::Search] + # @param default [Array] Candidates if the search does not indicate + # conditions. + # @return [Array] + def candidate_devices(search_config, default: []) + return default if search_config.any_device? + + [find_device(search_config)].compact + end + + # Performs a search in the devicegraph to find a device matching the given search config. + # + # @param search_config [Agama::Storage::Configs::Search] + # @return [Y2Storage::Device] + def find_device(search_config) + devicegraph.find_by_any_name(search_config.name) + end + + # Next unassigned device from the given list. + # + # @param devices [Array] + # @return [Y2Storage::Device, nil] + def next_unassigned_device(devices) + devices + .reject { |d| sids.include?(d.sid) } + .min_by(&:name) + end + + # @see #search + def process_element(element, collection, issues_list) + found = element.found_device + if found + @sids << found.sid + else + issues_list << not_found_issue(element) + collection.delete(element) if element.search.skip_device? + end + end + + # Issue generated if a corresponding device is not found for the given element + # + # @param element [Agama::Storage::Configs::Drive, Agama::Storage::Configs::Partition] + # @return [Agama::Issue] + def not_found_issue(element) + Agama::Issue.new( + issue_message(element), + source: Agama::Issue::Source::CONFIG, + severity: issue_severity(element.search) + ) + end + + # @see #not_found_issue + def issue_message(element) + if element.is_a?(Agama::Storage::Configs::Drive) + if element.search.skip_device? + _("No device found for an optional drive") + else + _("No device found for a mandatory drive") + end + elsif element.search.skip_device? + _("No device found for an optional partition") + else + _("No device found for a mandatory partition") + end + end + + # @see #not_found_issue + def issue_severity(search) + return Agama::Issue::Severity::WARN if search.skip_device? + + Agama::Issue::Severity::ERROR + end + end + end +end diff --git a/service/lib/y2storage/proposal/agama_space_maker.rb b/service/lib/y2storage/proposal/agama_space_maker.rb new file mode 100644 index 0000000000..54d44ccfe2 --- /dev/null +++ b/service/lib/y2storage/proposal/agama_space_maker.rb @@ -0,0 +1,110 @@ +# 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 "y2storage/proposal/space_maker" +require "y2storage/proposal_settings" + +module Y2Storage + module Proposal + # Space maker for Agama. + class AgamaSpaceMaker < SpaceMaker + # @param disk_analyzer [DiskAnalyzer] + # @param config [Agama::Storage::Config] + def initialize(disk_analyzer, config) + super(disk_analyzer, guided_settings(config)) + end + + private + + # Method used by the constructor to somehow simulate a typical Guided Proposal + # + # @param config [Agama::Storage::Config] + def guided_settings(config) + # Despite the "current_product" part in the name of the constructor, it only applies + # generic default values that are independent of the product (there is no YaST + # ProductFeatures mechanism in place). + Y2Storage::ProposalSettings.new_for_current_product.tap do |target| + target.space_settings.strategy = :bigger_resize + target.space_settings.actions = space_actions(config) + + boot_device = config.boot_device + + target.root_device = boot_device + target.candidate_devices = [boot_device].compact + end + end + + # Space actions from the given config. + # + # @param config [Agama::Storage::Config] + # @return [Hash] + def space_actions(config) + force_delete_actions = force_delete_actions(config) + delete_actions = delete_actions(config) + + force_delete_actions.merge(delete_actions) + end + + # Space actions for devices that must be deleted. + # + # @param config [Agama::Storage::Config] + # @return [Hash] + def force_delete_actions(config) + partition_configs = partitions(config).select(&:delete?) + partition_names = device_names(partition_configs) + + partition_names.each_with_object({}) { |p, a| a[p] = :force_delete } + end + + # Space actions for devices that might be deleted. + # + # @note #delete? takes precedence over #delete_if_needed?. + # + # @param config [Agama::Storage::Config] + # @return [Hash] + def delete_actions(config) + partition_configs = partitions(config).select(&:delete_if_needed?).reject(&:delete?) + partition_names = device_names(partition_configs) + + partition_names.each_with_object({}) { |p, a| a[p] = :delete } + end + + # All partition configs from the given config. + # + # @param config [Agama::Storage::Config] + # @return [Array] + def partitions(config) + config.drives.flat_map(&:partitions) + end + + # Device names from the given configs. + # + # @param configs [Array<#found_device>] + # @return [Array] + def device_names(configs) + configs + .map(&:found_device) + .compact + .map(&:name) + end + end + end +end diff --git a/service/package/gem2rpm.yml b/service/package/gem2rpm.yml index aab4071193..6195f67f85 100644 --- a/service/package/gem2rpm.yml +++ b/service/package/gem2rpm.yml @@ -26,6 +26,7 @@ Provides: agama-yast BuildRequires: dbus-1-common Requires: dbus-1-common + Requires: dbus-1-daemon Requires: suseconnect-ruby-bindings # YaST dependencies Requires: autoyast2-installation @@ -38,7 +39,7 @@ Requires: yast2-iscsi-client >= 4.5.7 Requires: yast2-network Requires: yast2-proxy - Requires: yast2-storage-ng >= 5.0.13 + Requires: yast2-storage-ng >= 5.0.17 Requires: yast2-users %ifarch s390 s390x Requires: yast2-s390 >= 4.6.4 @@ -53,7 +54,6 @@ Requires: dosfstools Requires: e2fsprogs Requires: exfatprogs - Requires: f2fs-tools Requires: fcoe-utils %ifarch x86_64 aarch64 Requires: fde-tools @@ -63,7 +63,6 @@ Requires: lvm2 Requires: mdadm Requires: multipath-tools - Requires: nilfs-utils Requires: nfs-client Requires: ntfs-3g Requires: ntfsprogs diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index b004ea94e5..8e8dae2b0c 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,32 @@ +------------------------------------------------------------------- +Wed Sep 4 08:55:29 UTC 2024 - José Iván López González + +- Storage: add support for deleting partitions in the storage + config (gh#openSUSE/agama#1572). + +------------------------------------------------------------------- +Tue Sep 3 08:14:23 UTC 2024 - José Iván López González + +- Storage: add support for searching by device in the storage + config (gh#openSUSE/agama#1560). + +------------------------------------------------------------------- +Tue Aug 27 15:16:17 UTC 2024 - José Iván López González + +- Storage: allow calling to #SetConfig D-Bus method using the new + storage JSON config (gh#openSUSE/agama#1471). + +------------------------------------------------------------------- +Tue Aug 27 11:38:01 UTC 2024 - Imobach Gonzalez Sosa + +- Add a dependency on the D-Bus daemon (bsc#1229807). + +------------------------------------------------------------------- +Mon Aug 26 10:01:27 UTC 2024 - Imobach Gonzalez Sosa + +- Do not depend on f2fs-tools and nilfs-utils + (jsc#PED-8669, gh#openSUSE/agama#1554). + ------------------------------------------------------------------- Wed Aug 21 19:07:28 UTC 2024 - Lubos Kocman diff --git a/service/po/ca.po b/service/po/ca.po index 652d490007..4e75e839c0 100644 --- a/service/po/ca.po +++ b/service/po/ca.po @@ -7,11 +7,11 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" "PO-Revision-Date: 2024-07-12 07:47+0000\n" "Last-Translator: David Medina \n" -"Language-Team: Catalan \n" +"Language-Team: Catalan \n" "Language: ca\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -191,7 +191,7 @@ msgstr "Escrivint la configuració de sistema del carregador d'arrencada" #. Issue representing the proposal is not valid. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:192 +#: service/lib/agama/storage/proposal.rb:283 msgid "Cannot accommodate the required file systems for installation" msgstr "" "No es poden acomodar els sistemes de fitxers necessaris per a la " @@ -200,7 +200,7 @@ msgstr "" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:203 +#: service/lib/agama/storage/proposal.rb:294 msgid "A problem ocurred while calculating the storage setup" msgstr "" "Hi ha hagut un problema en calcular la configuració de l'emmagatzematge." @@ -233,6 +233,50 @@ msgstr "" "Cal definir un usuari, establir la contrasenya d'arrel o una clau pública " "SSH." +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:134 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:145 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:156 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "" + +#. @see #not_found_issue +#: service/lib/y2storage/proposal/agama_searcher.rb:98 +msgid "No device found for an optional drive" +msgstr "" + +#: service/lib/y2storage/proposal/agama_searcher.rb:100 +msgid "No device found for a mandatory drive" +msgstr "" + +#: service/lib/y2storage/proposal/agama_searcher.rb:103 +#, fuzzy +msgid "No device found for an optional partition" +msgstr "No s'ha seleccionat cap dispositiu per a la instal·lació." + +#: service/lib/y2storage/proposal/agama_searcher.rb:105 +#, fuzzy +msgid "No device found for a mandatory partition" +msgstr "No s'ha seleccionat cap dispositiu per a la instal·lació." + #~ msgid "Probing Storage" #~ msgstr "Sondant l'emmagatzematge" diff --git a/service/po/cs.po b/service/po/cs.po index d0ebd81fa9..eef2788af0 100644 --- a/service/po/cs.po +++ b/service/po/cs.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" -"PO-Revision-Date: 2024-07-22 20:47+0000\n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" +"PO-Revision-Date: 2024-08-30 20:47+0000\n" "Last-Translator: Aleš Kastner \n" "Language-Team: Czech \n" @@ -190,14 +190,14 @@ msgstr "Zapisuji konfiguraci boot zavaděče v sysconfig" #. Issue representing the proposal is not valid. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:192 +#: service/lib/agama/storage/proposal.rb:283 msgid "Cannot accommodate the required file systems for installation" msgstr "Nelze umístit požadované souborové systémy pro instalaci" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:203 +#: service/lib/agama/storage/proposal.rb:294 msgid "A problem ocurred while calculating the storage setup" msgstr "Nastal problém při výpočtu nastavení paměti" @@ -227,6 +227,50 @@ msgid "" msgstr "" "Definuji uživatele, nastavuji heslo roota nebo potřebuji veřejný klíč SSH" +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:134 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "" +"Není uvedena žádná přístupová fráze (nutná pro použití metody " +"'%{crypt_method}')." + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:145 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "Šifrovací metoda '%{crypt_method}' není v tomto systému k dispozici." + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:156 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "'%{crypt_method}' není vhodná metoda pro šifrování zařízení." + +#. @see #not_found_issue +#: service/lib/y2storage/proposal/agama_searcher.rb:98 +msgid "No device found for an optional drive" +msgstr "Nenalezeno žádné zařízení pro volitelnou jednotku" + +#: service/lib/y2storage/proposal/agama_searcher.rb:100 +msgid "No device found for a mandatory drive" +msgstr "Nenalezeno žádné zařízení pro povinnou jednotku" + +#: service/lib/y2storage/proposal/agama_searcher.rb:103 +msgid "No device found for an optional partition" +msgstr "Pro volitelný oddíl nenalezeno zařízení" + +#: service/lib/y2storage/proposal/agama_searcher.rb:105 +msgid "No device found for a mandatory partition" +msgstr "Pro povinný oddíl nenalezeno zařízení" + #~ msgid "Probing Storage" #~ msgstr "Sondážní úložiště" diff --git a/service/po/de.po b/service/po/de.po index 16aebece94..59bd07e09f 100644 --- a/service/po/de.po +++ b/service/po/de.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" -"PO-Revision-Date: 2024-07-13 19:47+0000\n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" +"PO-Revision-Date: 2024-08-29 19:47+0000\n" "Last-Translator: Ettore Atalan \n" "Language-Team: German \n" @@ -191,7 +191,7 @@ msgstr "Bootloader-Systemkonfiguration wird geschrieben" #. Issue representing the proposal is not valid. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:192 +#: service/lib/agama/storage/proposal.rb:283 msgid "Cannot accommodate the required file systems for installation" msgstr "" "Die für die Installation erforderlichen Dateisysteme können nicht " @@ -200,7 +200,7 @@ msgstr "" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:203 +#: service/lib/agama/storage/proposal.rb:294 msgid "A problem ocurred while calculating the storage setup" msgstr "Bei der Berechnung der Speichereinrichtung ist ein Problem aufgetreten" @@ -232,6 +232,53 @@ msgstr "" "Die Definition eines Benutzers, das Festlegen des Root-Passworts oder eines " "öffentlichen SSH-Schlüssels ist erforderlich" +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:134 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "" +"Keine Passphrase angegeben (erforderlich für die Verwendung der Methode " +"'%{crypt_method}')." + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:145 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "" +"Die Verschlüsselungsmethode '%{crypt_method}' ist auf diesem System nicht " +"verfügbar." + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:156 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "" +"'%{crypt_method}' ist keine geeignete Methode zur Verschlüsselung des Geräts." + +#. @see #not_found_issue +#: service/lib/y2storage/proposal/agama_searcher.rb:98 +msgid "No device found for an optional drive" +msgstr "Kein Gerät für ein optionales Laufwerk gefunden" + +#: service/lib/y2storage/proposal/agama_searcher.rb:100 +msgid "No device found for a mandatory drive" +msgstr "Kein Gerät für ein obligatorisches Laufwerk gefunden" + +#: service/lib/y2storage/proposal/agama_searcher.rb:103 +msgid "No device found for an optional partition" +msgstr "Kein Gerät für eine optionale Partition gefunden" + +#: service/lib/y2storage/proposal/agama_searcher.rb:105 +msgid "No device found for a mandatory partition" +msgstr "Kein Gerät für eine obligatorische Partition gefunden" + #~ msgid "Probing Storage" #~ msgstr "Speicher wird untersucht" diff --git a/service/po/es.po b/service/po/es.po index 10e0641230..5eb4460639 100644 --- a/service/po/es.po +++ b/service/po/es.po @@ -7,11 +7,11 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" "PO-Revision-Date: 2024-07-28 18:47+0000\n" "Last-Translator: Victor hck \n" -"Language-Team: Spanish \n" +"Language-Team: Spanish \n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -191,7 +191,7 @@ msgstr "Escribiendo el gestor de arranque sysconfig" #. Issue representing the proposal is not valid. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:192 +#: service/lib/agama/storage/proposal.rb:283 msgid "Cannot accommodate the required file systems for installation" msgstr "" "No se pueden acomodar los sistemas de archivos necesarios para la instalación" @@ -199,7 +199,7 @@ msgstr "" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:203 +#: service/lib/agama/storage/proposal.rb:294 msgid "A problem ocurred while calculating the storage setup" msgstr "Ocurrió un problema al calcular la configuración de almacenamiento" @@ -233,6 +233,50 @@ msgstr "" "Es necesario definir un usuario, configurar la contraseña de root o una " "clave pública SSH" +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:134 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:145 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:156 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "" + +#. @see #not_found_issue +#: service/lib/y2storage/proposal/agama_searcher.rb:98 +msgid "No device found for an optional drive" +msgstr "" + +#: service/lib/y2storage/proposal/agama_searcher.rb:100 +msgid "No device found for a mandatory drive" +msgstr "" + +#: service/lib/y2storage/proposal/agama_searcher.rb:103 +#, fuzzy +msgid "No device found for an optional partition" +msgstr "No se seleccionó ningún dispositivo para la instalación" + +#: service/lib/y2storage/proposal/agama_searcher.rb:105 +#, fuzzy +msgid "No device found for a mandatory partition" +msgstr "No se seleccionó ningún dispositivo para la instalación" + #~ msgid "Partitioning" #~ msgstr "Particionado" diff --git a/service/po/fr.po b/service/po/fr.po index ac720319ec..ae6477f56a 100644 --- a/service/po/fr.po +++ b/service/po/fr.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" "PO-Revision-Date: 2024-04-19 23:43+0000\n" "Last-Translator: faila fail \n" "Language-Team: French \n" "Language-Team: Indonesian \n" "Language-Team: Japanese \n" @@ -190,14 +190,14 @@ msgstr "ブートローダの sysconfig を書き込んでいます" #. Issue representing the proposal is not valid. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:192 +#: service/lib/agama/storage/proposal.rb:283 msgid "Cannot accommodate the required file systems for installation" msgstr "インストールに必要なファイルシステムを調整できません" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:203 +#: service/lib/agama/storage/proposal.rb:294 msgid "A problem ocurred while calculating the storage setup" msgstr "ストレージ設定を作成する際に問題が発生しました" @@ -225,6 +225,49 @@ msgid "" msgstr "" "ユーザの設定、 root パスワードの設定、 SSH 公開鍵の設定のいずれかが必要です" +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:134 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "パスフレーズが設定されていません ('%{crypt_method}' " +"方式を使用する際には必須です) 。" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:145 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "このシステムでは '%{crypt_method}' 暗号化方式は利用できません。" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:156 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "'%{crypt_method}' はデバイスの暗号化方式として不適切です。" + +#. @see #not_found_issue +#: service/lib/y2storage/proposal/agama_searcher.rb:98 +msgid "No device found for an optional drive" +msgstr "任意指定のドライブに対応するデバイスが見つかりません" + +#: service/lib/y2storage/proposal/agama_searcher.rb:100 +msgid "No device found for a mandatory drive" +msgstr "必須指定のドライブに対応するデバイスが見つかりません" + +#: service/lib/y2storage/proposal/agama_searcher.rb:103 +msgid "No device found for an optional partition" +msgstr "任意指定のパーティションに対応するデバイスが見つかりません" + +#: service/lib/y2storage/proposal/agama_searcher.rb:105 +msgid "No device found for a mandatory partition" +msgstr "必須指定のパーティションに対応するデバイスが見つかりません" + #~ msgid "Probing Storage" #~ msgstr "ストレージを検出しています" diff --git a/service/po/ka.po b/service/po/ka.po index bd0944d7a7..f1582a0172 100644 --- a/service/po/ka.po +++ b/service/po/ka.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" "PO-Revision-Date: 2024-06-28 16:46+0000\n" "Last-Translator: Temuri Doghonadze \n" "Language-Team: Georgian \n" "Language-Team: Norwegian Bokmål \n" "Language-Team: Russian \n" -"Language-Team: Swedish \n" +"Language-Team: Swedish \n" "Language: sv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -190,14 +190,14 @@ msgstr "Skriver starthanterarens sysconfig" #. Issue representing the proposal is not valid. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:192 +#: service/lib/agama/storage/proposal.rb:283 msgid "Cannot accommodate the required file systems for installation" msgstr "Kan inte ta emot de filsystem som krävs för installation" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:203 +#: service/lib/agama/storage/proposal.rb:294 msgid "A problem ocurred while calculating the storage setup" msgstr "Ett problem uppstod vid beräkning av lagringskonfigurationen" @@ -227,6 +227,50 @@ msgstr "" "Definiera en användare, för att ställa in root-lösenordet eller en offentlig " "SSH-nyckel krävs" +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:134 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:145 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:156 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "" + +#. @see #not_found_issue +#: service/lib/y2storage/proposal/agama_searcher.rb:98 +msgid "No device found for an optional drive" +msgstr "" + +#: service/lib/y2storage/proposal/agama_searcher.rb:100 +msgid "No device found for a mandatory drive" +msgstr "" + +#: service/lib/y2storage/proposal/agama_searcher.rb:103 +#, fuzzy +msgid "No device found for an optional partition" +msgstr "Ingen enhet har valts för installation" + +#: service/lib/y2storage/proposal/agama_searcher.rb:105 +#, fuzzy +msgid "No device found for a mandatory partition" +msgstr "Ingen enhet har valts för installation" + #~ msgid "Probing Storage" #~ msgstr "Undersöker lagring" diff --git a/service/po/tr.po b/service/po/tr.po index 8cc60ad926..4957b1c22e 100644 --- a/service/po/tr.po +++ b/service/po/tr.po @@ -7,51 +7,49 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" -"PO-Revision-Date: 2024-05-16 15:46+0000\n" -"Last-Translator: Özgür Arslan \n" -"Language-Team: Turkish \n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" +"PO-Revision-Date: 2024-08-28 17:47+0000\n" +"Last-Translator: yok \n" +"Language-Team: Turkish \n" "Language: tr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.5.5\n" +"X-Generator: Weblate 5.6.2\n" #. Runs the startup phase #: service/lib/agama/manager.rb:88 msgid "Load software translations" -msgstr "" +msgstr "Yazılım çevirilerini yükle" #: service/lib/agama/manager.rb:89 msgid "Load storage translations" -msgstr "" +msgstr "Depolama alanı çevirilerini yükleyin" #. Runs the config phase #: service/lib/agama/manager.rb:104 msgid "Analyze disks" -msgstr "" +msgstr "Diskleri analiz et" #: service/lib/agama/manager.rb:104 -#, fuzzy msgid "Configure software" -msgstr "Yazılım İnceleniyor" +msgstr "Yazılımı yapılandırın" #. Runs the install phase #. rubocop:disable Metrics/AbcSize #: service/lib/agama/manager.rb:124 msgid "Prepare disks" -msgstr "" +msgstr "Diskleri hazırlayın" #: service/lib/agama/manager.rb:125 -#, fuzzy msgid "Install software" -msgstr "Yazılımı Yükleme" +msgstr "Yazılımı yükleyin" #: service/lib/agama/manager.rb:126 msgid "Configure the system" -msgstr "" +msgstr "Sistemi yapılandırın" #. Callback to handle unsigned files #. @@ -143,13 +141,16 @@ msgid "" "case the device does contain a file system or a storage system that is not " "supported, resizing will most likely cause data loss." msgstr "" +"Cihazda ne bir dosya sistemi ne de bir depolama sistemi algılanmadı. Cihazın " +"desteklenmeyen bir dosya sistemi veya depolama sistemi içermesi durumunda, " +"yeniden boyutlandırma büyük olasılıkla veri kaybına neden olacaktır." #. Text of the reason preventing to shrink because there is no valid minimum size. #. #. @return [String, nil] nil if there is a minimum size or there is any other reasons. #: service/lib/agama/storage/device_shrinking.rb:162 msgid "Shrinking is not supported by this device" -msgstr "" +msgstr "Bu cihaz küçültmeyi desteklemiyor" #. Probes storage devices and performs an initial proposal #: service/lib/agama/storage/manager.rb:115 @@ -189,16 +190,16 @@ msgstr "bootloader sysconfig yazılıyor" #. Issue representing the proposal is not valid. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:192 +#: service/lib/agama/storage/proposal.rb:283 msgid "Cannot accommodate the required file systems for installation" -msgstr "Kurulum için gerekli dosya sistemleri karşılanamıyor" +msgstr "Kurulum için gerekli dosya sistemlerine yer verilemiyor" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] -#: service/lib/agama/storage/proposal.rb:203 +#: service/lib/agama/storage/proposal.rb:294 msgid "A problem ocurred while calculating the storage setup" -msgstr "" +msgstr "Depolama kurulumu hesaplanırken bir sorun oluştu" #. Returns an issue if there is no target device. #. @@ -223,6 +224,51 @@ msgstr[1] "Aşağıdaki seçilen cihazlar sistemde bulunamadı: %{devices}" msgid "" "Defining a user, setting the root password or a SSH public key is required" msgstr "" +"Bir kullanıcı tanımlamak, kök parolasını veya bir SSH genel anahtarını " +"ayarlamak gerekir" + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:134 +#, perl-brace-format +msgid "" +"No passphrase provided (required for using the method '%{crypt_method}')." +msgstr "" +"Parola sağlanmadı ('%{crypt_method}' metodunu kullanmak için gereklidir)." + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:145 +#, perl-brace-format +msgid "Encryption method '%{crypt_method}' is not available in this system." +msgstr "Bu sistemde '%{crypt_method}' şifreleme yöntemi mevcut değil." + +#. @see #check_encryption +#. TRANSLATORS: 'crypt_method' is the name of the method to encrypt the device (like +#. 'luks1' or 'random_swap'). +#: service/lib/y2storage/proposal/agama_device_planner.rb:156 +#, perl-brace-format +msgid "'%{crypt_method}' is not a suitable method to encrypt the device." +msgstr "'%{crypt_method}' cihazı şifrelemek için uygun bir yöntem değil." + +#. @see #not_found_issue +#: service/lib/y2storage/proposal/agama_searcher.rb:98 +msgid "No device found for an optional drive" +msgstr "İsteğe bağlı bir sürücü için aygıt bulunamadı" + +#: service/lib/y2storage/proposal/agama_searcher.rb:100 +msgid "No device found for a mandatory drive" +msgstr "Zorunlu bir sürücü için cihaz bulunamadı" + +#: service/lib/y2storage/proposal/agama_searcher.rb:103 +msgid "No device found for an optional partition" +msgstr "İsteğe bağlı bölüm için cihaz bulunamadı" + +#: service/lib/y2storage/proposal/agama_searcher.rb:105 +msgid "No device found for a mandatory partition" +msgstr "Zorunlu bölüm için hiçbir cihaz bulunamadı" #~ msgid "Probing Storage" #~ msgstr "Depolama inceleniyor" diff --git a/service/po/zh_Hans.po b/service/po/zh_Hans.po index 625a523752..c9a68ad63d 100644 --- a/service/po/zh_Hans.po +++ b/service/po/zh_Hans.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-07-10 02:23+0000\n" +"POT-Creation-Date: 2024-08-28 02:30+0000\n" "PO-Revision-Date: 2024-07-03 14:46+0000\n" "Last-Translator: Monstorix \n" "Language-Team: Chinese (Simplified) "/dev/vda" }]) + expect(settings).to eq(config_json[:legacyAutoyastStorage]) end - subject.apply_storage_config(storage_config.to_json) + subject.apply_config(serialized_config) end end end - describe "#serialized_storage_config" do - def pretty_json(value) + describe "#recover_config" do + def serialize(value) JSON.pretty_generate(value) end context "if a proposal has not been calculated" do it "returns serialized empty storage config" do - expect(subject.serialized_storage_config).to eq(pretty_json({})) + expect(subject.recover_config).to eq(serialize({})) end end - context "if a proposal has been calculated" do + context "if a guided proposal has been calculated" do before do proposal.calculate_guided(settings) end @@ -537,92 +579,102 @@ def pretty_json(value) end end - it "returns serialized storage config including guided proposal settings" do + it "returns serialized guided storage config" do expected_config = { storage: { guided: settings.to_json_settings } } - expect(subject.serialized_storage_config).to eq(pretty_json(expected_config)) + expect(subject.recover_config).to eq(serialize(expected_config)) end end - end - describe "#proposal_calculated?" do - before do - allow(proposal).to receive(:calculated?).and_return(calculated) - end + context "if an agama proposal has been calculated" do + before do + proposal.calculate_agama(config) + end - context "if the proposal is not calculated yet" do - let(:calculated) { false } + let(:config) do + fs_type = Agama::Storage::Configs::FilesystemType.new.tap do |t| + t.fs_type = Y2Storage::Filesystems::Type::BTRFS + end - it "returns false" do - expect(subject.proposal_calculated?).to eq(false) - end - end + filesystem = Agama::Storage::Configs::Filesystem.new.tap do |f| + f.type = fs_type + end - context "if the proposal is calculated" do - let(:calculated) { true } + drive = Agama::Storage::Configs::Drive.new.tap do |d| + d.filesystem = filesystem + end - it "returns true" do - expect(subject.proposal_calculated?).to eq(true) + Agama::Storage::Config.new.tap do |config| + config.drives = [drive] + end end - end - end - describe "#proposal_result" do - before do - allow(proposal).to receive(:calculated?).and_return(calculated) - end + it "returns serialized storage config" do + skip "Missing conversion from Agama::Storage::Config to JSON" - context "if the proposal is not calculated yet" do - let(:calculated) { false } + expected_config = { + storage: { + drives: [ + { + filesystem: { + type: "btrfs" + } + } + ] + } + } - it "returns an empty hash" do - expect(subject.proposal_result).to eq({}) + expect(subject.recover_config).to eq(serialize(expected_config)) end end - context "if the proposal is calculated" do - let(:calculated) { true } - let(:guided) { Agama::DBus::Storage::Manager::ProposalStrategy::GUIDED } - let(:autoyast) { Agama::DBus::Storage::Manager::ProposalStrategy::AUTOYAST } - - context "and it is a guided proposal" do - before do - allow(proposal).to receive(:strategy?).with(guided).and_return(true) - allow(proposal).to receive(:success?).and_return(true) - allow(proposal).to receive(:settings).and_return(Agama::Storage::ProposalSettings.new) - end + context "if a proposal was calculated from storage json" do + before do + proposal.calculate_from_json(config_json) + end - it "returns a Hash with success, strategy and settings" do - result = subject.proposal_result - serialized_settings = proposal.settings.to_json_settings.to_json + let(:config_json) do + { + storage: { + drives: [ + ptableType: "gpt", + partitions: [ + { + filesystem: { + type: "btrfs", + path: "/" + } + } + ] + ] + } + } + end - expect(result.keys).to contain_exactly("success", "strategy", "settings") - expect(result["success"]).to eq(true) - expect(result["strategy"]).to eq(guided) - expect(result["settings"]).to eq(serialized_settings) - end + it "returns the serialized storage config" do + expect(subject.recover_config).to eq(serialize(config_json)) end + end - context "and it is an autoyast proposal" do - before do - allow(proposal).to receive(:strategy?).with(guided).and_return(false) - allow(proposal).to receive(:success?).and_return(true) - allow(proposal).to receive(:settings).and_return({}) - end + context "if a proposal was calculated from AutoYaST json" do + before do + proposal.calculate_from_json(autoyast_json) + end - it "returns a Hash with success, strategy and settings" do - result = subject.proposal_result - serialized_settings = proposal.settings.to_json + let(:autoyast_json) do + { + legacyAutoyastStorage: [ + { device: "/dev/vda" } + ] + } + end - expect(result.keys).to contain_exactly("success", "strategy", "settings") - expect(result["success"]).to eq(true) - expect(result["strategy"]).to eq(autoyast) - expect(result["settings"]).to eq(serialized_settings) - end + it "returns the serialized AutoYaST config" do + expect(subject.recover_config).to eq(serialize(autoyast_json)) end end end diff --git a/service/test/agama/dbus/storage/proposal_test.rb b/service/test/agama/dbus/storage/proposal_test.rb index 59b5768c41..49237c240c 100644 --- a/service/test/agama/dbus/storage/proposal_test.rb +++ b/service/test/agama/dbus/storage/proposal_test.rb @@ -32,23 +32,27 @@ subject { described_class.new(backend, logger) } let(:backend) do - instance_double(Agama::Storage::Proposal, settings: settings) + instance_double(Agama::Storage::Proposal, guided_settings: settings, guided?: guided) end let(:logger) { Logger.new($stdout, level: :warn) } let(:settings) { nil } + let(:guided) { false } + describe "#settings" do - context "if a proposal has not been calculated yet" do - let(:settings) { nil } + context "if a guided proposal has not been calculated yet" do + let(:guided) { false } it "returns an empty hash" do expect(subject.settings).to eq({}) end end - context "if a proposal has been calculated" do + context "if a guided proposal has been calculated" do + let(:guided) { true } + let(:settings) do Agama::Storage::ProposalSettings.new.tap do |settings| settings.device = Agama::Storage::DeviceSettings::Disk.new("/dev/vda") diff --git a/service/test/agama/storage/autoyast_proposal_test.rb b/service/test/agama/storage/autoyast_proposal_test.rb index e68a3e10f0..eef85a3920 100644 --- a/service/test/agama/storage/autoyast_proposal_test.rb +++ b/service/test/agama/storage/autoyast_proposal_test.rb @@ -468,6 +468,31 @@ def root_filesystem(disk) end end + describe "using encryption for some partitions" do + let(:root) do + ROOT_PART.merge("create" => true, "crypt_method" => :luks1, "crypt_key" => "12345") + end + + let(:partitioning) do + [{ "device" => "/dev/sda", "use" => "all", "partitions" => [root] }] + end + + it "returns true and stores a successful proposal" do + expect(subject.calculate_autoyast(partitioning)).to eq true + expect(Y2Storage::StorageManager.instance.proposal.failed?).to eq false + end + + it "creates the expected layout" do + subject.calculate_autoyast(partitioning) + partitions = staging.find_by_name("/dev/sda").partitions.sort_by(&:number) + expect(partitions.size).to eq(2) + expect(partitions[0].id.is?(:esp)).to eq(true) + expect(partitions[1].filesystem.root?).to eq(true) + expect(partitions[1].encryption.type).to eq(Y2Storage::EncryptionType::LUKS1) + expect(partitions[1].encryption.password).to eq("12345") + end + end + describe "automatic partitioning" do let(:partitioning) do [ diff --git a/service/test/agama/storage/config_conversions/from_json_test.rb b/service/test/agama/storage/config_conversions/from_json_test.rb new file mode 100644 index 0000000000..4a736505e5 --- /dev/null +++ b/service/test/agama/storage/config_conversions/from_json_test.rb @@ -0,0 +1,873 @@ +# 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_conversions/from_json" +require "y2storage/encryption_method" +require "y2storage/filesystems/mount_by_type" +require "y2storage/filesystems/type" +require "y2storage/pbkd_function" + +describe Agama::Storage::ConfigConversions::FromJSON do + subject { described_class.new(config_json, product_config: product_config) } + + let(:product_config) { Agama::Config.new(product_data) } + + let(:product_data) do + { + "storage" => { + "lvm" => false, + "space_policy" => "delete", + "encryption" => { + "method" => "luks2", + "pbkd_function" => "argon2id" + }, + "volumes" => ["/", "swap"], + "volume_templates" => [ + { + "mount_path" => "/", "filesystem" => "btrfs", "size" => { "auto" => true }, + "btrfs" => { + "snapshots" => true, "default_subvolume" => "@", + "subvolumes" => ["home", "opt", "root", "srv"] + }, + "outline" => { + "required" => true, "snapshots_configurable" => true, + "auto_size" => { + "base_min" => "5 GiB", "base_max" => "10 GiB", + "min_fallback_for" => ["/home"], "max_fallback_for" => ["/home"], + "snapshots_increment" => "300%" + } + } + }, + { + "mount_path" => "/home", "size" => { "auto" => false, "min" => "5 GiB" }, + "filesystem" => "xfs", "outline" => { "required" => false } + }, + { + "mount_path" => "swap", "filesystem" => "swap", + "outline" => { "required" => false } + }, + { "mount_path" => "", "filesystem" => "ext4", + "size" => { "min" => "100 MiB" } } + ] + } + } + end + + before do + # Speed up tests by avoding real check of TPM presence. + allow(Y2Storage::EncryptionMethod::TPM_FDE).to receive(:possible?).and_return(true) + end + + describe "#convert" do + using Y2Storage::Refinements::SizeCasts + + context "with an empty JSON configuration" do + let(:config_json) { {} } + + it "generates a storage configuration" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Config) + end + + it "calculates default boot settings" do + config = subject.convert + expect(config.boot).to be_a(Agama::Storage::Configs::Boot) + expect(config.boot.configure).to eq true + expect(config.boot.device).to eq nil + end + + # @todo Generate default drive/LVM from product descripton. + it "does not include any device in the configuration" do + config = subject.convert + expect(config.drives).to be_empty + end + end + + context "with some drives and boot configuration at JSON" do + let(:config_json) do + { + boot: { configure: true, device: "/dev/sdb" }, + drives: [ + { + ptableType: "gpt", + partitions: [{ filesystem: { path: "/" } }] + } + ] + } + end + + it "generates a storage configuration" do + config = subject.convert + expect(config).to be_a(Agama::Storage::Config) + end + + it "calculates the corresponding boot settings" do + config = subject.convert + expect(config.boot).to be_a(Agama::Storage::Configs::Boot) + expect(config.boot.configure).to eq true + expect(config.boot.device).to eq "/dev/sdb" + end + + it "includes the corresponding drives" do + config = subject.convert + expect(config.drives.size).to eq 1 + drive = config.drives.first + expect(drive).to be_a(Agama::Storage::Configs::Drive) + expect(drive.ptable_type).to eq Y2Storage::PartitionTables::Type::GPT + expect(drive.partitions.size).to eq 1 + partition = drive.partitions.first + expect(partition.filesystem.path).to eq "/" + end + + context "omitting search for a drive" do + let(:config_json) do + { + drives: [ + { + partitions: [] + } + ] + } + end + + it "sets the default search" do + config = subject.convert + drive = config.drives.first + expect(drive.search).to be_a(Agama::Storage::Configs::Search) + expect(drive.search.name).to be_nil + expect(drive.search.if_not_found).to eq(:error) + end + end + + context "specifying search for a drive" do + let(:config_json) do + { + drives: [ + { + search: search, + partitions: [] + } + ] + } + end + + context "with a device name" do + let(:search) { "/dev/vda" } + + it "sets the expected search" do + config = subject.convert + drive = config.drives.first + expect(drive.search).to be_a(Agama::Storage::Configs::Search) + expect(drive.search.name).to eq("/dev/vda") + expect(drive.search.if_not_found).to eq(:error) + end + end + + context "with a search section" do + let(:search) do + { + condition: { name: "/dev/vda" }, + ifNotFound: "skip" + } + end + + it "sets the expected search" do + config = subject.convert + drive = config.drives.first + expect(drive.search).to be_a(Agama::Storage::Configs::Search) + expect(drive.search.name).to eq("/dev/vda") + expect(drive.search.if_not_found).to eq(:skip) + end + end + end + end + + context "specifying a filesystem for a drive" do + let(:config_json) do + { + drives: [{ filesystem: filesystem }] + } + end + + let(:filesystem) do + { + path: "/", + type: "xfs", + label: "root", + mkfsOptions: ["version=2"], + mountOptions: ["rw"], + mountBy: "label" + } + end + + it "uses the specified attributes" do + config = subject.convert + filesystem = config.drives.first.filesystem + expect(filesystem.path).to eq "/" + expect(filesystem.type.fs_type).to eq Y2Storage::Filesystems::Type::XFS + expect(filesystem.label).to eq "root" + expect(filesystem.mkfs_options).to eq ["version=2"] + expect(filesystem.mount_options).to eq ["rw"] + expect(filesystem.mount_by).to eq Y2Storage::Filesystems::MountByType::LABEL + end + + context "if the filesystem specification only contains a path" do + let(:filesystem) { { path: "/" } } + + it "uses the default type and btrfs attributes for that path" do + config = subject.convert + filesystem = config.drives.first.filesystem + expect(filesystem.type.fs_type).to eq Y2Storage::Filesystems::Type::BTRFS + expect(filesystem.type.btrfs.snapshots).to eq true + expect(filesystem.type.btrfs.default_subvolume).to eq "@" + expect(filesystem.type.btrfs.subvolumes.map(&:path)).to eq ["home", "opt", "root", "srv"] + end + end + + context "if the filesystem specification contains some btrfs settings" do + let(:filesystem) do + { path: "/", + type: { btrfs: { snapshots: false, default_subvolume: "", subvolumes: ["tmp"] } } } + end + + it "uses the specified btrfs attributes" do + config = subject.convert + filesystem = config.drives.first.filesystem + expect(filesystem.type.fs_type).to eq Y2Storage::Filesystems::Type::BTRFS + expect(filesystem.type.btrfs.snapshots).to eq false + # TODO: none of the following attributes are specified at the schema. Intentional? + # expect(filesystem.type.btrfs.default_subvolume).to eq "" + # expect(filesystem.type.btrfs.subvolumes.map(&:path)).to eq ["tmp"] + end + + context "and the default filesystem type is not btrfs" do + let(:filesystem) do + { path: "/home", type: { btrfs: { snapshots: false } } } + end + + it "uses btrfs filesystem" do + config = subject.convert + filesystem = config.drives.first.filesystem + expect(filesystem.type.fs_type).to eq Y2Storage::Filesystems::Type::BTRFS + end + end + end + end + + context "omitting search for a partition" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { + path: "/" + } + } + ] + } + ] + } + end + + it "does not set a search" do + config = subject.convert + drive = config.drives.first + partition = drive.partitions.first + expect(partition.search).to be_nil + end + end + + context "specifying search for a partition" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + search: search, + filesystem: { + path: "/" + } + } + ] + } + ] + } + end + + context "with a device name" do + let(:search) { "/dev/vda1" } + + it "sets the expected search" do + config = subject.convert + drive = config.drives.first + partition = drive.partitions.first + expect(partition.search).to be_a(Agama::Storage::Configs::Search) + expect(partition.search.name).to eq("/dev/vda1") + expect(partition.search.if_not_found).to eq(:error) + end + end + + context "with a search section" do + let(:search) do + { + condition: { name: "/dev/vda1" }, + ifNotFound: "skip" + } + end + + it "sets the expected search" do + config = subject.convert + drive = config.drives.first + partition = drive.partitions.first + expect(partition.search).to be_a(Agama::Storage::Configs::Search) + expect(partition.search.name).to eq("/dev/vda1") + expect(partition.search.if_not_found).to eq(:skip) + end + end + end + + context "setting delete for a partition" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + search: "/dev/vda1", + delete: true + }, + { + filesystem: { path: "/" } + } + ] + } + ] + } + end + + it "sets #delete to true" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + search: have_attributes(name: "/dev/vda1"), + delete: true, + delete_if_needed: false + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + delete: false, + delete_if_needed: false + ) + ) + end + end + + context "setting delete if needed for a partition" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + search: "/dev/vda1", + deleteIfNeeded: true + }, + { + filesystem: { path: "/" } + } + ] + } + ] + } + end + + it "sets #delete_if_needed to true" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + search: have_attributes(name: "/dev/vda1"), + delete: false, + delete_if_needed: true + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + delete: false, + delete_if_needed: false + ) + ) + end + end + + context "omitting sizes for the partitions" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { filesystem: { path: "/", type: { btrfs: { snapshots: false } } } }, + { filesystem: { path: "/home" } }, + { filesystem: { path: "/opt" } }, + { filesystem: { path: "swap" } } + ] + } + ] + } + end + + it "uses default sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 5.GiB, max: 10.GiB) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + size: have_attributes(default: true, min: 5.GiB, + max: Y2Storage::DiskSize.unlimited) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: true, min: 100.MiB, + max: Y2Storage::DiskSize.unlimited) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + size: have_attributes( + default: true, min: Y2Storage::DiskSize.zero, max: Y2Storage::DiskSize.unlimited + ) + ) + ) + end + end + + context "setting fixed sizes for the partitions" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { filesystem: { path: "/" }, size: "10 GiB" }, + { filesystem: { path: "/home" }, size: "6Gb" }, + { filesystem: { path: "/opt" }, size: 3221225472 }, + { filesystem: { path: "swap" }, size: "6 Gib" } + ] + } + ] + } + end + + it "sets both min and max to the same value if a string is used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: false, min: 10.GiB, max: 10.GiB) + ) + ) + end + + it "sets both min and max to the same value if an integer is used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: false, min: 3.GiB, max: 3.GiB) + ) + ) + end + + it "makes a difference between SI units and binary units" do + config = subject.convert + partitions = config.drives.first.partitions + home_size = partitions.find { |p| p.filesystem.path == "/home" }.size + swap_size = partitions.find { |p| p.filesystem.path == "swap" }.size + expect(swap_size.min.to_i).to eq 6 * 1024 * 1024 * 1024 + expect(home_size.max.to_i).to eq 6 * 1000 * 1000 * 1000 + end + end + + # Note the min is mandatory + context "specifying size limits for the partitions" do + RSpec.shared_examples "size limits" do + it "sets both min and max limits as requested if strings are used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + size: have_attributes(default: false, min: 6.GiB, max: 9.GiB) + ) + ) + end + + it "makes a difference between SI units and binary units" do + config = subject.convert + partitions = config.drives.first.partitions + home_size = partitions.find { |p| p.filesystem.path == "/home" }.size + swap_size = partitions.find { |p| p.filesystem.path == "swap" }.size + expect(home_size.min.to_i).to eq 6 * 1024 * 1024 * 1024 + expect(swap_size.max.to_i).to eq 6 * 1000 * 1000 * 1000 + end + + it "sets both min and max limits as requested if numbers are used" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + size: have_attributes(default: false, min: 1.GiB) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: false, min: 1.GiB, max: 3.GiB) + ) + ) + end + + it "uses unlimited for the omitted max sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: false, min: 3.GiB, + max: Y2Storage::DiskSize.unlimited) + ) + ) + end + end + + context "using a hash" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/", type: { btrfs: { snapshots: false } } }, + size: { min: "3 GiB" } + }, + { + filesystem: { path: "/home" }, + size: { min: "6 GiB", max: "9 GiB" } + }, + { + filesystem: { path: "swap" }, + size: { min: 1073741824, max: "6 GB" } + }, + { + filesystem: { path: "/opt" }, + size: { min: "1073741824", max: 3221225472 } + } + ] + } + ] + } + end + + include_examples "size limits" + end + + context "using an array" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/", type: { btrfs: { snapshots: false } } }, + size: ["3 GiB"] + }, + { + filesystem: { path: "/home" }, + size: ["6 GiB", "9 GiB"] + }, + { + filesystem: { path: "swap" }, + size: [1073741824, "6 GB"] + }, + { + filesystem: { path: "/opt" }, + size: ["1073741824", 3221225472] + } + ] + } + ] + } + end + + include_examples "size limits" + end + end + + # TODO: "default" is not currently accepted by the schema. + xcontext "using 'default' as size for some partitions and size limit for others" do + let(:config_json) do + { + drives: [ + { + partitions: [ + { + filesystem: { path: "/", size: "default" } + }, + { + filesystem: { path: "/opt" }, + size: { min: "6 GiB", max: "22 GiB" } + } + ] + } + ] + } + end + + it "uses the appropriate sizes for each partition" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 40.GiB, + max: Y2Storage::DiskSize.unlimited) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "/opt"), + size: have_attributes(default: false, min: 6.GiB, max: 22.GiB) + ) + ) + end + end + + # TODO: "default" is not currently accepted by the schema. + xcontext "using 'default' for a partition that is fallback for others" do + let(:config_json) { { drives: [{ partitions: partitions }] } } + let(:root) do + { filesystem: { path: "/", type: { btrfs: { snapshots: false } } }, size: "default" } + end + let(:partitions) { [root] + other } + + context "if the other partitions are ommitted" do + let(:other) { [] } + + it "sums all the fallback sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 10.GiB, + max: Y2Storage::DiskSize.unlimited) + ) + ) + end + end + + context "if the other partitions are included (even with non-exact name)" do + let(:other) { [{ filesystem: { path: "/home/" } }] } + + it "ignores the fallback sizes" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to include( + an_object_having_attributes( + filesystem: have_attributes(path: "/"), + size: have_attributes(default: true, min: 5.GiB, max: 10.GiB) + ) + ) + end + end + end + + context "configuring partial information for several mount points" do + let(:config_json) { { drives: [{ partitions: partitions }] } } + let(:partitions) do + [ + { filesystem: { path: "/" } }, + { filesystem: { path: "swap" } }, + { filesystem: { path: "/opt" } } + ] + end + + it "configures the filesystem types according to the product configuration" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes( + path: "/", type: have_attributes(fs_type: Y2Storage::Filesystems::Type::BTRFS) + ) + ), + an_object_having_attributes( + filesystem: have_attributes( + path: "swap", type: have_attributes(fs_type: Y2Storage::Filesystems::Type::SWAP) + ) + ), + an_object_having_attributes( + filesystem: have_attributes( + path: "/opt", type: have_attributes(fs_type: Y2Storage::Filesystems::Type::EXT4) + ) + ) + ) + end + end + + context "when some partition is configured to be encrypted" do + let(:config_json) do + { + drives: [{ partitions: partitions }] + } + end + + let(:partitions) do + [ + { + id: "linux", size: { min: "10 GiB" }, + filesystem: { type: "xfs", path: "/home" }, + encryption: encryption_home + }, + { + size: { min: "2 GiB" }, + filesystem: { type: "swap", path: "swap" }, + encryption: encryption_swap + } + ] + end + + let(:encryption_home) do + { luks2: { password: "notsecret", keySize: 256 } } + end + + let(:encryption_swap) { nil } + + it "sets the encryption settings for the corresponding partition" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + encryption: have_attributes( + password: "notsecret", method: Y2Storage::EncryptionMethod::LUKS2, key_size: 256 + ) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + encryption: nil + ) + ) + end + + context "if only the password is provided" do + let(:encryption_home) { { luks2: { password: "notsecret" } } } + let(:encryption_swap) { nil } + + it "uses the default derivation function" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + encryption: have_attributes( + password: "notsecret", + method: Y2Storage::EncryptionMethod::LUKS2, + pbkd_function: Y2Storage::PbkdFunction::ARGON2ID + ) + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + encryption: nil + ) + ) + end + end + + context "if random encryption is configured for swap" do + let(:encryption_home) { nil } + let(:encryption_swap) { "random_swap" } + + it "sets the corresponding configuration" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + encryption: nil + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + encryption: have_attributes( + password: nil, + label: nil, + cipher: nil, + method: Y2Storage::EncryptionMethod::RANDOM_SWAP + ) + ) + ) + end + end + end + + context "when the id of some partition is specified" do + let(:config_json) do + { + drives: [{ partitions: partitions }] + } + end + + let(:partitions) do + [ + { + id: "Esp", size: { min: "10 GiB" }, + filesystem: { type: "xfs", path: "/home" } + }, + { + size: { min: "2 GiB" }, + filesystem: { type: "swap", path: "swap" } + } + ] + end + + it "configures the corresponding id" do + config = subject.convert + partitions = config.drives.first.partitions + expect(partitions).to contain_exactly( + an_object_having_attributes( + filesystem: have_attributes(path: "/home"), + id: Y2Storage::PartitionId::ESP + ), + an_object_having_attributes( + filesystem: have_attributes(path: "swap"), + id: nil + ) + ) + end + end + end +end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index ad66668f49..b29bf6f5d8 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -22,37 +22,65 @@ require_relative "../../test_helper" require_relative "storage_helpers" require "agama/config" +require "agama/storage/configs" require "agama/storage/device_settings" require "agama/storage/proposal" require "agama/storage/proposal_settings" require "y2storage" +def root_partition(size) + fs_type_config = Agama::Storage::Configs::FilesystemType.new.tap do |t| + t.fs_type = Y2Storage::Filesystems::Type::BTRFS + end + + filesystem_config = Agama::Storage::Configs::Filesystem.new.tap do |f| + f.type = fs_type_config + f.path = "/" + end + + size_config = Agama::Storage::Configs::Size.new.tap do |s| + s.min = size + s.max = size + end + + Agama::Storage::Configs::Partition.new.tap do |p| + p.filesystem = filesystem_config + p.size = size_config + end +end + +def drive(partitions) + Agama::Storage::Configs::Drive.new.tap do |d| + d.partitions = partitions + end +end + describe Agama::Storage::Proposal do include Agama::RSpec::StorageHelpers - before { mock_storage(devicegraph: "partitioned_md.yml") } - subject(:proposal) { described_class.new(config, logger: logger) } let(:logger) { Logger.new($stdout, level: :warn) } let(:config) { Agama::Config.new } - let(:achievable_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.boot.device = "/dev/sda" - settings.volumes = [Agama::Storage::Volume.new("/")] + before do + mock_storage(devicegraph: "empty-hd-50GiB.yaml") + end + + let(:achivable_config) do + Agama::Storage::Config.new.tap do |config| + root = root_partition(Y2Storage::DiskSize.GiB(10)) + drive = drive([root]) + config.drives = [drive] end end - let(:impossible_settings) do - Agama::Storage::ProposalSettings.new.tap do |settings| - settings.device.name = "/dev/sdb" - settings.volumes = [ - # The boot disk size is 500 GiB, so it cannot accomodate a 1 TiB volume. - Agama::Storage::Volume.new("/").tap { |v| v.min_size = Y2Storage::DiskSize.TiB(1) } - ] + let(:impossible_config) do + Agama::Storage::Config.new.tap do |config| + root = root_partition(Y2Storage::DiskSize.GiB(100)) + drive = drive([root]) + config.drives = [drive] end end @@ -61,13 +89,13 @@ expect(subject.success?).to eq(false) end - context "if calculate_guided was already called" do + context "if a proposal was already calculated" do before do - subject.calculate_guided(settings) + subject.calculate_agama(config) end context "and the proposal was successful" do - let(:settings) { achievable_settings } + let(:config) { achivable_config } it "returns true" do expect(subject.success?).to eq(true) @@ -75,7 +103,7 @@ end context "and the proposal failed" do - let(:settings) { impossible_settings } + let(:config) { impossible_config } it "returns false" do expect(subject.success?).to eq(false) @@ -84,20 +112,141 @@ end end - describe "#calculate_guided" do - it "calculates a new proposal with the given settings" do - expect(Y2Storage::StorageManager.instance.proposal).to be_nil + describe "#config_json" do + context "if no proposal has been calculated yet" do + it "returns an empty hash" do + expect(subject.calculated?).to eq(false) + expect(proposal.config_json).to eq({}) + end + end - subject.calculate_guided(achievable_settings) + context "if a proposal was calculated with the guided strategy" do + before do + subject.calculate_guided(Agama::Storage::ProposalSettings.new) + end - expect(Y2Storage::StorageManager.instance.proposal).to_not be_nil - y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings - expect(y2storage_settings.root_device).to eq("/dev/sda") - expect(y2storage_settings.volumes).to contain_exactly( - an_object_having_attributes(mount_point: "/", device: "/dev/sdb") - ) + it "returns the guided JSON config" do + expected_json = { + storage: { + guided: { + boot: { + configure: true + }, + space: { + policy: "keep" + }, + target: { + disk: "/dev/sda" + }, + volumes: [] + } + } + } + + expect(subject.config_json).to eq(expected_json) + end + end + + context "if a proposal was calculated with the agama strategy" do + before do + subject.calculate_agama(achivable_config) + end + + it "returns the storage JSON config" do + skip "Missing conversion from Agama::Storage::Config to JSON" + end + end + + context "if a proposal was calculated from guided JSON config" do + before do + subject.calculate_from_json(config_json) + end + + let(:config_json) do + { + storage: { + guided: { + target: { + disk: "/dev/vda" + } + } + } + } + end + + it "returns the full guided JSON config" do + expected_json = { + storage: { + guided: { + boot: { + configure: true + }, + space: { + policy: "keep" + }, + target: { + disk: "/dev/vda" + }, + volumes: [] + } + } + } + + expect(subject.config_json).to eq(expected_json) + end + end + + context "if a proposal was calculated from storage JSON config" do + before do + subject.calculate_from_json(config_json) + end + + let(:config_json) do + { + storage: { + drives: [ + { + filesystem: { + type: "btrfs" + } + } + ] + } + } + end + + it "returns the given storage JSON config" do + expect(subject.config_json).to eq(config_json) + end end + context "if a proposal was calculated from autoyast JSON config" do + before do + subject.calculate_from_json(config_json) + end + + let(:config_json) do + { + legacyAutoyastStorage: [ + { + partitions: [ + { + mount: "/", + size: "10 GiB" + } + ] + } + ] + } + end + + it "returns the given autoyast JSON config" do + expect(subject.config_json).to eq(config_json) + end + end + end + + shared_examples "check proposal callbacks" do |action, settings| it "runs all the callbacks" do callback1 = proc {} callback2 = proc {} @@ -108,26 +257,106 @@ expect(callback1).to receive(:call) expect(callback2).to receive(:call) - subject.calculate_guided(achievable_settings) + subject.public_send(action, send(settings)) end + end + shared_examples "check proposal return" do |action, achivable_settings, impossible_settings| it "returns whether the proposal was successful" do - expect(subject.calculate_guided(achievable_settings)).to eq(true) - expect(subject.calculate_guided(impossible_settings)).to eq(false) + result = subject.public_send(action, send(achivable_settings)) + expect(result).to eq(true) + + result = subject.public_send(action, send(impossible_settings)) + expect(result).to eq(false) + end + end + + shared_examples "check early proposal" do |action, settings| + context "if the system has not been probed yet" do + before do + allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) + end + + it "does not calculate a proposal" do + subject.public_send(action, send(settings)) + expect(Y2Storage::StorageManager.instance.proposal).to be_nil + end + + it "does not run the callbacks" do + callback1 = proc {} + callback2 = proc {} + + subject.on_calculate(&callback1) + subject.on_calculate(&callback2) + + expect(callback1).to_not receive(:call) + expect(callback2).to_not receive(:call) + + subject.public_send(action, send(settings)) + end + + it "returns false" do + result = subject.public_send(action, send(settings)) + expect(result).to eq(false) + end + end + end + + describe "#calculate_guided" do + before do + mock_storage(devicegraph: "partitioned_md.yml") + end + + let(:achivable_settings) do + Agama::Storage::ProposalSettings.new.tap do |settings| + settings.device.name = "/dev/sdb" + settings.boot.device = "/dev/sda" + settings.volumes = [Agama::Storage::Volume.new("/")] + end + end + + let(:impossible_settings) do + Agama::Storage::ProposalSettings.new.tap do |settings| + settings.device.name = "/dev/sdb" + settings.volumes = [ + # The boot disk size is 500 GiB, so it cannot accomodate a 1 TiB volume. + Agama::Storage::Volume.new("/").tap { |v| v.min_size = Y2Storage::DiskSize.TiB(1) } + ] + end + end + + it "calculates a proposal with the guided strategy and with the given settings" do + expect(Y2Storage::StorageManager.instance.proposal).to be_nil + + subject.calculate_guided(achivable_settings) + + expect(Y2Storage::StorageManager.instance.proposal).to_not be_nil + y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings + expect(y2storage_settings.root_device).to eq("/dev/sda") + expect(y2storage_settings.volumes).to contain_exactly( + an_object_having_attributes(mount_point: "/", device: "/dev/sdb") + ) end + include_examples "check proposal callbacks", :calculate_guided, :achivable_settings + + include_examples "check proposal return", + :calculate_guided, :achivable_settings, :impossible_settings + + include_examples "check early proposal", :calculate_guided, :achivable_settings + context "if the given device settings sets a disk as target" do before do - achievable_settings.device = Agama::Storage::DeviceSettings::Disk.new + achivable_settings.device = Agama::Storage::DeviceSettings::Disk.new end context "and the target disk is not indicated" do before do - achievable_settings.device.name = nil + achivable_settings.device.name = nil end it "sets the first available device as target device for volumes" do - subject.calculate_guided(achievable_settings) + subject.calculate_guided(achivable_settings) y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings expect(y2storage_settings.volumes).to contain_exactly( @@ -139,71 +368,166 @@ context "if the given device settings sets a new LVM volume group as target" do before do - achievable_settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new + achivable_settings.device = Agama::Storage::DeviceSettings::NewLvmVg.new end context "and the target disks for physical volumes are not indicated" do before do - achievable_settings.device.candidate_pv_devices = [] + achivable_settings.device.candidate_pv_devices = [] end it "sets the first available device as candidate device" do - subject.calculate_guided(achievable_settings) + subject.calculate_guided(achivable_settings) y2storage_settings = Y2Storage::StorageManager.instance.proposal.settings expect(y2storage_settings.candidate_devices).to contain_exactly("/dev/sda") end end end + end - context "if the system has not been probed yet" do - before do - allow(Y2Storage::StorageManager.instance).to receive(:probed?).and_return(false) - end + describe "#calculate_agama" do + it "calculates a proposal with the agama strategy and with the given config" do + expect(Y2Storage::StorageManager.instance.proposal).to be_nil - it "does not calculate a proposal" do - subject.calculate_guided(achievable_settings) - expect(Y2Storage::StorageManager.instance.proposal).to be_nil - end + subject.calculate_agama(achivable_config) - it "does not run the callbacks" do - callback1 = proc {} - callback2 = proc {} + expect(Y2Storage::StorageManager.instance.proposal).to be_a(Y2Storage::AgamaProposal) + end - subject.on_calculate(&callback1) - subject.on_calculate(&callback2) + include_examples "check proposal callbacks", :calculate_agama, :achivable_config - expect(callback1).to_not receive(:call) - expect(callback2).to_not receive(:call) + include_examples "check proposal return", + :calculate_agama, :achivable_config, :impossible_config - subject.calculate_guided(achievable_settings) + include_examples "check early proposal", :calculate_agama, :achivable_config + end + + describe "#calculate_autoyast" do + let(:achivable_settings) do + [ + { + partitions: [ + { + mount: "/", + size: "10 GiB" + } + ] + } + ] + end + + let(:impossible_settings) do + [ + { + device: "/dev/sdb", + partitions: [ + { + mount: "/", + size: "10 GiB" + } + ] + } + ] + end + + it "calculates a proposal with the autoyast strategy and with the given settings" do + expect(Y2Storage::StorageManager.instance.proposal).to be_nil + + subject.calculate_autoyast(achivable_settings) + expect(Y2Storage::StorageManager.instance.proposal).to be_a(Y2Storage::AutoinstProposal) + end + + include_examples "check proposal callbacks", :calculate_autoyast, :achivable_settings + + include_examples "check proposal return", + :calculate_autoyast, :achivable_settings, :impossible_settings + + include_examples "check early proposal", :calculate_autoyast, :achivable_settings + end + + describe "#calculate_from_json" do + context "if the JSON contains storage guided settings" do + let(:config_json) do + { + storage: { + guided: { + target: { + disk: "/dev/vda" + } + } + } + } end - it "returns false" do - expect(subject.calculate_guided(achievable_settings)).to eq(false) + it "calculates a proposal with the guided strategy and with the expected settings" do + expect(subject).to receive(:calculate_guided) do |settings| + expect(settings).to be_a(Agama::Storage::ProposalSettings) + expect(settings.device.name).to eq("/dev/vda") + end + + subject.calculate_from_json(config_json) end end - end - describe "#settings" do - it "returns nil if calculate has not been called yet" do - expect(proposal.settings).to be_nil + context "if the JSON contains storage settings" do + let(:config_json) do + { + storage: { + drives: [ + { + filesystem: { + type: "xfs" + } + } + ] + } + } + end + + it "calculates a proposal with the agama strategy and with the expected config" do + expect(subject).to receive(:calculate_agama) do |config| + expect(config).to be_a(Agama::Storage::Config) + expect(config.drives.size).to eq(1) + + drive = config.drives.first + expect(drive.filesystem.type.fs_type).to eq(Y2Storage::Filesystems::Type::XFS) + end + + subject.calculate_from_json(config_json) + end end - context "if the proposal was already calculated" do - before do - subject.calculate_guided(achievable_settings) + context "if the JSON contains autoyast settings" do + let(:config_json) do + { + legacyAutoyastStorage: [ + { + partitions: [ + { + mount: "/", + size: "10 GiB" + } + ] + } + ] + } end - it "returns the settings used for calculating the proposal" do - expect(subject.settings).to be_a(Agama::Storage::ProposalSettings) + it "calculates a proposal with the autoyast strategy and with the given settings" do + expect(subject).to receive(:calculate_autoyast) do |settings| + expect(settings).to eq(config_json[:legacyAutoyastStorage]) + end - expect(subject.settings).to have_attributes( - device: an_object_having_attributes(name: "/dev/sdb"), - volumes: contain_exactly( - an_object_having_attributes(mount_path: "/") - ) - ) + subject.calculate_from_json(config_json) + end + end + + context "if the JSON does not contain any of the storage settings" do + let(:config_json) { {} } + + it "raises an error" do + expect { subject.calculate_from_json(config_json) }.to raise_error(/Invalid storage/) end end end @@ -215,7 +539,7 @@ context "if the proposal failed" do before do - subject.calculate_guided(impossible_settings) + subject.calculate_agama(impossible_config) end it "returns an empty list" do @@ -225,12 +549,12 @@ context "if the proposal was successful" do before do - subject.calculate_guided(achievable_settings) + subject.calculate_agama(achivable_config) end it "returns the actions from the actiongraph" do expect(proposal.actions).to include( - an_object_having_attributes(text: /Create partition \/dev\/sdb1/) + an_object_having_attributes(text: /Create partition \/dev\/sda2/) ) end end @@ -242,21 +566,37 @@ end it "returns an empty list if the current proposal is successful" do - subject.calculate_guided(achievable_settings) + subject.calculate_agama(achivable_config) expect(subject.issues).to eq([]) end context "if the current proposal is failed" do - let(:settings) { impossible_settings } + let(:config) { impossible_config } - it "includes an error because the volumes cannot be accommodated" do - subject.calculate_guided(settings) + it "includes an error" do + subject.calculate_agama(config) expect(subject.issues).to include( - an_object_having_attributes(description: /Cannot accommodate/) + an_object_having_attributes(description: /A problem ocurred/) ) end + end + + context "if the proposal was calculated with the guided strategy" do + before do + mock_storage(devicegraph: "partitioned_md.yml") + end + + let(:impossible_settings) do + Agama::Storage::ProposalSettings.new.tap do |settings| + settings.device.name = "/dev/sdb" + settings.volumes = [ + # The boot disk size is 500 GiB, so it cannot accomodate a 1 TiB volume. + Agama::Storage::Volume.new("/").tap { |v| v.min_size = Y2Storage::DiskSize.TiB(1) } + ] + end + end context "and the settings does not indicate a target device" do before do @@ -297,4 +637,64 @@ end end end + + describe "#guided?" do + context "if no proposal has been calculated yet" do + it "returns false" do + expect(subject.calculated?).to eq(false) + expect(subject.guided?).to eq(false) + end + end + + context "if the proposal was calculated with the guided strategy" do + before do + settings = Agama::Storage::ProposalSettings.new + subject.calculate_guided(settings) + end + + it "returns true" do + expect(subject.guided?).to eq(true) + end + end + + context "if the proposal was calculated with any other strategy" do + before do + subject.calculate_agama(achivable_config) + end + + it "returns false" do + expect(subject.guided?).to eq(false) + end + end + end + + describe "#guided_settings" do + context "if no proposal has been calculated yet" do + it "returns nil" do + expect(subject.calculated?).to eq(false) + expect(subject.guided_settings).to be_nil + end + end + + context "if the proposal was calculated with the guided strategy" do + before do + settings = Agama::Storage::ProposalSettings.new + subject.calculate_guided(settings) + end + + it "returns the guided settings" do + expect(subject.guided_settings).to be_a(Agama::Storage::ProposalSettings) + end + end + + context "if the proposal was calculated with any other strategy" do + before do + subject.calculate_agama(achivable_config) + end + + it "returns nil" do + expect(subject.guided_settings).to be_nil + end + end + end end diff --git a/service/test/agama/storage/proposal_volumes_test.rb b/service/test/agama/storage/proposal_volumes_test.rb index fa055227b6..1638d997d1 100644 --- a/service/test/agama/storage/proposal_volumes_test.rb +++ b/service/test/agama/storage/proposal_volumes_test.rb @@ -157,7 +157,7 @@ def expect_proposal_with_specs(*specs) it "returns settings with a set of volumes with adjusted sizes" do proposal.calculate_guided(settings) - expect(proposal.settings.volumes).to contain_exactly( + expect(proposal.guided_settings.volumes).to contain_exactly( an_object_having_attributes( mount_path: "/", auto_size: true, @@ -198,7 +198,7 @@ def expect_proposal_with_specs(*specs) it "returns settings with a set of volumes with adjusted sizes" do proposal.calculate_guided(settings) - expect(proposal.settings.volumes).to contain_exactly( + expect(proposal.guided_settings.volumes).to contain_exactly( an_object_having_attributes( mount_path: "/", auto_size: true, @@ -240,7 +240,7 @@ def expect_proposal_with_specs(*specs) it "returns settings with a set of volumes with fixed limits and adjusted sizes" do proposal.calculate_guided(settings) - expect(proposal.settings.volumes).to contain_exactly( + expect(proposal.guided_settings.volumes).to contain_exactly( an_object_having_attributes( mount_path: "/", btrfs: an_object_having_attributes(snapshots?: true), @@ -281,7 +281,7 @@ def expect_proposal_with_specs(*specs) it "returns settings with a set of volumes with adjusted sizes" do proposal.calculate_guided(settings) - expect(proposal.settings.volumes).to contain_exactly( + expect(proposal.guided_settings.volumes).to contain_exactly( an_object_having_attributes(mount_path: "/", auto_size: true), an_object_having_attributes( mount_path: "swap", @@ -330,7 +330,7 @@ def expect_proposal_with_specs(*specs) it "returns settings with a set of volumes with fixed limits and adjusted sizes" do proposal.calculate_guided(settings) - expect(proposal.settings.volumes).to contain_exactly( + expect(proposal.guided_settings.volumes).to contain_exactly( an_object_having_attributes( mount_path: "/", btrfs: an_object_having_attributes(snapshots?: false), diff --git a/service/test/fixtures/partitioned_disk.yaml b/service/test/fixtures/partitioned_disk.yaml new file mode 100644 index 0000000000..bfe5219833 --- /dev/null +++ b/service/test/fixtures/partitioned_disk.yaml @@ -0,0 +1,23 @@ +--- +- disk: + name: "/dev/vda" + size: 50 GiB + partition_table: gpt + partitions: + - partition: + size: 2 MiB + name: "/dev/vda1" + id: bios_boot + - partition: + size: 20 GiB + name: "/dev/vda2" + id: linux + file_system: btrfs + - partition: + size: 10 GiB + name: "/dev/vda3" + id: linux + file_system: xfs +- disk: + name: "/dev/vdb" + size: 50 GiB diff --git a/service/test/y2storage/agama_proposal_test.rb b/service/test/y2storage/agama_proposal_test.rb new file mode 100644 index 0000000000..1260e9e6bd --- /dev/null +++ b/service/test/y2storage/agama_proposal_test.rb @@ -0,0 +1,574 @@ +# 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 "../agama/storage/storage_helpers" +require "agama/config" +require "agama/storage/config" +require "y2storage/agama_proposal" + +describe Y2Storage::AgamaProposal do + include Agama::RSpec::StorageHelpers + + let(:initial_settings) do + Agama::Storage::Config.new.tap do |settings| + settings.drives = drives + end + end + + let(:issues_list) { [] } + + let(:drives) { [drive0] } + + let(:drive0) { Agama::Storage::Configs::Drive.new.tap { |d| d.partitions = partitions0 } } + + let(:partitions0) { [root_partition] } + + let(:root_partition) do + Agama::Storage::Configs::Partition.new.tap do |part| + part.filesystem = Agama::Storage::Configs::Filesystem.new.tap do |fs| + fs.path = "/" + fs.type = Agama::Storage::Configs::FilesystemType.new.tap do |type| + type.fs_type = Y2Storage::Filesystems::Type::BTRFS + end + end + part.size = Agama::Storage::Configs::Size.new.tap do |size| + size.min = Y2Storage::DiskSize.GiB(8.5) + size.max = Y2Storage::DiskSize.unlimited + end + end + end + + let(:home_partition) do + Agama::Storage::Configs::Partition.new.tap do |part| + part.filesystem = Agama::Storage::Configs::Filesystem.new.tap do |fs| + fs.path = "/home" + fs.type = Agama::Storage::Configs::FilesystemType.new.tap do |type| + type.fs_type = Y2Storage::Filesystems::Type::EXT4 + end + end + part.size = Agama::Storage::Configs::Size.new.tap do |size| + size.min = Y2Storage::DiskSize.GiB(10) + size.max = Y2Storage::DiskSize.unlimited + end + end + end + + before do + mock_storage(devicegraph: scenario) + end + + subject(:proposal) do + described_class.new(initial_settings, issues_list: issues_list) + end + + let(:scenario) { "empty-hd-50GiB.yaml" } + + describe "#propose" do + context "when only the root partition is specified" do + context "if no configuration about boot devices is specified" do + it "proposes to create the root device and the boot-related partition" do + proposal.propose + partitions = proposal.devices.partitions + expect(partitions.size).to eq 2 + expect(partitions.first.id).to eq Y2Storage::PartitionId::BIOS_BOOT + root_part = partitions.last + expect(root_part.size).to be > Y2Storage::DiskSize.GiB(49) + root_fs = root_part.filesystem + expect(root_fs.root?).to eq true + expect(root_fs.type.is?(:btrfs)).to eq true + end + end + + context "if no boot devices should be created" do + before do + initial_settings.boot = Agama::Storage::Configs::Boot.new.tap { |b| b.configure = false } + end + + it "proposes to create only the root device" do + proposal.propose + partitions = proposal.devices.partitions + expect(partitions.size).to eq 1 + root_part = partitions.first + expect(root_part.id).to eq Y2Storage::PartitionId::LINUX + expect(root_part.size).to be > Y2Storage::DiskSize.GiB(49) + root_fs = root_part.filesystem + expect(root_fs.root?).to eq true + expect(root_fs.type.is?(:btrfs)).to eq true + end + end + end + + context "when the config has 2 drives" do + let(:scenario) { "partitioned_disk.yaml" } + + let(:drives) { [drive0, drive1] } + + let(:drive1) do + Agama::Storage::Configs::Drive.new.tap { |d| d.partitions = [home_partition] } + end + + it "proposes the expected devices" do + devicegraph = proposal.propose + + root = devicegraph.find_by_name("/dev/vda4") + expect(root.filesystem.mount_path).to eq("/") + + home = devicegraph.find_by_name("/dev/vdb1") + expect(home.filesystem.mount_path).to eq("/home") + end + end + + context "when a partition table type is specified for a drive" do + let(:drive0) do + Agama::Storage::Configs::Drive.new.tap do |drive| + drive.partitions = partitions0 + drive.ptable_type = Y2Storage::PartitionTables::Type::MSDOS + end + end + + it "tries to propose a partition table of the requested type" do + proposal.propose + ptable = proposal.devices.disks.first.partition_table + expect(ptable.type).to eq Y2Storage::PartitionTables::Type::MSDOS + end + + it "honors the partition table type if possible when calculating the boot partitions" do + proposal.propose + partitions = proposal.devices.partitions + expect(partitions.map(&:id)).to_not include Y2Storage::PartitionId::BIOS_BOOT + end + end + + context "when encrypting some devices" do + let(:partitions0) { [root_partition, home_partition] } + + let(:home_encryption) do + Agama::Storage::Configs::Encryption.new.tap do |enc| + enc.password = "notSecreT" + enc.method = encryption_method + end + end + + let(:encryption_method) { Y2Storage::EncryptionMethod::LUKS2 } + let(:available?) { true } + + before do + allow(encryption_method).to receive(:available?).and_return(available?) if encryption_method + home_partition.encryption = home_encryption + end + + context "if the encryption settings contain all the detailed information" do + let(:home_encryption) do + Agama::Storage::Configs::Encryption.new.tap do |enc| + enc.password = "notSecreT" + enc.method = encryption_method + enc.pbkd_function = Y2Storage::PbkdFunction::ARGON2I + enc.label = "luks_label" + enc.cipher = "aes-xts-plain64" + enc.key_size = 512 + end + end + + it "proposes the right encryption layer" do + proposal.propose + partition = proposal.devices.partitions.find do |part| + part.blk_filesystem&.mount_path == "/home" + end + expect(partition.encrypted?).to eq true + expect(partition.encryption).to have_attributes( + method: Y2Storage::EncryptionMethod::LUKS2, + password: "notSecreT", + pbkdf: Y2Storage::PbkdFunction::ARGON2I, + label: "luks_label", + cipher: "aes-xts-plain64", + # libstorage-ng uses bytes instead of bits to represent the key size, contrary to + # all LUKS documentation and to cryptsetup + key_size: 64 + ) + end + end + + context "if the encryption method is not available for this system" do + let(:available?) { false } + + it "aborts the proposal process" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "reports the corresponding error" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /method 'luks2' is not available/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + + context "if the encryption method is not available for this system" do + let(:encryption_method) { Y2Storage::EncryptionMethod::RANDOM_SWAP } + + it "aborts the proposal process" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "reports the corresponding error" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /'random_swap' is not a suitable method/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + + context "if the method requires a password but none is provided" do + let(:home_encryption) do + Agama::Storage::Configs::Encryption.new.tap do |enc| + enc.method = encryption_method + end + end + + it "aborts the proposal process" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "reports the corresponding error" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /No passphrase provided/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + + context "when there are more drives than disks in the system" do + let(:drives) { [drive0, drive1] } + let(:drive1) do + Agama::Storage::Configs::Drive.new.tap do |drive| + drive.search = Agama::Storage::Configs::Search.new.tap do |search| + search.if_not_found = if_not_found + end + end + end + + context "if if_not_found is set to :skip for the surplus drive" do + let(:if_not_found) { :skip } + + it "calculates a proposal if possible" do + proposal.propose + expect(proposal.failed?).to eq false + end + + it "registers a non-critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /optional drive/, + severity: Agama::Issue::Severity::WARN + ) + end + end + + context "if if_not_found is set to :error for the surplus drive" do + let(:if_not_found) { :error } + + it "aborts the proposal" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "registers a critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /mandatory drive/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + + context "when searching for an existent drive" do + let(:scenario) { "partitioned_disk.yaml" } + + before do + drive0.search.name = "/dev/vdb" + end + + it "uses the drive" do + proposal.propose + + root = proposal.devices.partitions.find do |part| + part.filesystem&.mount_path == "/" + end + + expect(root.disk.name).to eq("/dev/vdb") + end + end + + context "when searching for any drive" do + let(:scenario) { "partitioned_disk.yaml" } + + let(:drives) { [drive0, drive1] } + + let(:drive0) do + Agama::Storage::Configs::Drive.new.tap { |d| d.partitions = [root_partition] } + end + + let(:drive1) do + Agama::Storage::Configs::Drive.new.tap { |d| d.partitions = [home_partition] } + end + + it "uses the first unassigned drive" do + proposal.propose + + root = proposal.devices.partitions.find do |part| + part.filesystem&.mount_path == "/" + end + + home = proposal.devices.partitions.find do |part| + part.filesystem&.mount_path == "/home" + end + + expect(root.disk.name).to eq("/dev/vda") + expect(home.disk.name).to eq("/dev/vdb") + end + end + + context "when searching for a missing partition" do + let(:partitions0) { [root_partition, missing_partition] } + let(:missing_partition) do + Agama::Storage::Configs::Partition.new.tap do |part| + part.search = Agama::Storage::Configs::Search.new.tap do |search| + search.if_not_found = if_not_found + end + end + end + + context "if if_not_found is set to :skip" do + let(:if_not_found) { :skip } + + it "calculates a proposal if possible" do + proposal.propose + expect(proposal.failed?).to eq false + end + + it "registers a non-critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /optional partition/, + severity: Agama::Issue::Severity::WARN + ) + end + end + + context "if if_not_found is set to :error" do + let(:if_not_found) { :error } + + it "aborts the proposal" do + proposal.propose + expect(proposal.failed?).to eq true + end + + it "registers a critical issue" do + proposal.propose + expect(proposal.issues_list).to include an_object_having_attributes( + description: /mandatory partition/, + severity: Agama::Issue::Severity::ERROR + ) + end + end + end + + context "when searching for an existent partition" do + let(:scenario) { "partitioned_disk.yaml" } + + let(:partitions0) { [root_partition, home_partition] } + + before do + home_partition.search = Agama::Storage::Configs::Search.new.tap do |search| + search.name = "/dev/vda3" + end + end + + it "reuses the partition" do + vda3 = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3") + proposal.propose + + partition = proposal.devices.find_by_name("/dev/vda3") + expect(partition.sid).to eq(vda3.sid) + expect(partition.filesystem.mount_path).to eq("/home") + end + end + + context "when searching for any partition" do + let(:scenario) { "partitioned_disk.yaml" } + + let(:partitions0) { [root_partition, home_partition] } + + before do + home_partition.search = Agama::Storage::Configs::Search.new + end + + # TODO: Is this correct? The first partition (boot partition) is reused for home. + it "reuses the first unassigned partition" do + vda1 = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda1") + proposal.propose + + partition = proposal.devices.find_by_name("/dev/vda1") + expect(partition.sid).to eq(vda1.sid) + expect(partition.filesystem.mount_path).to eq("/home") + end + + it "does not reuse the same partition twice" do + vda1 = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda1") + vda2 = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda2") + root_partition.search = Agama::Storage::Configs::Search.new + proposal.propose + + root = proposal.devices.find_by_name("/dev/vda1") + expect(root.sid).to eq(vda1.sid) + expect(root.filesystem.mount_path).to eq("/") + + home = proposal.devices.find_by_name("/dev/vda2") + expect(home.sid).to eq(vda2.sid) + expect(home.filesystem.mount_path).to eq("/home") + end + end + + def partition_config(name) + Agama::Storage::Configs::Partition.new.tap do |partition_config| + partition_config.search = Agama::Storage::Configs::Search.new.tap do |search_config| + search_config.name = name + end + end + end + + context "forcing to delete some partitions" do + let(:scenario) { "partitioned_disk.yaml" } + + let(:partitions0) { [root_partition, vda2, vda3] } + + let(:vda2) do + partition_config("/dev/vda2").tap { |c| c.delete = true } + end + + let(:vda3) do + partition_config("/dev/vda3").tap { |c| c.delete = true } + end + + before do + drive0.search.name = "/dev/vda" + end + + it "deletes the partitions" do + vda1_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda1").sid + vda2_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda2").sid + vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid + + devicegraph = proposal.propose + + expect(devicegraph.find_device(vda1_sid)).to_not be_nil + expect(devicegraph.find_device(vda2_sid)).to be_nil + expect(devicegraph.find_device(vda3_sid)).to be_nil + + root = devicegraph.find_by_name("/dev/vda2") + expect(root.filesystem.mount_path).to eq("/") + end + end + + context "allowing to delete some partition" do + let(:scenario) { "partitioned_disk.yaml" } + + let(:partitions0) { [root_partition, vda3] } + + let(:vda3) do + partition_config("/dev/vda3").tap { |c| c.delete_if_needed = true } + end + + before do + # vda has 18 GiB of free space. + drive0.search.name = "/dev/vda" + end + + context "if deleting the partition is not needed" do + before do + root_partition.size.min = Y2Storage::DiskSize.GiB(15) + end + + it "does not delete the partition" do + vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid + + devicegraph = proposal.propose + expect(devicegraph.find_device(vda3_sid)).to_not be_nil + + root = devicegraph.find_by_name("/dev/vda4") + expect(root.filesystem.mount_path).to eq("/") + end + end + + context "if the partition has to be deleted" do + before do + root_partition.size.min = Y2Storage::DiskSize.GiB(20) + end + + it "deletes the partition" do + vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid + + devicegraph = proposal.propose + expect(devicegraph.find_device(vda3_sid)).to be_nil + + root = devicegraph.find_by_name("/dev/vda3") + expect(root.filesystem.mount_path).to eq("/") + end + end + end + + # Testing precedence. This configuration should not be possible. + context "if the partition config indicates both force to delete and allow to delete" do + let(:scenario) { "partitioned_disk.yaml" } + + let(:partitions0) { [root_partition, vda3] } + + let(:vda3) do + partition_config("/dev/vda3").tap do |config| + config.delete = true + config.delete_if_needed = true + end + end + + before do + drive0.search.name = "/dev/vda" + end + + it "deletes the partition" do + vda3_sid = Y2Storage::StorageManager.instance.probed.find_by_name("/dev/vda3").sid + + devicegraph = proposal.propose + expect(devicegraph.find_device(vda3_sid)).to be_nil + + root = devicegraph.find_by_name("/dev/vda3") + expect(root.filesystem.mount_path).to eq("/") + end + end + end +end diff --git a/setup-services.sh b/setup-services.sh index 3c6534ab29..5e3f82e8e1 100755 --- a/setup-services.sh +++ b/setup-services.sh @@ -51,6 +51,7 @@ $SUDO $ZYPPER install \ # TODO extract list from gem2rpm.yml $SUDO $ZYPPER install \ dbus-1-common \ + dbus-1-daemon \ suseconnect-ruby-bindings \ autoyast2-installation \ yast2 \ @@ -115,7 +116,13 @@ fi sed -e '/gemspec/a gem "ruby-dbus", path: "/checkout-ruby-dbus"' -i Gemfile fi - bundle config set --local path 'vendor/bundle' + if [ -n "$CI" ]; then + # in CI reuse the pre-installed system gems from RPMs + bundle config set --local disable_shared_gems 0 + else + bundle config set --local path 'vendor/bundle' + fi + bundle install ) @@ -182,6 +189,9 @@ $SUDO cp -v $MYDIR/service/share/dbus.conf /usr/share/dbus-1/agama.conf $SUDO mkdir -p /usr/share/agama/products.d $SUDO cp -f $MYDIR/products.d/*.yaml /usr/share/agama/products.d +# - Make sure NetworkManager is running +$SUDO systemctl start NetworkManager + # systemd reload and start of service ( $SUDO systemctl daemon-reload @@ -190,6 +200,3 @@ $SUDO cp -f $MYDIR/products.d/*.yaml /usr/share/agama/products.d # Start the web server $SUDO systemctl start agama-web-server.service ) - -# - Make sure NetworkManager is running -$SUDO systemctl start NetworkManager diff --git a/web/po/ca.po b/web/po/ca.po index a5ee913a8a..1fdca856a7 100644 --- a/web/po/ca.po +++ b/web/po/ca.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-08-08 02:27+0000\n" +"POT-Creation-Date: 2024-08-18 02:29+0000\n" "PO-Revision-Date: 2024-07-25 08:46+0000\n" "Last-Translator: David Medina \n" "Language-Team: Catalan