diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 8b053d6238..138de166bf 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -71,6 +71,7 @@ jobs: run: zypper --non-interactive install clang-devel dbus-1-daemon + golang-github-google-jsonnet jq libopenssl-3-devel openssl-3 diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 2862c6f339..69794c4444 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -52,7 +52,7 @@ jobs: run: cd web && npm ci - name: Build Web UI documentation - run: cd web && npm run typedoc:client && mv typedoc.out/ ../doc/dist/web-ui + run: cd web && npm run typedoc && mv typedoc.out/ ../doc/dist/web-ui - name: Setup Pages uses: actions/configure-pages@v3 diff --git a/doc/auto_storage.md b/doc/auto_storage.md deleted file mode 100644 index 4d0831e54e..0000000000 --- a/doc/auto_storage.md +++ /dev/null @@ -1,1161 +0,0 @@ -# Storage Section of the Agama Profile - -This document describes Agama's approach to configure storage using a profile for unattended -installation. - -## Agama and AutoYaST - -The Agama profile has a special `legacyAutoyastStorage` section which is a 1:1 representation of the -XML AutoYaST profile. This section supports everything offered by the *partitioning* AutoYaST -section. Note that Agama does not validate this special section, so be careful to provide valid -AutoYaST options. - -~~~json -{ - "legacyAutoyastStorage": [ - { - "use": "all", - "partitions": [] - } - ] -} -~~~ - -Although that special section is offered for backwards compatibility and to ease gradual migration -from AutoYaST to Agama, there are no plans to introduce any improvement or new feature in the legacy -mode due to the [intrinsic limitations](./autoyast_storage.md) of the original AutoYaST schema. - -### Implementation Considerations for AutoYaST Specification - -In principle, implementing the legacy AutoYaST module is as simple as converting the corresponding -section of the profile into a `Y2Storage::PartitioningSection` object and use -`Y2Storage::AutoInstProposal` to calculate the result. - -But there are some special cases in which AutoYaST fallbacks to read some settings from the YaST -settings or to use some YaST mechanisms. Those cases should be taken into account during the -implementation. - -For example, AutoYaST relies on the traditional YaST proposal settings when "auto" is used to -specify the size of a partition or to determine the default list of subvolumes when Btrfs is used. -See also the sections "Automatic Partitioning" and "Guided Partitioning" at the AutoYaST -documentation for situations in which AutoYaST uses the standard YaST `GuidedProposal` as fallback. - -## The New Storage Schema - -Agama offers its own storage schema which is more semantic, comprehensive and flexible than the -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. - -``` -Storage - drives - volumeGroups - mdRaids - btrfsRaids - nfsMounts - boot [BootSettings] - encryption [EncryptionSettings] -``` - -Thus, a `storage` section can contain several entries describing how to configure the corresponding -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 -that can be used as regular disks. That includes removable and fixed disks, SD cards, DASD or zFCP -devices, iSCSI disks, multipath devices, etc. Those entries always correspond to devices that can be -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. - -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 [] - alias [] - encryption [] - filesystem [] - ptableType [] - partitions [] - -VolumeGroup - search [] - alias [] - name [] - peSize [] - physicalVolumes [[]>] - logicalVolumes [] - delete [] - -MdRaid - search [] - alias [] - name - level [] - chunkSize [] - devices [<[]>] - size [] - encryption [] - filesystem [Filesystem] - ptableType [] - partitions [] - delete [] - -BtrfsRaid - search [] - alias [] - dataRaidLevel - metadataRaidLevel - devices [<[]>] - label [] - mkfsOptions [] - [Btrfs] - delete [] - -NFS - alias [] - path [] - mount [] - -Partition - search [] - alias [] - id [] - size [] - encryption [Encryption] - filesystem [] - delete [] - deleteIfNeeded [] - -LogicalVolume - search [] - alias [] - name [] - size [] - pool [] - usedPool [] - stripes [] - stripSize [] - encryption [Encryption] - filesystem [] - delete [] - deleteIfNeeded [] -Encryption - reuse - type - -EncryptionType - -EncryptionLUKS1 - password - keySize [] - cipher [] - -EncryptionLUKS2 - password - keySize [] - cipher [] - pdkdf [] - label [] - -EncryptionPervasiveLUKS2 - password - -Filesystem - reuse - type - label [] - mkfsOptions [] - path - mountOptions [] - mountBy [] - -Btrfs - subvolumePrefix [] - subvolumes [] - snapshots [] - quotas [] - -Size - -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 -the system is partitioned and a volume group is created on top of that partition (after encrypting -it) to allocate two file systems. - -```json -"storage": { - "drives": [ - { - "partitions": [ - { - "alias": "pv", - "id": "lvm", - "size": { "min": "12 GiB" }, - "encryption": { - "luks2": { "password": "my secret passphrase" } - } - } - ] - } - ], - "volumeGroups": [ - { - "name": "system", - "physicalVolumes": [ "pv" ], - "logicalVolumes": [ - { - "size": { "min": "10 GiB" }, - "filesystem": { "path": "/", "type": "btrfs" } - }, - { - "size": "2 GiB", - "filesystem": { "path": "swap", "type": "swap" } - } - ] - } - ] -} -``` - -### 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. - -The most straightforward one is just using a string that can be parsed into a valid size. - -The second option is to provide a minimum size and an optional maximum one. The resulting size will -be between those thresholds. If the maximum is omitted or set to `null`, the device will grow as -much as possible, taking into account the available spaces and all the other specified sizes. - -It is also possible to specify "current" as a minimum or maximum size limit for partitions and -logical volumes that already exist in the system (so "current" can only be used for device -specifications that contain a `search` section). The usage of "current" and how it affects -resizing the corresponding devices is explained at a separate section below. - -If the size is completely omitted for a device that already exists (ie. combined with `search`), -then Agama would act as if both min and max limits would have been set to "current" (which implies -the partition or logical volume will not be resized). - -Moreover, if the size is omitted for a device that will be created, Agama will determine the size -limits when possible. There are basically two kinds of situations in which that automatic size -calculation can be performed. - -On the one hand, the device may directly contain a `filesystem` entry specifying a mount point. -Agama will then use the settings of the product to set the size limits. From a more technical point -of view, that translates into the following: - - - If the mount path corresponds to a volume supporting `auto_size`, that feature will be used. - - If it corresponds to a volume without `auto_size`, the min and max sizes of the volumes will be - used. - - If there is no volume for that mount path, the sizes of the default volume will be used. - - If the product does not specify a default volume, the behavior is still not defined (there are - several reasonable options). - -On the other hand, the size limits of some devices can be omitted if they can be inferred from other -related devices following some rules. - - - For an MD RAID defined on top of new partitions, it is possible to specify the size of all the - partitions that will become members of the RAID but is also possible to specify the desired size - for the resulting MD RAID and then the size limits of each partition will be automatically - inferred with a small margin of error of a few MiBs. - - Something similar happens with a partition that acts as the **only** physical volume of a new LVM - volume group. Specifying the sizes of the logical volumes could be enough, the size limits of the - underlying partition will match the necessary values to make the logical volumes fit. In this - case the calculated partition size is fully accurate. - - The two previous scenarios can be combined. For a new MD RAID that acts as the **only** physical - volume of a new LVM volume group, the sizes of the logical volumes can be used to precisely - determine what should be the size of the MD and, based on that, what should be the almost - exact size of the underlying new partitions defined to act as members of the RAID. - -The two described mechanisms to automatically determine size limits can be combined. Even creating -a configuration with no explicit sizes at all like the following example. - -```json -"storage": { - "drives": [ - { - "partitions": [ - { "alias": "pv" } - ] - } - ], - "volumeGroups": [ - { - "name": "system", - "physicalVolumes": [ "pv" ], - "logicalVolumes": [ - { "filesystem": { "path": "/" } }, - { "filesystem": { "path": "swap" } } - ] - } - ] -} -``` - -Assuming the product configuration specifies a root filesystem with a minimum size of 5 GiB and a -max of 20 GiB and sets that the swap must have a size equivalent to the RAM on the system, then -those values would be applied to the logical volumes and the partition with alias "pv" would be -sized accordingly, taking into account all the overheads and roundings introduced by the different -technologies like LVM or the used partition table type. - -#### Under Discussion - -As explained, it should be possible to specify the sizes as a fixed value or as a range. But a -a parseable string like "40 GiB" may not be the only option to represent a size or a range limit. -The following two possibilities are also under consideration. - - - `{ "gib": 40 }` - - `{ "value": 40, "units": "gib" }` - -### 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 -from the system. - -If a description matches several devices, the same operations will be applied to -all. That's useful in several situations like applying the same partitioning schema to several disks -or deleting all partitions of a disk that match a given criteria. - -Matching is performed using a `search` subsection. The format is still under heavy discussion but -may look similar to this. - -``` -Search - condition [] - sort [] - max [] - ifNotFound [] - -Condition - -OperatorAnd - and: - -OperatorOr - or: - -Rule - property - value - operator [] - -Operator <'equal'|'notEqual'|'less'|'greater'|'lessOrEqual'|'greaterOrEqual'> - -Sort - property - order <'asc'|'desc'> - -NotFoundAction <'create'|'skip'|'error'> -``` - -By default, all devices in the scope fitting the conditions will be matched. The number of device -matches can be limited using `max`. The following example shows how several `search` sections could -be used to find the three biggest disks in the system, delete all linux partitions bigger than 1 GiB -within them and create new partitions of type RAID. - -```json -"storage": { - "drives": [ - { - "search": { - "sort": { "property": "sizeKib", "order": "desc" }, - "max": 3 - }, - "partitions": [ - { - "search": { - "condition": { - "and": [ - { "property": "id", "value": "linux" }, - { "property": "sizeGib", "value": 1, "operator": "greater" } - ] - } - }, - "delete": true - }, - { - "alias": "newRaidPart", - "id": "raid", - "size": { "min": "1 GiB" } - } - ] - } - ] -} -``` - -The example also serves to illustrate the scope of each search. That is, the devices from the system -that are considered as possible candidates. That obviously depends on the place in the profile of -the `search` section. A `search` section inside the description of an MD RAID will only match MD -devices and a `search` section inside the `partitions` subsection of that RAID description will only -match partitions of RAIDs that have matched the conditions of the most external `search`. - -A given device can never match two different sections of the Agama profile. When several sections -at the same level contain a search subsection, devices are matched in the order the sections appear -on the profile. - -```json -"storage": { - "drives": [ - { - "search": { - "sort": { "property": "sizeKib", "order": "desc" }, - "max": 1 - }, - "alias": "biggest" - }, - { - "search": { - "sort": { "property": "sizeKib", "order": "desc" }, - "max": 1 - }, - "alias": "secondBiggest" - } - ] -} -``` - -An empty search will match all devices in the scope, so the following example would delete all the -partitions of the chosen disk. - -```json -"storage": { - "drives": [ - { - "partitions": - { "search": {}, "delete": true } - } - ] -} -``` - -If there is not a single system device matching the scope and the conditions of a given search, then -`ifNotFound` comes into play. If the value is "skip", the device definition is ignored. If it's -"error" the whole process is aborted. The value "create", which cannot be used for a drive, will -cause the `search` section to be ignored if no device matches. As a consequence, a new logical -device (partition, LVM, etc.) will be created. - -Entries on `drives` are different to all other subsections describing devices because drives can -only be matched to existing devices, they cannot be simply created. If `search` is omitted for a -drive, it will be considered to contain the following one. - -```json -{ - "search": { - "sort": { "property": "name" }, - "max": 1, - "ifNotFound": "error" - } -} -``` - -#### 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. - -```json -{ "search": "/dev/sda" } -``` - -Using a string as value for `search` may also be useful in other situations. Special values could be -used as aliases for typical cases: - - - Empty string or "\*" to match all devices (the same than an empty section) - - Something like "next" to represent the default search for drives (see above) - -If a simple string like "next" could be used to specify the standard search entry for drives, it -would make sense to simply make `search` mandatory for all drives instead of assuming a default one. - -Another possible improvement for that string-based format would be supporting regular expressions. -That would make it possible to use searchers like this. - -```json -{ "search": ".*" } -``` - -But regular expressions would not play well with libstorage-ng. Since not all device names are -stored in the devicegraph, it is is necessary to use functions like `find_by_any_name` in order to -perform an exhaustive search by name. - -Another apect under discussion is the format to specify conditions. Instead of the format described -above, it would be possible to use the key as name of the property, resulting in something like this. - -```json -{ - "search": { - "condition": { "sizeGib": 1, "operator": "greater" } - } -} -``` - -### 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 -another entry of the Agama profile. - -Aliases can be used for that purpose as shown in this example. - -```json -"storage": { - "drives": [ - { - "partitions": - { "size": "50 GiB", "id": "lvm", "alias": "newPV" } - } - ], - "volumeGroups": [ - { - "name": "newVG", - "physicalVolumes": [ "newPV" ], - "logicalVolumes": [ { "name": "data", "size": "20 GiB" } ] - } - ] -} -``` - -If a section that matches several existing devices contains an alias, that alias will be considered -to be a reference to all the devices. As a consequence, this two examples are equivalent. - -```json -"storage": { - "drives": [ - { - "search": { - "sort": { "property": "sizeKib", "order": "desc" }, - "max": 1, - }, - "alias": "biggest" - }, - { - "search": { - "sort": { "property": "sizeKib", "order": "desc" }, - "max": 1, - }, - "alias": "secondBiggest" - } - ], - "mdRaids": [ - { - "devices": [ "biggest", "secondBiggest" ], - "level": "raid0" - } - ] -} - -"storage": { - "drives": [ - { - "search": { - "sort": { "property": "sizeKib", "order": "desc" }, - "max": 2, - "min": 2 - }, - "alias": "big" - } - ], - "mdRaids": [ - { - "devices": [ "big" ], - "level": "raid0" - } - ] -} -``` - -#### 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 -system (so the same conditions can be matched by a disk, a partition, an LVM device, etc.) and -`ifNotFound` could not be set to "create" (similar to what happens for drives in general). - -```json -"storage": { - "volume_groups": [ - { - "name": "newVG", - "physicalVolumes": [ - { "search": { "condition": { "property": "name", "value": "/dev/sda2" } } } - ], - "logicalVolumes": [ { "name": "data", "size": "20 GiB" } ] - } - ] -} -``` - -### 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). - -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. - -```json -"storage": { - "drives": [ - { - "partitions": [ - { - "search": { - "condition": { "property": "fsLabel", "value": "root" } - }, - "delete": true - }, - { "search": {}, "deleteIfNeeded": true }, - { "size": "30 GiB" } - ] - } - ] -} -``` - -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. - -In any case, note that resizing a partition can be limited depending on its content, the filesystem -type, etc. - -Combining `search` and `resize` is enough to indicate Agama is expected to resize a given partition -if possible. The keyword "current" can be used as min and/or max for the size range 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": { "min": "current", "max": "current" } - } - ] - } - ] -} -``` - -Other combinations can be used 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 - } - ] - } - ] -} -``` - -### Generating Partitions as MD RAID members - -MD arrays can be configured to explicitly use a set of devices by adding their aliases to the -`devices` property. - -```json -"storage": { - "drives": [ - { - "search": "/dev/sda", - "partitions": [ - { "alias": "sda-40", "size": "40 GiB" } - ] - }, - { - "search": "/dev/sdb", - "partitions": [ - { "alias": "sdb-40", "size": "40 GiB" } - ] - } - ], - "mdRaids": [ - { - "devices": [ "sda-40", "sdb-40" ], - "level": "raid0" - } - ] -} -``` - -The partitions acting as members can be automatically generated by simply indicating the target -disks that will hold the partitions. For that, the `devices` section must contain a `generate` -entry. - -```json -"storage": { - "drives": [ - { "search": "/dev/sda", "alias": "sda" }, - { "search": "/dev/sdb", "alias": "sdb" }, - ], - "mdRaids": [ - { - "devices": [ - { - "generate": { - "targetDisks": ["sda", "sdb" ], - "size": "40 GiB" - } - } - ] - "level": "raid0" - } - ] -} -``` - -As explained at the section about sizes, it's also possible to set the size for the new RAID letting -Agama calculate the corresponding sizes of the partitions used as members. That allows to use the -short syntax for `generate`. - -```json -"storage": { - "drives": [ - { "search": "/dev/sda", "alias": "sda" }, - { "search": "/dev/sdb", "alias": "sdb" }, - ], - "mdRaids": [ - { - "devices": [ { "generate": ["sda", "sdb" ] } ], - "level": "raid0", - "size": "40 GiB" - } - ] -} -``` - -### Generating Default Volumes - -Every product provides a configuration which defines the storage volumes (e.g., feasible file -systems for root, default partitions to create, etc). The default or mandatory product volumes can -be automatically generated by using a *generate* section in the *partitions* or *logicalVolumes* -sections. - -```json -"storage": { - "drives": [ - { - "partitions": [ - { "generate": "default" } - ] - } - ] -} - -``` - -The *generate* section allows creating the product volumes without explicitly writing all of them. -The config above would be equivalent to something like this: - -```json -"storage": { - "drives": [ - { - "partitions": [ - { "filesystem": { "path": "/" } }, - { "filesystem": { "path": "/home" } }, - { "filesystem": { "path": "swap" } } - ] - } - ] -} - -``` - -If any path is explicitly defined, then the *generate* section will not generate a volume for it. -For example, with the following config only root and swap would be automatically added: - -```json -"storage": { - "drives": [ - { - "partitions": [ - { "generate": "default" }, - { "filesystem": { "path": "/home" } } - ] - } - ] -} -``` - -The auto-generated volumes can be also configured. For example, for encrypting the partitions: - -```json -"storage": { - "drives": [ - { - "partitions": [ - { - "generate": { - "partitions": "default", - "encryption": { - "luks1": { "password": "12345" } - } - } - } - ] - } - ] -} -``` - -The *mandatory* keyword can be used for only generating the mandatory partitions or logical volumes: - -```json -"storage": { - "volumeGroups": [ - { - "name": "system", - "logicalVolumes": [ - { "generate": "mandatory" } - ] - } - ] -} -``` - -The *default* and *mandatory* keywords can also be used to generate a set of formatted MD arrays. -Assuming the default volumes are "/", "/home" and "swap", the following snippet would generate three -RAIDs of the appropriate sizes and the corresponding six partitions needed to support them. - -```json -"storage": { - "drives": [ - { "search": "/dev/sda", "alias": "sda" }, - { "search": "/dev/sdb", "alias": "sdb" }, - ], - "mdRaids": [ - { - "generate": { - "mdRaids": "default", - "level": "raid0", - "devices": [ - { "generate": ["sda", "sdb"] } - ] - } - } - ] -} -``` - -### Generating Physical Volumes - -Volume groups can be configured to explicitly use a set of devices as physical volumes. The aliases -of the devices to use are added to the list of physical volumes: - -```json -"storage": { - "drives": [ - { - "search": "/dev/vda", - "partitions": [ - { "alias": "pv2", "size": "100 GiB" }, - { "alias": "pv1", "size": "20 GiB" } - ] - } - ], - "volumeGroups": [ - { - "name": "system", - "physicalVolumes": ["pv1", "pv2"] - } - ] -} -``` - -The physical volumes can be automatically generated too, by simply indicating the target devices in -which to create the partitions. For that, a *generate* section is added to the list of physical -volumes: - -```json -"storage": { - "drives": [ - { - "search": "/dev/vda", - "alias": "pvs-disk" - } - ], - "volumeGroups": [ - { - "name": "system", - "physicalVolumes": [ - { "generate": ["pvs-disk"] } - ] - } - ] -} -``` - -If the auto-generated physical volumes have to be encrypted, then the encryption config is added to -the *generate* section: - - -```json -"storage": { - "drives": [ - { - "search": "/dev/vda", - "alias": "pvs-disk" - } - ], - "volumeGroups": [ - { - "name": "system", - "physicalVolumes": [ - { - "generate": { - "targetDevices": ["pvs-disk"], - "encryption": { - "luks2": { "password": "12345" } - } - } - } - ] - } - ] -} -``` - -### 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 - device [TargetDevice] - boot [BootSettings] - encryption [EncryptionSettings] - space <'delete'|'resize'|'keep'> - volumes [Volume[]] - -TargetDevice - -TargetDisk - disk - -TargetNewLvm - newLvmVg - -TargetReusedLvm - reusedLvmVg - -Volume - mountPath - mountOptions - filesystem - autoSize - minSize - maxSize - snapshots - target - -VolumeTarget <'default'|NewPartition|NewVg|UseDevice|UseFilesystem> - -NewPartition - newPartition - -NewVg - newVg - -UseDevice - device - -UseFilesystem - filesystem -``` - -The `device` can be specified in several ways. The simplest one is using one of the strings "disk" -or "newLvmVg". In that case, the proposal will automatically select the first disk to be used as -target disk or as base to create the physical volumes. For example, this will create a default -partition-based installation on the first available disk. - -```json -"storage": { - "guided": { "device": "disk" } -} -``` - -And this will do the same, but creating a new LVM volume group on that first candidate disk. - -```json -"storage": { - "guided": { "device": "newLvmVg" } -} -``` - -It's also possible to use a device name to specify a concrete disk... - -```json -"storage": { - "guided": { - "device": { - "disk": "/dev/sda" - } - } -} -``` - -or to specify the set of disks where the LVM physical volumes can be created. - -```json -"storage": { - "guided": { - "device": { - "newLvmVg": ["/dev/vda", "/dev/vdb"] - } - } -} -``` - -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 -volumes will be created and if some aspects are omitted for a given volume they will be completed -with default values. In the future we may consider more advanced mechanisms to include or exclude -some given volumes or to customize a single volume without having to provide the full list of -volume mount paths. - -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/doc/autoyast_storage.md b/doc/autoyast_storage.md index 190a4559f6..740c21e605 100644 --- a/doc/autoyast_storage.md +++ b/doc/autoyast_storage.md @@ -1,13 +1,30 @@ -# Problems with the AutoYaST Storage Schema +# Agama and AutoYaST -The AutoYaST schema is far from ideal and it presents some structural problems. For that reason, -Agama offers its own storage schema following similar principles but a different approach at several -levels. +The AutoYaST schema to specify the storage setup is far from ideal and presents some structural +problems. Although Agama uses its own storage schema, an Agama profile can contain a special +`legacyAutoyastStorage` section which is a 1:1 representation of the XML AutoYaST profile. -This document explains some of the problems that caused the AutoYaST schema (or an hypothetical +## Implementation considerations for the AutoYaST specification + +In principle, implementing the legacy AutoYaST module is as simple as converting the corresponding +section of the profile into a `Y2Storage::PartitioningSection` object and use +`Y2Storage::AutoInstProposal` to calculate the result. + +But there are some special cases in which AutoYaST fallbacks to read some settings from the YaST +settings or to use some YaST mechanisms. Those cases should be taken into account during the +implementation. + +For example, AutoYaST relies on the traditional YaST proposal settings when "auto" is used to +specify the size of a partition or to determine the default list of subvolumes when Btrfs is used. +See also the sections "Automatic Partitioning" and "Guided Partitioning" at the AutoYaST +documentation for situations in which AutoYaST uses the standard YaST `GuidedProposal` as fallback. + +## Problems with the AutoYaST storage schema + +This section explains some of the problems that caused the AutoYaST schema (or an hypothetical compatible one) to be discarded as the main schema for Agama. -## Everything Is a Drive or a Partition Section +### Everything is a Drive or a Partition section This could seem a minor detail, but it has several implications: @@ -29,7 +46,7 @@ This could seem a minor detail, but it has several implications: ~~~ -## Directly Formatting Devices is Hammered +### Directly formatting devices is hammered A `` section is still needed for directly formatting a device, which shows the abuse of the schema. @@ -47,7 +64,7 @@ the schema. ~~~ -## Selecting Devices is Difficult and Limited +### 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 @@ -101,7 +118,7 @@ allowing selecting a partition only by its number. Note that you could indicate the same partition number for deleting (``) and for reusing (``). -## Devices Are Created in a Indirect Way +### 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 @@ -133,13 +150,13 @@ going to be used by. It would be more natural to indicate the used devices directly in the RAID or logical volume drive. -## Actions to make space must be very explicit +### Actions to make space must be very explicit There is no way to specify optional actions to be performed on the existing devices, like "resize a given partition as much as needed to make space for the new ones" or "delete a partition only if necessary" or "grow the existing partition to use the rest of the available space". -## MD RAIDs and LVM Volume Groups must be described exhaustively +### MD RAIDs and LVM Volume Groups must be described exhaustively To get a volume group on top of partitions distributed across several disks, the profile must specify the partitions that will serve as physical volumes on each disk, including exact sizes. @@ -153,3 +170,20 @@ partition level even if the usable size of the resulting MD RAID may not obvious Of course, the problem accumulates when defining an LVM volume group on top of an MD RAID that sits on top of some partitions. All the sizes may match (including all possible overheads and rounding) or the result will contain either wasted or surplus space. + +## The New Agama storage schema + +Agama offers its own storage schema (using a `storage` section instead of the mentioned +`legacyAutoyastStorage`) 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. diff --git a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml index 4b4e839ef5..701b615834 100644 --- a/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml +++ b/doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml @@ -51,6 +51,15 @@ + + + + + + + + + diff --git a/doc/images/storage_ui/yast_guided_result.png b/doc/images/storage/yast_guided_result.png similarity index 100% rename from doc/images/storage_ui/yast_guided_result.png rename to doc/images/storage/yast_guided_result.png diff --git a/doc/images/storage_ui/agama_guided.png b/doc/images/storage_ui/agama_guided.png deleted file mode 100644 index f37d111c2d..0000000000 Binary files a/doc/images/storage_ui/agama_guided.png and /dev/null differ diff --git a/doc/images/storage_ui/automatic_size_example1.png b/doc/images/storage_ui/automatic_size_example1.png deleted file mode 100644 index da851e6d6d..0000000000 Binary files a/doc/images/storage_ui/automatic_size_example1.png and /dev/null differ diff --git a/doc/images/storage_ui/automatic_size_example2.png b/doc/images/storage_ui/automatic_size_example2.png deleted file mode 100644 index 0da03a65f3..0000000000 Binary files a/doc/images/storage_ui/automatic_size_example2.png and /dev/null differ diff --git a/doc/images/storage_ui/automatic_size_example3.png b/doc/images/storage_ui/automatic_size_example3.png deleted file mode 100644 index 052c75426b..0000000000 Binary files a/doc/images/storage_ui/automatic_size_example3.png and /dev/null differ diff --git a/doc/images/storage_ui/boot_config_popup.png b/doc/images/storage_ui/boot_config_popup.png deleted file mode 100644 index 01e1096d53..0000000000 Binary files a/doc/images/storage_ui/boot_config_popup.png and /dev/null differ diff --git a/doc/images/storage_ui/change_location_popup.png b/doc/images/storage_ui/change_location_popup.png deleted file mode 100644 index 810d7aaf6c..0000000000 Binary files a/doc/images/storage_ui/change_location_popup.png and /dev/null differ diff --git a/doc/images/storage_ui/default_device_popup.png b/doc/images/storage_ui/default_device_popup.png deleted file mode 100644 index 2f88ac5a59..0000000000 Binary files a/doc/images/storage_ui/default_device_popup.png and /dev/null differ diff --git a/doc/images/storage_ui/find_space.png b/doc/images/storage_ui/find_space.png deleted file mode 100644 index 42aa7e9892..0000000000 Binary files a/doc/images/storage_ui/find_space.png and /dev/null differ diff --git a/doc/images/storage_ui/transactional.png b/doc/images/storage_ui/transactional.png deleted file mode 100644 index 68177aeb74..0000000000 Binary files a/doc/images/storage_ui/transactional.png and /dev/null differ diff --git a/doc/storage.md b/doc/storage.md new file mode 100644 index 0000000000..5683596749 --- /dev/null +++ b/doc/storage.md @@ -0,0 +1,184 @@ +# Storage considerations + +This document describes several aspects of Agama's approach to storage configuration. + +All the user-facing information has been moved to the +[repository](https://github.com/agama-project/agama-project.github.io) containing the Agama +documentation. This document is maintained here for the following purposes. + + - Document the rationale behind some design decisions. + - Recap implementation details or other information that is too technical for user-oriented + documents. + - Record aspects that are still under discussion. + +## Agama and YaST + +This section describes some of the main differences between the Agama and YaST approaches. + +### Volumes in the YaST Proposal + +The YaST proposal heavily relies in the concept of the so-called volumes. Those volumes, that are +different for every product or system role, describe the partitions or LVM logical volumes to be +created during the process. + +In YaST, every volume specifies two different kinds of lower size limits. The so-called "desired +size" that is the smallest size that is recommeded for a normal usage of that volume and the "min +size" that is the lower threshold for the volume to be minimally useful. On top of that, every +volume has a "weight", used to adjust how the available space is distributed among the volumes. + +On the other hand, the maximum size for a given volume can be configured with the optional "max +size". But that value can be overridden if LVM is used by the also optional "max size LVM". + +Experience has shown that people in charge of defining the volumes for each product struggle to +grasp the concepts of desired size, min size and weight. The flexibility and level of customization +they provide doesn't seem to pay off for the confusion they introduce. + +Volumes at Agama will only have a minimum size and (optionally) a maximum one. No "desired size", +"weight" or "max size LVM". + +### The Initial Proposal + +Currently YaST tries really hard to present an initial proposal to the user, even if that implies +several subsequent executions of the `GuidedProposal`, each of them with a less ambitious +configuration. For that it relies on two features of the so-called volumes. + +- First of all, every volume specifies both a "min size" and a "desired size". +- On the other hand, some features of a volume are marked as optional in the control file. That + includes the usage of snapshots, the ability to expand based on the RAM size or even the existence + of the volume at all. + +YaST performs an initial execution of the `GuidedProposal` using the desired sizes as starting point +and with all the optional features set at their recommended values. If that fails, it runs +subsequent attempts until a proposal is possible. For that it fallbacks to the min sizes and +disables volumes (or volume features) in the order specified in the control file. It also explores +the possibility of using the different disks found on the system. + +That behavior almost guarantees that YaST can make a storage proposal so it's possible to install +with an empty AutoYaST profile or by simply clicking "next, next, next" in the interactive +installer. But it is not very self-explanatory. To somehow explain what happened, YaST shows a +sentence like these next to the result of the current proposal: + +- "_Initial layout proposed with the default Guided Setup settings_" +- "_Initial layout proposed after adjusting the Guided Setup settings_" (see screenshot). + +![Guided Setup result at YaST](images/storage/yast_guided_result.png) + +As mentioned before, Agama doesn't need to replicate all YaST behaviors or to inherit its +requirements and expectations. It's possible to adopt the same approach or to go all the way in the +other direction and try by default to execute the storage proposal only once, with: + + - A single disk as target (chosen by any criteria) + - The default product strategy for making space (eg. wiping the content of the disk) + - Using the default settings for all volumes + +If that execution of the proposal fails, then Agama could simply show a message like: +"it was not possible to calculate an initial storage layout". + +### Reusing LVM Setups + +For historical reasons, YaST tries to reuse existing LVM volume groups when making a proposal. That +behavior can be very confusing in many situations. To avoid the associated problems, the Agama +storage proposal will not automatically reuse existing LVM structures. + +To reuse existing volume groups the user must explicitly specify that. See the section "future +features". + +## Agama and AutoYaST + +The relationship between the Agama storage schema and the old AutoYaST format is described +at a [separate document](./autoyast_storage.md). + +## Calculating the omitted size of a file system + +If the size is omitted for a new device that directly contain a `filesystem` entry with a mount +point, Agama will then use the settings of the product to set the size limits. From a more +technical point of view, that translates into the following: + + - If the mount path corresponds to a volume supporting `auto_size`, that feature will be used. + - If it corresponds to a volume without `auto_size`, the min and max sizes of the volumes will be + used. + - If there is no volume for that mount path, the sizes of the default volume will be used. + - If the product does not specify a default volume, the behavior is still not defined (there are + several reasonable options). + +## Schema sections under discussion + +This section summarizes several aspects of the Agama storage schema that have been considered +but not implemented so far. + +### Specifying the Size of a Device + +The current schema makes it possible to specify the sizes as a fixed value or as a range. But a +a parseable string like "40 GiB" may not be the only option to represent a size or a range limit. +The following two possibilities are also under consideration. + + - `{ "gib": 40 }` + - `{ "value": 40, "units": "gib" }` + +### Searching Existing Devices + +Strings may be used as value for `search` to locate a device by its name or to search all existing +devices using "\*". But strings may be useful in other situations. + +For example, "next" (or any similar term) could be used to represent the default search for drives +(which is something like `{ "sort": { "property": "name" }, "max": 1, "ifNotFound": "error" }`. + +If a simple string like "next" could be used to specify the standard search entry for drives, it +would make sense to simply make `search` mandatory for all drives instead of assuming a default one. + +Another possible improvement for that string-based format would be supporting regular expressions. +That would make it possible to use searchers like this. + +```json +{ "search": ".*" } +``` + +But regular expressions would not play well with libstorage-ng. Since not all device names are +stored in the devicegraph, it is is necessary to use functions like `find_by_any_name` in order to +perform an exhaustive search by name. + +Another aspect under discussion is the format to specify conditions. Instead of the format described +above, it would be possible to use the key as name of the property, resulting in something like this. + +```json +{ + "search": { + "condition": { "sizeGib": 1, "operator": "greater" } + } +} +``` + +More formats for the conditions are being considered, like the one displayed at the next examples. + +```json +"condition": { "size": { "greater": "1 GiB" } } +``` + +```json +"condition": { "size": { "greater": "1 GiB", "smaller": "10 GiB" } } +``` + +```json +"condition": { "name": { "match": "^/dev/system" } } +``` + +### Referencing Other Devices + +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 +system (so the same conditions can be matched by a disk, a partition, an LVM device, etc.) and +`ifNotFound` could not be set to "create" (similar to what happens for drives in general). + +```json +"storage": { + "volume_groups": [ + { + "name": "newVG", + "physicalVolumes": [ + { "search": { "condition": { "property": "name", "value": "/dev/sda2" } } } + ], + "logicalVolumes": [ { "name": "data", "size": "20 GiB" } ] + } + ] +} +``` diff --git a/doc/storage_ui.md b/doc/storage_ui.md deleted file mode 100644 index 1393cd3271..0000000000 --- a/doc/storage_ui.md +++ /dev/null @@ -1,358 +0,0 @@ -# A Proposal for the Storage User Interface - -## Previous Considerations - -### Don't Take Mock-ups Too Seriously - -First of all, bear in mind the screenshots are far from being a faithfull representation of the -final look & feel. This document presents the concept focusing on the elements that should be there -and how they will interact. Something that is represented as a sentence in the screenshots can -become a tool-tip, a given icon can become a label, actions grouped in a drop-down can end up -being represented as separate buttons, etc. - -### Transactional Systems - -Agama is able to install transactional distributions like openSUSE MicroOS. There will be no option -in the Agama storage user interface to set whether the root file system of the installed system -should be transactional (also known as "immutable") or not. Since the implications go beyond the -file system settings, the nature of the system (transactional vs read-write) will be determined by -the selection of the operating system to install, "product" in Agama jargon. - -This document describes the Agama interface for a traditional (non-transactional) system. See the -section "interface changes for transactional systems" for the alternative. - -### Representation of the Result - -The result of the storage setup is represented in the mockups of this document as a section of the -storage page titled "result", which includes a list of (libstorage-ng) actions and a table -representing the final state of the affected devices. That is far from ideal, but a complete design -for a more convenient representation of the result is out of the scope of this proposal. - -In the long term, we may need to come with a better alternative to show the result. - -## General Workflow - -Having all the previous considerations in mind, let's describe how the general user interaction will -work. - -The summary page of Agama would display the result of the current storage proposal (or a -message about the failed initial calculation) and a link to modify that layout. That link will lead -to the page that allows to (re)configure and (re)calculate the storage proposal and that is -described at *The Proposal Page*. - -The Agama storage proposal will be the only mechanism to define the file systems of the new -operating system, including their mount points, subvolumes and options for formatting or mounting. -This proposal is based on the `GuidedProposal` implemented by the YaST libraries and presented as -"Guided Setup" in the YaST user interface. As such, the wanted file systems will actually be defined -as a set of so-called volumes very similar to the YaST ones (see *Volumes in the YaST Proposal* for -more information). The term "volume" is intentionally avoided in the user interface, using the term -"file system" instead. Nevertheless this document uses both terms indistinctly. - -Sometimes a previous setup may be needed in order to prepare the devices used by that proposal -mechanism. That includes actions like connecting to some iSCSI disks, activating and formating -DASDs, creating a software-defined RAID or setting an advanced LVM layout potentially including -several volume groups or thin-provisioned volumes. Those actions will in general modify the system -right away, instead of just planning actions to be performed during installation. Access to those -preliminary actions will be available from the Agama storage page. The general functionality is -briefly described at *Advanced Preparations*. - -## The Proposal Page - -### Overall Description of the Proposal - -The following interface will allow to configure the Agama storage setup for installation. Note the -mock-ups do not display an initial proposal, but the status after some manual changes done by the -user. - -![Initial storage screen](images/storage_ui/agama_guided.png) - -Every change to any of the configuration options will result in an immediate re-calculation of the -section that presents the result. Changes in the configuration of encryption, btrfs snapshots or the -target devices can also imply refreshing the description of the file systems. In a similar way, -changes in those volumes or the target device may result in a change in the number of disks -mentioned in the sentence about finding space. - -The table with the file systems actually represents the volumes used as input for the Agama variant -of the `GuidedProposal`. Compared to YaST, Agama turns the volumes into a much more visible concept. -The users will be able to see and adjust most of their attributes. Users could even define new -volumes that are not initially part of the configuration of the selected product. - -Pop-up dialogs will be used to modify the target device(s), the encryption configuration, the -booting setup or the strategy to find free space, as well as to add or edit a given volume. - -All file systems will be created by default at the chosen target disk or at the default LVM volume -group (in the case of LVM-based proposal). The user will be able to manually overwrite the location -of any particular volume. That has been done in the mock-up about both for the swap volume, which -will be created in an alternative disk, and for the `/home` one, that will reuse the existing file -system at the `vdb1` partition. Continue reading to understand all the possible options. - -Defining the settings and the list of volumes also defines, as a direct consequence, the disks -affected by the installation process. It may be needed to make some space in those disks. That -deserves a dedicated "find space" setting that is described below. - -### Device Selection - -As seen on the image above, the main device to install the system can be chosen at the very top of -the storage proposal page. Although a Linux installation can extend over several disks, the storage -proposal algorithm and its configuration is better understood if one device is chosen as the main -target one. That device can be a single disk (or equivalent device) or an LVM volume group. - -![Dialog to select installation device](images/storage_ui/default_device_popup.png) - -When the main target device is a disk, the volumes will be created by default as new partitions -there. If a new LVM volume group is chosen as installation device, the selection of disks indicates -which devices will be partitioned in order to allocate the physical volumes of the new volume group. -In that case, the file systems will be created by default as new LVM logical volumes at that new -volume group. - -### General Settings - -The device selection is followed by some global settings that define how the installation is going -to look and what are the possibilities in terms of booting and structuring the file systems. Those -settings include the usage of btrfs snapshots, which in YaST is presented relatively hidden as one -of the configuration options for the root file system. - -### File Systems - -The "settings" section also contains the table that displays the file systems to be created, volumes -in YaST jargon. The size of each volume is specified as a couple of lower and upper limits (the -upper one is optional in all cases). With the current approach of the YaST `GuidedProposal` there -are some volumes that may need to recalculate those limits based on the proposal configuration (eg. -whether Btrfs snapshots are enabled) or its relationship with others volumes. Their limits will be -set as "auto-calculated" by default. For more details, see the corresponding section below. - -If btrfs is used for the root file system, it will be possible to define subvolumes for it. Those -subvolumes are represented in the same table, nested on the entry of the root file system. They can -be removed and added. The subvolume entries are collapsed by default. - -If a subvolume becomes irrelevant due to the creation of another file system (let's say a `/var/lib` -subvolume exists but a new `/var` file system is added to the table), then it will not be created by -the proposal. That's known as "subvolume masking" in the YaST internals. It's still not clear how -that will be represented in the table. - -Although btrfs subvolumes don't have sizes. We might consider to add support for defining btrfs -quotas. In that case, the quota could be specified for each entry taking advantage of the already -existing column "size limits", although those quotas don't really affect the size calculations -performed by the proposal. - -All file systems will be created on the installation device by default. But it will be possible to -specify an an alternative location using the following form that offers several options. - -![Dialog to change a volume location](images/storage_ui/change_location_popup.png) - -When the option to reuse an existing device is chosen, size limits cannot be adjusted. The size of -the reused device will be displayed in the table of file systems in the corresponding column. - -### Configuration of Booting Partitions - -One of the main features of the `GuidedProposal` is its ability to automatically determine any extra -partition that may be needed for booting the new system, like PReP, EFI, Zipl or any other described -at the [corresponding YaST -document](https://github.com/yast/yast-storage-ng/blob/master/doc/boot-requirements.md). The -algorithm can create those partitions or reuse existing ones that are already in the system if the -user wants to keep them (see the section about finding space). The behavior of that feature can be -also be tweaked in the "settings" section of the page. - -![Dialog to configure booting](images/storage_ui/boot_config_popup.png) - -### Finding Space for the Volumes - -Similar to YaST, Agama will offer by default the option to automatically make space for the new -operating system. But the algorithm will be different and less configurable, offering basically -three automatic modes. - -As an alternative, the Agama proposal will offer a custom mode in which the user will explicitly -select which partitions to keep, delete or resize. - -That will result in up to four possibilities presented at a pop-up dialog if the user clicks on the -corresponding option at the botton of the "settings" section. - -- Delete everything in the disk(s). Obviously, all previous data is removed. -- Shrink existing partition(s). The information is kept, but partitions are resized as needed to make - enough space. -- Do not modify existing partition(s). The installation will only succeed if the disk(s) already - contains suitable free spaces. -- Custom. The user interface will allow the user to specify what to do with every individual partition - in the affected disks: delete it, keep it as it is or allow the algorithm to shrink it based on - the needs determined by the sizes of the volumes to allocate. - -![Section to find space](images/storage_ui/find_space.png) - -If the device chosen for installation is an already existing LVM volume group (see "device selection -and general settings"), that volume group will be displayed in a very similar way to any affected -disk, making it possible to specify what to do with the pre-existing logical volumes in a way that -is analogous to partitions in a disk. - -### Automatic Size Limits - -Currently there are cases in which the lower and upper limits of a given volume are adjusted for -the `GuidedProposal` based on the following aspects: - -- Whether snapshots are activated for the root volume -- Whether the size of the volume must be influenced by the RAM size (used for suspend in the case - of swap and for Kdump in the case of the root volume) -- Whether the given volume is marked as "fallback" for another one (eg. if the separate /home is - disabled then the upper limit of the root one disappears) - -To make that possible, the size limits of the volumes that are affected by one or several of those -circumstances will be set as "auto-calculated" by default. If that's the case, a tool-tip will be -available next to each set of limits to explain the rationale of the current values. - -Let's consider the following example in which some volumes are configured like this for the product -being installed: - -```yaml -volumes: -- mount_point: "/" - min_size: 5 GiB - max_size: 20 GiB - # Sizes are multiplied by 3 if snapshots are configured - snapshots_percentage: 200 - ... -- mount_point: "/home" - min_size: 10 GiB - max_size: unlimited - # If this volume is disabled we want "/" to increase - fallback_for_max_size: "/" - ... -``` - -The list could start with something like this. - -![Automatic Sizes Example Step 1](images/storage_ui/automatic_size_example1.png) - -The reason for the "auto-calculated" value would be explained to the user via a tool-tip (or similar -mechanism) with a text similar to this (very crude wording, to be refined): - -``` -These limits are affected by:: - - The configuration of snapshots - - The presence of a separate /home volume -``` - -As a consequence of all that, if the user deletes the /home volume then the new list would be (note -the change in the automatic size limits of the root volume). - -![Automatic Sizes Example Step 2](images/storage_ui/automatic_size_example2.png) - -If, on top of that, the user also disables snapshots the new resulting list would be. - -![Automatic Sizes Example Step 3](images/storage_ui/automatic_size_example3.png) - -Of course, at any point in time the user could modify the root volume and switch to fixed (ie. not -auto-calculated) limits. In that case, the entered values would be observed and would not be -automatically recalculated anymore, despite any configuration for the default volumes. - -### Interface Changes for Transactional Systems - -As explained at the beginning of this document, Agama can install some transactional distributions. -In those systems, it makes no sense to disable Btrfs snapshots, which are required to provide the -functionality. Is not only that snapshots are mandatory in transactional systems, they are actually -used with a different purpose when compared to read-write systems. - -Thus, if the system being installed is transactional, that will be clearly stated at the top of the -page. The setting to use btrfs snapshots will not be there and the root file system will be labeled as -"transactional" in the corresponding table. - -![Interface changes for transactional systems](images/storage_ui/transactional.png) - -## Advanced Preparations - -As mentioned above, in addition to the page for defining the proposal, Agama will offer interfaces to -perform some preparatory actions like managing DASDs or setting up complex RAID or LVM layouts. -Those interfaces will never replace the storage proposal as the only way to define the file systems of -the installed system. Instead, they will operate right away in the system to configure the devices -to be used by the proposal. - -Some of those interfaces already exist, like the one that allows to connect and disconnect to iSCSI -targets or the one to manage DASDs. Currently they can be reached through a special menu at the -Agama header. We may consider other mechanisms to make them more discoverable in the future. - -There will also be interfaces to: - -- Manage software-defined RAIDs -- Manage Bcache devices -- Define custom LVM setups -- Manipulate partitions in the disks or in any of the RAID and bcache devices - -Since all those actions are interrelated (eg. the user often creates partitions that are combined -into a RAID that is then used as an LVM physical volume), the final user interface will likely -resemble the traditional YaST Expert Partitioner. But, since the scope of such a tool will be -limited to preparing the disk for the proposal, it will not allow to format devices or to define -mount points for the target system. After defining all the actions to be performed, the changes will -be committed to the system before returning to the proposal page to define the location and settings -of each file system. - -## Comparison with the YaST Proposal - -### Volumes in the YaST Proposal - -The YaST proposal heavily relies in the concept of the so-called volumes. Those volumes, that are -different for every product or system role, describe the partitions or LVM logical volumes to be -created during the process. - -In YaST, every volume specifies two different kinds of lower size limits. The so-called "desired -size" that is the smallest size that is recommeded for a normal usage of that volume and the "min -size" that is the lower threshold for the volume to be minimally useful. On top of that, every -volume has a "weight", used to adjust how the available space is distributed among the volumes. - -On the other hand, the maximum size for a given volume can be configured with the optional "max -size". But that value can be overridden if LVM is used by the also optional "max size LVM". - -Experience has shown that people in charge of defining the volumes for each product struggle to -grasp the concepts of desired size, min size and weight. The flexibility and level of customization -they provide doesn't seem to pay off for the confusion they introduce. - -Volumes at Agama will only have a minimum size and (optionally) a maximum one. No "desired size", -"weight" or "max size LVM". - -### Reusing LVM Setups - -For historical reasons, YaST tries to reuse existing LVM volume groups when making a proposal. That -behavior can be very confusing in many situations. To avoid the associated problems, the Agama -storage proposal will not automatically reuse existing LVM structures. - -To reuse existing volume groups the user must explicitly specify that. See the section "future -features". - -### About the Initial Proposal - -Currently YaST tries really hard to present an initial proposal to the user, even if that implies -several subsequent executions of the `GuidedProposal`, each of them with a less ambitious -configuration. For that it relies on two features of the so-called volumes (volumes are already -described at *Volumes in the YaST Proposal*): - -- First of all, every volume specifies both a "min size" and a "desired size". -- On the other hand, some features of a volume are marked as optional in the control file. That - includes the usage of snapshots, the ability to expand based on the RAM size or even the existence - of the volume at all. - -YaST performs an initial execution of the `GuidedProposal` using the desired sizes as starting point -and with all the optional features set at their recommended values. If that fails, it runs -subsequent attempts until a proposal is possible. For that it fallbacks to the min sizes and -disables volumes (or volume features) in the order specified in the control file. It also explores -the possibility of using the different disks found on the system. - -That behavior almost guarantees that YaST can make a storage proposal so it's possible to install -with an empty AutoYaST profile or by simply clicking "next, next, next" in the interactive -installer. But it is not very self-explanatory. To somehow explain what happened, YaST shows a -sentence like these next to the result of the current proposal: - -- "_Initial layout proposed with the default Guided Setup settings_" -- "_Initial layout proposed after adjusting the Guided Setup settings_" (see screenshot). - -![Guided Setup result at YaST](images/storage_ui/yast_guided_result.png) - -As mentioned before, Agama doesn't need to replicate all YaST behaviors or to inherit its -requirements and expectations. It's possible to adopt the same approach or to go all the way in the -other direction and try by default to execute a variant of the `GuidedProposal` only once, with: - - - A single disk as target (chosen by any criteria) - - A simple strategy for making space (eg. wiping the content of the disk) - - Using the default settings for all volumes - -If that execution of the `GuidedProposal` fails, then Agama could simply show a message like: -"it was not possible to calculate an initial storage layout". - -The interface proposed in this document will work equally whatever approach is decided for the -initial storage proposal of Agama. diff --git a/live/root/etc/systemd/system/live-password-cmdline.service b/live/root/etc/systemd/system/live-password-cmdline.service index 0893c64b19..3307967f7b 100644 --- a/live/root/etc/systemd/system/live-password-cmdline.service +++ b/live/root/etc/systemd/system/live-password-cmdline.service @@ -9,7 +9,7 @@ Before=agama-web-server.service Before=live-password-dialog.service Before=live-password-systemd.service -# plain text password or encrypted password passed via kernel command line +# plain text password or hashed password passed via kernel command line ConditionKernelCommandLine=|live.password ConditionKernelCommandLine=|live.password_hash diff --git a/products.d/agama-products.changes b/products.d/agama-products.changes index 0470f30118..3c0380ef27 100644 --- a/products.d/agama-products.changes +++ b/products.d/agama-products.changes @@ -1,3 +1,10 @@ +------------------------------------------------------------------- +Mon Jan 6 14:41:28 UTC 2025 - Angela Briel + +- SLES for SAP Application product: + Change product description. + (bsc#1235023) + ------------------------------------------------------------------- Thu Dec 5 11:04:06 UTC 2024 - Angela Briel diff --git a/products.d/leap_160.yaml b/products.d/leap_160.yaml index ee05d5604a..273b72ee9a 100644 --- a/products.d/leap_160.yaml +++ b/products.d/leap_160.yaml @@ -15,8 +15,8 @@ translations: Enterprise Server. cs: Nejnovější verze komunitní distribuce založené na nejnovějším SUSE Linux Enterprise Serveru. - de: Leap 16.0 ist die neueste Version einer Community-Distribution, die auf dem - aktuellen SUSE Linux Enterprise Server basiert. + de: Die neueste Version einer Community-Distribution, die auf dem aktuellen SUSE + Linux Enterprise Server basiert. es: La última versión de una distribución comunitaria basada en el último SUSE Linux Enterprise Server. ja: 最新のSUSE Linux Enterprise Server をベースにした、コミュニティディストリビューションの最新版です。 diff --git a/products.d/microos.yaml b/products.d/microos.yaml index 4e847af284..8aef2782f2 100644 --- a/products.d/microos.yaml +++ b/products.d/microos.yaml @@ -32,8 +32,8 @@ translations: es: Una distribución pequeña y rápida diseñada para alojar cargas de trabajo de contenedores con administración y parches automatizados. openSUSE MicroOS proporciona actualizaciones transaccionales (atómicas) en un sistema de - archivos raíz btrfs de solo lectura. Como distribución de lanzamiento - continuo, el software siempre está actualizado. + archivos raíz btrfs de solo lectura. Como distribución de actualización + continua, el software siempre está actualizado. fr: Une petite distribution rapide conçue pour héberger des charges de travail de conteneurs avec une administration et des correctifs automatisés. openSUSE MicroOS fournit des mises à jour transactionnelles (atomiques) diff --git a/products.d/sles_160.yaml b/products.d/sles_160.yaml index 3c095d6b84..e73d436ad1 100644 --- a/products.d/sles_160.yaml +++ b/products.d/sles_160.yaml @@ -25,6 +25,11 @@ translations: přizpůsobivý operační systém pro dlouhodobě podporovanou infrastrukturu připravenou na inovace, na které běží kritické podnikové úlohy v lokálním prostředí, v cloudu i na okraji sítě. + de: Ein offener, zuverlässiger, kompatibler und zukunftssicherer Linux-Server, + der die Geschäftskontinuität des Unternehmens gewährleistet. Es ist das + sichere und anpassungsfähige Betriebssystem für eine langfristig + unterstützte, innovationsbereite Infrastruktur, auf der geschäftskritische + Arbeitslasten vor Ort, in der Cloud und am Netzwerkrand ausgeführt werden. es: Una opción de servidor Linux abierta, confiable, compatible y preparada para el futuro que garantiza la continuidad del negocio de la empresa. Es el sistema operativo seguro y adaptable para una infraestructura lista para diff --git a/products.d/sles_sap_160.yaml b/products.d/sles_sap_160.yaml index 8113188e09..05088d31c1 100644 --- a/products.d/sles_sap_160.yaml +++ b/products.d/sles_sap_160.yaml @@ -1,5 +1,5 @@ -id: SLES-SAP -name: SUSE Linux Enterprise Server for SAP Applications 16.0 Alpha +id: SLES_SAP +name: SUSE Linux Enterprise Server for SAP Applications 16.0 Beta registration: "mandatory" version: "16-0" # ------------------------------------------------------------------------------ @@ -7,45 +7,14 @@ version: "16-0" # at the at translations/description key below to avoid using obsolete # translations!! # ------------------------------------------------------------------------------ -description: "An open, reliable, compliant, and future-proof Linux Server choice - that ensures the enterprise's business continuity. It is the secure and - adaptable OS for long-term supported, innovation-ready infrastructure running - business-critical workloads on-premises, in the cloud, and at the edge." +description: "The leading OS for a secure and reliable SAP platform. +Endorsed for SAP deployments, SUSE Linux Enterprise Server for SAP Applications +futureproofs the SAP project, offers uninterrupted business, and minimizes +operational risks and costs." icon: SUSE.svg # Do not manually change any translations! See README.md for more details. translations: description: - ca: Una opció de servidor de Linux oberta, fiable, compatible i a prova del - futur que garanteix la continuïtat del negoci de l'empresa. És el sistema - operatiu segur i adaptable per a una infraestructura amb suport a llarg - termini i preparada per a la innovació que executa càrregues de treball - crítiques per a l'empresa a les instal·lacions, al núvol i a l'última. - cs: Otevřená, spolehlivá, kompatibilní a perspektivní volba linuxového serveru, - která zajišťuje kontinuitu podnikání podniku. Je to bezpečný a - přizpůsobivý operační systém pro dlouhodobě podporovanou infrastrukturu - připravenou na inovace, na které běží kritické podnikové úlohy v lokálním - prostředí, v cloudu i na okraji sítě. - es: Una opción de servidor Linux abierta, confiable, compatible y preparada para - el futuro que garantiza la continuidad del negocio de la empresa. Es el - sistema operativo seguro y adaptable para una infraestructura lista para - la innovación y con soporte a largo plazo que ejecuta cargas de trabajo - críticas para el negocio en las instalaciones, en la nube y en el borde. - ja: オープンで信頼性が高く、各種の標準にも準拠し、将来性とビジネスの継続性を支援する Linux - サーバです。長期のサポートが提供されていることから、安全性と順応性に優れ、オンプレミスからクラウド、エッジ環境に至るまで、様々な場所で重要なビジネス処理をこなすことのできる革新性の高いインフラストラクチャです。 - pt_BR: Uma escolha de servidor Linux aberta, confiável, compatível e à prova do - futuro que garante a continuidade dos negócios da empresa. É o SO seguro e - adaptável para infraestrutura com suporte de longo prazo e pronta para - inovação, executando cargas de trabalho críticas para os negócios no - local, na nuvem e na borda. - sv: Ett öppet, pålitligt, kompatibelt och framtidssäkert Linux-serverval som - säkerställer företagets affärskontinuitet. Det är det säkra och - anpassningsbara operativsystemet för långsiktigt stödd, innovationsfärdig - infrastruktur som kör affärskritiska arbetsbelastningar på plats, i molnet - och vid kanten. - tr: İşletmenin iş sürekliliğini garanti eden açık, güvenilir, uyumlu ve geleceğe - dönük bir Linux Sunucu seçeneği. Uzun vadeli desteklenen, inovasyona hazır - altyapı için güvenli ve uyarlanabilir işletim sistemidir. Şirket içinde, - bulutta ve uçta iş açısından kritik iş yüklerini çalıştırır. software: installation_repositories: [] diff --git a/products.d/slowroll.yaml b/products.d/slowroll.yaml index 97462723fb..e4d1b25d36 100644 --- a/products.d/slowroll.yaml +++ b/products.d/slowroll.yaml @@ -21,8 +21,20 @@ translations: tak, aby se aktualizovalo méně často než Tumbleweed. Zároveň se však aktualizuje častěji než Leap, aby se uživatelé nemuseli rozhodovat mezi "stabilními" a novějšími balíčky. + de: Ein experimentelles und etwas langsameres Rolling Release von openSUSE, das + darauf ausgelegt ist, weniger häufig als Tumbleweed, aber häufiger als + Leap zu aktualisieren, ohne die Benutzer zu zwingen, zwischen „stabilen“ + und neueren Paketen zu wählen. + es: Una versión experimental y de actualización contínua ligeramente más lenta + de openSUSE, diseñada para actualizarse con menos frecuencia que + Tumbleweed pero más a menudo que Leap, sin obligar a los usuarios a elegir + entre paquetes "estables" y más nuevos. ja: 実験的なディストリビューションではありますが、 Tumbleweed よりは比較的ゆっくりした、かつ Leap よりは速いペースで公開される openSUSE ローリングリリース型ディストリビューションです。 "安定性" と最新パッケージの中間を目指しています。 + pt_BR: Uma versão experimental e um pouco mais lenta do openSUSE, projetada para + atualizar com menos frequência que o Tumbleweed, mas com mais frequência + que o Leap, sem forçar os usuários a escolher entre pacotes "estáveis" e + mais novos. sv: En experimentell och något långsammare rullande utgåva av openSUSE utformad för att få nya paketuppdateringar mer sällan än Tumbleweed men oftare än Leap utan att tvinga användarna att välja mellan "stabila" eller nyare diff --git a/products.d/tumbleweed.yaml b/products.d/tumbleweed.yaml index 409d56cc98..f0ed0a7cf9 100644 --- a/products.d/tumbleweed.yaml +++ b/products.d/tumbleweed.yaml @@ -19,15 +19,14 @@ translations: cs: Čistě klouzavá verze openSUSE obsahující nejnovější "stabilní" verze veškerého softwaru, která se nespoléhá na pevné periodické cykly vydávání. Projekt to dělá pro uživatele, kteří chtějí nejnovější stabilní software. - de: Die Tumbleweed-Distribution ist eine Version mit reinen rollierenden - Veröffentlichungen von openSUSE, die die neuesten „stabilen“ Versionen der - gesamten Software enthält, anstatt sich auf starre periodische - Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für Benutzer, - die die neueste, stabile Software wünschen. - es: Una versión puramente continua de openSUSE que contiene las últimas - versiones "estables" de todo el software en lugar de depender de rígidos - ciclos de lanzamiento periódicos. El proyecto hace esto para usuarios que - desean el software estable más novedoso. + de: Eine reine Rolling-Release-Version von openSUSE, die die neuesten „stabilen“ + Versionen der gesamten Software enthält, anstatt sich auf starre + periodische Veröffentlichungszyklen zu verlassen. Das Projekt tut dies für + Benutzer, die die neueste, stabile Software wünschen. + es: Una versión de actualización continua pura de openSUSE que contiene las + últimas versiones "estables" de todo el software en lugar de depender de + rígidos ciclos de publicaciones periódicas. El proyecto hace esto para + usuarios que desean el software estable más novedoso. fr: La distribution Tumbleweed est une pure "rolling release" (publication continue) d'openSUSE contenant les dernières versions "stables" de tous les logiciels au lieu de se baser sur des cycles de publication diff --git a/puppeteer/tests/test_root_password.js b/puppeteer/tests/test_root_password.js index 32bb7b65a1..cdb7c5cc22 100644 --- a/puppeteer/tests/test_root_password.js +++ b/puppeteer/tests/test_root_password.js @@ -80,19 +80,22 @@ describe("Agama test", function () { slowMo, defaultViewport: { width: 1280, - height: 768 + height: 768, }, - ...browserSettings(agamaBrowser) + ...browserSettings(agamaBrowser), }); page = await browser.newPage(); page.setDefaultTimeout(20000); - await page.goto(agamaServer, { timeout: 60000, waitUntil: "domcontentloaded" }); + await page.goto(agamaServer, { + timeout: 60000, + waitUntil: "domcontentloaded", + }); }); after(async function () { await page.close(); await browser.close(); - }) + }); // automatically take a screenshot and dump the page content for failed tests afterEach(async function () { @@ -119,38 +122,50 @@ describe("Agama test", function () { await page.click("button[type='submit']"); }); - it("should require setting the root password", async function () { - await page.waitForSelector("input#rootPassword"); - // for simplicity just set the current password - await page.type("input#rootPassword", agamaPassword); - await page.click("button[type='submit']"); - }); - it("should optionally display the product selection dialog", async function () { - this.timeout(60000); - // Either the main page is displayed (with the storage link) or there is + // Either the root password setting is displayed or there is // the product selection page. - let productSelectionDisplayed = await Promise.any([ - page.waitForSelector("a[href='#/storage']") - .then(s => {s.dispose(); return false}), - page.waitForSelector("button[form='productSelectionForm']") - .then(s => {s.dispose(); return true}) + const productSelectionDisplayed = await Promise.any([ + page.waitForSelector("input#rootPassword").then((s) => { + s.dispose(); + return false; + }), + page.waitForSelector("button[form='productSelectionForm']").then((s) => { + s.dispose(); + return true; + }), ]); if (productSelectionDisplayed) { - await page.locator("::-p-text('openSUSE Tumbleweed')").click(); - await page.locator("button[form='productSelectionForm']") + const product = await page.locator("label[for='opensuse-tumbleweed']").waitHandle(); + // scroll the page so the product is visible + await product.scrollIntoView(); + await product.click(); + + await page + .locator("button[form='productSelectionForm']") // wait until the button is enabled .setWaitForEnabled(true) .click(); - // refreshing the repositories might take long time - await page.locator("h3::-p-text('Overview')").setTimeout(60000).wait(); } else { // no product selection displayed, mark the test as skipped this.skip(); } }); + it("should require setting the root password", async function () { + // increase the timeout for the whole test + this.timeout(60000); + await page + .locator("input#rootPassword") + // refreshing the repositories before showing the password configuration might take long time + .setTimeout(60000) + .waitHandle() + // type the new password + .then((h) => h.type("test")); + await page.locator("button[type='submit']").setWaitForEnabled(true).click(); + }); + it("should display overview card", async function () { await page.waitForSelector("h3::-p-text('Overview')"); }); @@ -158,13 +173,13 @@ describe("Agama test", function () { it("should allow setting the root password", async function () { await page.locator("a[href='#/users']").click(); - let button = await Promise.any([ + const button = await Promise.any([ page.waitForSelector("button::-p-text(Set a password)"), - page.waitForSelector("button#actions-for-root-password") + page.waitForSelector("button#actions-for-root-password"), ]); await button.click(); - const id = await button.evaluate(x => x.id); + const id = await button.evaluate((x) => x.id); // drop the handler to avoid memory leaks button.dispose(); diff --git a/rust/.gitignore b/rust/.gitignore index 8a45e69442..96395e220a 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -7,3 +7,6 @@ # Agama configuration file /etc/agama.d + +# Generated OpenAPI data +/out/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index aa76a0f561..d14e624000 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2167,7 +2167,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax", + "regex-syntax 0.8.5", "string_cache", "term", "tiny-keccak", @@ -2181,7 +2181,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata", + "regex-automata 0.4.8", ] [[package]] @@ -2310,6 +2310,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matchit" version = "0.7.3" @@ -3075,8 +3084,17 @@ checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -3087,9 +3105,15 @@ checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -4140,10 +4164,14 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index f4fa82312d..836a9b787e 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -32,7 +32,7 @@ mod questions; use crate::error::CliError; use agama_lib::base_http_client::BaseHTTPClient; use agama_lib::{ - error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, transfer::Transfer, + error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, utils::Transfer, }; use auth::run as run_auth_cmd; use commands::Commands; diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index 1e7018cdac..9f2cbcce0d 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -22,14 +22,15 @@ use crate::show_progress; use agama_lib::{ base_http_client::BaseHTTPClient, install_settings::InstallSettings, - profile::{AutoyastProfile, ProfileEvaluator, ProfileValidator, ValidationResult}, - transfer::Transfer, + profile::{AutoyastProfileImporter, ProfileEvaluator, ProfileValidator, ValidationResult}, + utils::FileFormat, + utils::Transfer, Store as SettingsStore, }; use anyhow::Context; use clap::Subcommand; use console::style; -use std::os::unix::process::CommandExt; +use std::os::unix::{fs::PermissionsExt, process::CommandExt}; use std::{ fs::File, io::stdout, @@ -114,48 +115,62 @@ async fn import(url_string: String, dir: Option) -> anyhow::Result<()> tokio::spawn(async move { show_progress().await.unwrap(); }); + let url = Url::parse(&url_string)?; let tmpdir = TempDir::new()?; // TODO: create it only if dir is not passed + let work_dir = dir.unwrap_or_else(|| tmpdir.into_path()); + let profile_path = work_dir.join("profile.json"); + + // Specific AutoYaST handling let path = url.path(); - let output_file = if path.ends_with(".sh") { - "profile.sh" - } else if path.ends_with(".jsonnet") { - "profile.jsonnet" - } else { - "profile.json" - }; - let output_dir = dir.unwrap_or_else(|| tmpdir.into_path()); - let mut output_path = output_dir.join(output_file); - let output_fd = File::create(output_path.clone())?; if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { - // autoyast specific download and convert to json - AutoyastProfile::new(&url)?.read_into(output_fd)?; + // AutoYaST specific download and convert to JSON + AutoyastProfileImporter::read(&url)?.write_file(&profile_path)?; } else { - // just download profile - Transfer::get(&url_string, output_fd)?; + pre_process_profile(&url_string, &profile_path)?; } - // exec shell scripts - if output_file.ends_with(".sh") { - let err = Command::new("bash") - .args([output_path.to_str().context("Wrong path to shell script")?]) - .exec(); - eprintln!("Exec failed: {}", err); - } + validate(&profile_path)?; + store_settings(&profile_path).await?; - // evaluate jsonnet profiles - if output_file.ends_with(".jsonnet") { - let fd = File::create(output_dir.join("profile.json"))?; - let evaluator = ProfileEvaluator {}; - evaluator - .evaluate(&output_path, fd) - .context("Could not evaluate the profile".to_string())?; - output_path = output_dir.join("profile.json"); - } + Ok(()) +} - validate(&output_path)?; - store_settings(&output_path).await?; +// Preprocess the profile. +// +// The profile can be a JSON or a Jsonnet file or a script. +// +// * If it is a JSON file, no preprocessing is needed. +// * If it is a Jsonnet file, it is converted to JSON. +// * If it is a script, it is executed. +fn pre_process_profile>(url_string: &str, path: P) -> anyhow::Result<()> { + let work_dir = path.as_ref().parent().unwrap(); + let tmp_profile_path = work_dir.join("profile.temp"); + let tmp_file = File::create(&tmp_profile_path)?; + Transfer::get(url_string, tmp_file)?; + match FileFormat::from_file(&tmp_profile_path)? { + FileFormat::Jsonnet => { + let file = File::create(path)?; + let evaluator = ProfileEvaluator {}; + evaluator + .evaluate(&tmp_profile_path, file) + .context("Could not evaluate the profile".to_string())?; + } + FileFormat::Script => { + let mut perms = std::fs::metadata(&tmp_profile_path)?.permissions(); + perms.set_mode(0o750); + std::fs::set_permissions(&tmp_profile_path, perms)?; + let err = Command::new(&tmp_profile_path).exec(); + eprintln!("Exec failed: {}", err); + } + FileFormat::Json => { + std::fs::rename(&tmp_profile_path, path.as_ref())?; + } + _ => { + return Err(anyhow::Error::msg("Unsupported file format")); + } + } Ok(()) } @@ -168,8 +183,8 @@ async fn store_settings>(path: P) -> anyhow::Result<()> { fn autoyast(url_string: String) -> anyhow::Result<()> { let url = Url::parse(&url_string)?; - let reader = AutoyastProfile::new(&url)?; - reader.read_into(std::io::stdout())?; + let importer = AutoyastProfileImporter::read(&url)?; + importer.write(std::io::stdout())?; Ok(()) } diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 83ddee9ca2..629677d396 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -39,6 +39,16 @@ } } }, + "bootloader": { + "title": "Bootloader settings", + "type": "object", + "properties": { + "stopOnBootMenu": { + "title": "Specify if bootloader should stop on menu during boot.", + "type": "boolean" + } + } + }, "software": { "title": "Software settings", "type": "object", @@ -96,8 +106,8 @@ "title": "The name of the network interface bound to this connection", "type": "string" }, - "mac-address": { - "title": "Custom mac-address", + "macAddress": { + "title": "Custom MAC address", "description": "Can also be 'preserve', 'permanent', 'random' or 'stable'.", "type": "string" }, @@ -149,10 +159,21 @@ "additionalProperties": false } }, - "ignore_auto_dns": { + "ignoreAutoDns": { "description": "Whether DNS options provided via DHCP are used or not", "type": "boolean" }, + "status": { + "title": "Connection status", + "description": "The status of the connection", + "type": "string", + "enum": ["up", "down", "removed"] + }, + "autoconnect": { + "title": "Auto-connected", + "description": "Whether the connection should be automatically connected", + "type": "boolean" + }, "wireless": { "type": "object", "title": "Wireless configuration", @@ -382,12 +403,17 @@ "examples": ["jane.doe"] }, "password": { - "title": "User password (plain text or encrypted depending on the \"encryptedPassword\" field)", + "title": "User password (plain text or hashed depending on the \"hashedPassword\" field)", "type": "string", "examples": ["nots3cr3t"] }, - "encryptedPassword": { - "title": "Flag for encrypted password (true) or plain text password (false or not defined)", + "hashedPassword": { + "title": "Flag for hashed password (true) or plain text password (false or not defined)", + "type": "boolean" + }, + "autologin": { + "title": "Automatic user login", + "description": "Whether the user should be automatically logged in (only relevant in desktop systems)", "type": "boolean" } }, @@ -399,11 +425,11 @@ "additionalProperties": false, "properties": { "password": { - "title": "Root password (plain text or encrypted depending on the \"encryptedPassword\" field)", + "title": "Root password (plain text or hashed depending on the \"hashedPassword\" field)", "type": "string" }, - "encryptedPassword": { - "title": "Flag for encrypted password (true) or plain text password (false or not defined)", + "hashedPassword": { + "title": "Flag for hashed password (true) or plain text password (false or not defined)", "type": "boolean" }, "sshPublicKey": { diff --git a/rust/agama-lib/src/bootloader.rs b/rust/agama-lib/src/bootloader.rs new file mode 100644 index 0000000000..24dbb6c8d2 --- /dev/null +++ b/rust/agama-lib/src/bootloader.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements support for handling the bootloader settings + +pub mod client; +pub mod http_client; +pub mod model; +pub mod proxies; +pub mod store; diff --git a/rust/agama-lib/src/bootloader/client.rs b/rust/agama-lib/src/bootloader/client.rs new file mode 100644 index 0000000000..10287522e4 --- /dev/null +++ b/rust/agama-lib/src/bootloader/client.rs @@ -0,0 +1,57 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a client to access Agama's D-Bus API related to Bootloader management. + +use zbus::Connection; + +use crate::{ + bootloader::{model::BootloaderSettings, proxies::BootloaderProxy}, + error::ServiceError, +}; + +/// Client to connect to Agama's D-Bus API for Bootloader management. +#[derive(Clone)] +pub struct BootloaderClient<'a> { + bootloader_proxy: BootloaderProxy<'a>, +} + +impl<'a> BootloaderClient<'a> { + pub async fn new(connection: Connection) -> Result, ServiceError> { + let bootloader_proxy = BootloaderProxy::new(&connection).await?; + + Ok(Self { bootloader_proxy }) + } + + pub async fn get_config(&self) -> Result { + let serialized_string = self.bootloader_proxy.get_config().await?; + let settings = serde_json::from_str(serialized_string.as_str())?; + Ok(settings) + } + + pub async fn set_config(&self, config: &BootloaderSettings) -> Result<(), ServiceError> { + // ignore return value as currently it does not fail and who knows what future brings + // but it should not be part of result and instead transformed to ServiceError + self.bootloader_proxy + .set_config(serde_json::to_string(config)?.as_str()) + .await?; + Ok(()) + } +} diff --git a/rust/agama-lib/src/bootloader/http_client.rs b/rust/agama-lib/src/bootloader/http_client.rs new file mode 100644 index 0000000000..9cde4444bc --- /dev/null +++ b/rust/agama-lib/src/bootloader/http_client.rs @@ -0,0 +1,43 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a client to access Agama's HTTP API related to Bootloader management. + +use crate::base_http_client::BaseHTTPClient; +use crate::bootloader::model::BootloaderSettings; +use crate::ServiceError; + +pub struct BootloaderHTTPClient { + client: BaseHTTPClient, +} + +impl BootloaderHTTPClient { + pub fn new(base: BaseHTTPClient) -> Self { + Self { client: base } + } + + pub async fn get_config(&self) -> Result { + self.client.get("/bootloader/config").await + } + + pub async fn set_config(&self, config: &BootloaderSettings) -> Result<(), ServiceError> { + self.client.put_void("/bootloader/config", config).await + } +} diff --git a/rust/agama-lib/src/bootloader/model.rs b/rust/agama-lib/src/bootloader/model.rs new file mode 100644 index 0000000000..ac50f3dd16 --- /dev/null +++ b/rust/agama-lib/src/bootloader/model.rs @@ -0,0 +1,30 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements a data model for Bootloader configuration. + +use serde::{Deserialize, Serialize}; + +/// Represents a Bootloader +#[derive(Clone, Debug, Serialize, Deserialize, Default, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BootloaderSettings { + pub stop_on_boot_menu: bool, +} diff --git a/rust/agama-lib/src/bootloader/proxies.rs b/rust/agama-lib/src/bootloader/proxies.rs new file mode 100644 index 0000000000..48e2543ed5 --- /dev/null +++ b/rust/agama-lib/src/bootloader/proxies.rs @@ -0,0 +1,35 @@ +//! # D-Bus interface proxy for: `org.opensuse.Agama.Storage1.Bootloader` +//! +//! This code was generated by `zbus-xmlgen` `5.0.1` from D-Bus introspection data. +//! Source: `org.opensuse.Agama.Storage1.bus.xml`. +//! +//! You may prefer to adapt it, instead of using it verbatim. +//! +//! More information can be found in the [Writing a client proxy] section of the zbus +//! documentation. +//! +//! This type implements the [D-Bus standard interfaces], (`org.freedesktop.DBus.*`) for which the +//! following zbus API can be used: +//! +//! * [`zbus::fdo::IntrospectableProxy`] +//! * [`zbus::fdo::ObjectManagerProxy`] +//! * [`zbus::fdo::PropertiesProxy`] +//! +//! Consequently `zbus-xmlgen` did not generate code for the above interfaces. +//! +//! [Writing a client proxy]: https://dbus2.github.io/zbus/client.html +//! [D-Bus standard interfaces]: https://dbus.freedesktop.org/doc/dbus-specification.html#standard-interfaces, +use zbus::proxy; +#[proxy( + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1", + interface = "org.opensuse.Agama.Storage1.Bootloader", + assume_defaults = true +)] +pub trait Bootloader { + /// GetConfig method + fn get_config(&self) -> zbus::Result; + + /// SetConfig method + fn set_config(&self, serialized_config: &str) -> zbus::Result; +} diff --git a/rust/agama-lib/src/bootloader/store.rs b/rust/agama-lib/src/bootloader/store.rs new file mode 100644 index 0000000000..b41b6d701d --- /dev/null +++ b/rust/agama-lib/src/bootloader/store.rs @@ -0,0 +1,49 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Implements the store for the bootloader settings. + +use crate::base_http_client::BaseHTTPClient; +use crate::error::ServiceError; + +use super::http_client::BootloaderHTTPClient; +use super::model::BootloaderSettings; + +/// Loads and stores the bootloader settings from/to the HTTP service. +pub struct BootloaderStore { + bootloader_client: BootloaderHTTPClient, +} + +impl BootloaderStore { + pub fn new(client: BaseHTTPClient) -> Result { + Ok(Self { + bootloader_client: BootloaderHTTPClient::new(client), + }) + } + + pub async fn load(&self) -> Result { + self.bootloader_client.get_config().await + } + + pub async fn store(&self, settings: &BootloaderSettings) -> Result<(), ServiceError> { + self.bootloader_client.set_config(settings).await?; + Ok(()) + } +} diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 380aaafd8d..8d9a31790c 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -23,7 +23,7 @@ use std::io; use thiserror::Error; use zbus::{self, zvariant}; -use crate::transfer::TransferError; +use crate::utils::TransferError; #[derive(Error, Debug)] pub enum ServiceError { diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index fe5113da0b..77ebdf9a59 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -21,6 +21,7 @@ //! Configuration settings handling //! //! This module implements the mechanisms to load and store the installation settings. +use crate::bootloader::model::BootloaderSettings; use crate::{ localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, users::UserSettings, @@ -39,6 +40,8 @@ use std::path::Path; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstallSettings { + #[serde(default)] + pub bootloader: Option, #[serde(default, flatten)] pub user: Option, #[serde(default)] diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index a2213c2cad..23c27abc0d 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -45,6 +45,7 @@ pub mod auth; pub mod base_http_client; +pub mod bootloader; pub mod error; pub mod install_settings; pub mod jobs; @@ -66,7 +67,7 @@ pub use store::Store; pub mod openapi; pub mod questions; pub mod scripts; -pub mod transfer; +pub mod utils; use crate::error::ServiceError; use reqwest::{header, Client}; diff --git a/rust/agama-lib/src/product/settings.rs b/rust/agama-lib/src/product/settings.rs index d7a050941f..10c6b23569 100644 --- a/rust/agama-lib/src/product/settings.rs +++ b/rust/agama-lib/src/product/settings.rs @@ -28,6 +28,8 @@ use serde::{Deserialize, Serialize}; pub struct ProductSettings { /// ID of the product to install (e.g., "ALP", "Tumbleweed", etc.) pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub registration_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub registration_email: Option, } diff --git a/rust/agama-lib/src/profile.rs b/rust/agama-lib/src/profile.rs index d05a91abd6..b1adffef3b 100644 --- a/rust/agama-lib/src/profile.rs +++ b/rust/agama-lib/src/profile.rs @@ -23,43 +23,49 @@ use anyhow::Context; use jsonschema::JSONSchema; use log::info; use serde_json; -use std::{fs, io::Write, path::Path, process::Command}; +use std::{ + fs::{self, File}, + io::Write, + path::Path, + process::Command, +}; use tempfile::{tempdir, TempDir}; use url::Url; /// Downloads and converts autoyast profile. -pub struct AutoyastProfile { - url: Url, +pub struct AutoyastProfileImporter { + content: String, } -impl AutoyastProfile { - pub fn new(url: &Url) -> anyhow::Result { - Ok(Self { url: url.clone() }) - } - - pub fn read_into(&self, mut out_fd: impl Write) -> anyhow::Result<()> { - let path = self.url.path(); - if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { - let content = self.read_from_autoyast()?; - out_fd.write_all(content.as_bytes())?; - Ok(()) - } else { - let msg = format!("Unsupported AutoYaST format at {}", self.url); - Err(anyhow::Error::msg(msg)) +impl AutoyastProfileImporter { + pub fn read(url: &Url) -> anyhow::Result { + let path = url.path(); + if !path.ends_with(".xml") && !path.ends_with(".erb") && !path.ends_with('/') { + let msg = format!("Unsupported AutoYaST format at {}", url); + return Err(anyhow::Error::msg(msg)); } - } - fn read_from_autoyast(&self) -> anyhow::Result { const TMP_DIR_PREFIX: &str = "autoyast"; const AUTOINST_JSON: &str = "autoinst.json"; let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX)?; Command::new("agama-autoyast") - .args([self.url.as_str(), &tmp_dir.path().to_string_lossy()]) + .args([url.as_str(), &tmp_dir.path().to_string_lossy()]) .status()?; let autoinst_json = tmp_dir.path().join(AUTOINST_JSON); - Ok(fs::read_to_string(autoinst_json)?) + let content = fs::read_to_string(autoinst_json)?; + Ok(Self { content }) + } + + pub fn write(&self, mut file: impl Write) -> anyhow::Result<()> { + file.write_all(self.content.as_bytes())?; + Ok(()) + } + + pub fn write_file>(&self, path: P) -> anyhow::Result<()> { + let mut file = File::create(path)?; + self.write(&mut file) } } @@ -170,7 +176,7 @@ impl ProfileEvaluator { .args(["-json"]) .output() .context("Failed to run lshw")?; - let helpers = fs::read_to_string("agama.libsonnet") + let helpers = fs::read_to_string("share/agama.libsonnet") .or_else(|_| fs::read_to_string("/usr/share/agama-cli/agama.libsonnet")) .context("Failed to read agama.libsonnet")?; let mut file = fs::File::create(path)?; diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs index 575981aeb7..fec37981ee 100644 --- a/rust/agama-lib/src/scripts/error.rs +++ b/rust/agama-lib/src/scripts/error.rs @@ -21,7 +21,7 @@ use std::io; use thiserror::Error; -use crate::transfer::TransferError; +use crate::utils::TransferError; #[derive(Error, Debug)] pub enum ScriptError { diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index ddf3c651f2..3ad9343712 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -28,7 +28,7 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::transfer::Transfer; +use crate::utils::Transfer; use super::ScriptError; diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 14efddfea5..ff4779d1e0 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -22,6 +22,7 @@ // TODO: quickly explain difference between FooSettings and FooStore, with an example use crate::base_http_client::BaseHTTPClient; +use crate::bootloader::store::BootloaderStore; use crate::error::ServiceError; use crate::install_settings::InstallSettings; use crate::manager::{InstallationPhase, ManagerHTTPClient}; @@ -38,6 +39,7 @@ use crate::{ /// /// This struct uses the default connection built by [connection function](super::connection). pub struct Store { + bootloader: BootloaderStore, users: UsersStore, network: NetworkStore, product: ProductStore, @@ -52,6 +54,7 @@ pub struct Store { impl Store { pub async fn new(http_client: BaseHTTPClient) -> Result { Ok(Self { + bootloader: BootloaderStore::new(http_client.clone())?, localization: LocalizationStore::new(http_client.clone())?, users: UsersStore::new(http_client.clone())?, network: NetworkStore::new(http_client.clone()).await?, @@ -67,6 +70,7 @@ impl Store { /// Loads the installation settings from the HTTP interface. pub async fn load(&self) -> Result { let mut settings = InstallSettings { + bootloader: Some(self.bootloader.load().await?), network: Some(self.network.load().await?), software: Some(self.software.load().await?), user: Some(self.users.load().await?), @@ -121,6 +125,9 @@ impl Store { if settings.storage.is_some() || settings.storage_autoyast.is_some() { self.storage.store(&settings.into()).await? } + if let Some(bootloader) = &settings.bootloader { + self.bootloader.store(bootloader).await?; + } Ok(()) } diff --git a/rust/agama-lib/src/users/client.rs b/rust/agama-lib/src/users/client.rs index 9747619236..6faafd8961 100644 --- a/rust/agama-lib/src/users/client.rs +++ b/rust/agama-lib/src/users/client.rs @@ -35,8 +35,8 @@ pub struct FirstUser { pub user_name: String, /// First user's password (in clear text) pub password: String, - /// Whether the password is encrypted (true) or is plain text (false) - pub encrypted_password: bool, + /// Whether the password is hashed (true) or is plain text (false) + pub hashed_password: bool, /// Whether auto-login should enabled or not pub autologin: bool, } @@ -48,7 +48,7 @@ impl FirstUser { full_name: data.0, user_name: data.1, password: data.2, - encrypted_password: data.3, + hashed_password: data.3, autologin: data.4, }) } @@ -73,12 +73,8 @@ impl<'a> UsersClient<'a> { } /// SetRootPassword method - pub async fn set_root_password( - &self, - value: &str, - encrypted: bool, - ) -> Result { - Ok(self.users_proxy.set_root_password(value, encrypted).await?) + pub async fn set_root_password(&self, value: &str, hashed: bool) -> Result { + Ok(self.users_proxy.set_root_password(value, hashed).await?) } pub async fn remove_root_password(&self) -> Result { @@ -110,7 +106,7 @@ impl<'a> UsersClient<'a> { &first_user.full_name, &first_user.user_name, &first_user.password, - first_user.encrypted_password, + first_user.hashed_password, first_user.autologin, std::collections::HashMap::new(), ) diff --git a/rust/agama-lib/src/users/http_client.rs b/rust/agama-lib/src/users/http_client.rs index a879826ddc..1008ed48d8 100644 --- a/rust/agama-lib/src/users/http_client.rs +++ b/rust/agama-lib/src/users/http_client.rs @@ -58,15 +58,11 @@ impl UsersHTTPClient { /// SetRootPassword method. /// Returns 0 if successful (always, for current backend) - pub async fn set_root_password( - &self, - value: &str, - encrypted: bool, - ) -> Result { + pub async fn set_root_password(&self, value: &str, hashed: bool) -> Result { let rps = RootPatchSettings { sshkey: None, password: Some(value.to_owned()), - encrypted_password: Some(encrypted), + hashed_password: Some(hashed), }; let ret = self.client.patch("/users/root", &rps).await?; Ok(ret) @@ -84,7 +80,7 @@ impl UsersHTTPClient { let rps = RootPatchSettings { sshkey: Some(value.to_owned()), password: None, - encrypted_password: None, + hashed_password: None, }; let ret = self.client.patch("/users/root", &rps).await?; Ok(ret) diff --git a/rust/agama-lib/src/users/model.rs b/rust/agama-lib/src/users/model.rs index 66d93c9d6f..90e07fecfe 100644 --- a/rust/agama-lib/src/users/model.rs +++ b/rust/agama-lib/src/users/model.rs @@ -35,6 +35,6 @@ pub struct RootPatchSettings { pub sshkey: Option, /// empty string here means remove password for root pub password: Option, - /// specify if patched password is provided in encrypted form - pub encrypted_password: Option, + /// specify if patched password is provided in plain text (default) or hashed + pub hashed_password: Option, } diff --git a/rust/agama-lib/src/users/proxies.rs b/rust/agama-lib/src/users/proxies.rs index b3743701de..f75921b2ee 100644 --- a/rust/agama-lib/src/users/proxies.rs +++ b/rust/agama-lib/src/users/proxies.rs @@ -47,7 +47,7 @@ use zbus::proxy; /// * full name /// * user name /// * password -/// * encrypted_password (true = encrypted, false = plain text) +/// * hashed_password (true = hashed, false = plain text) /// * auto-login (enabled or not) /// * some optional and additional data // NOTE: Manually added to this file. @@ -79,13 +79,13 @@ pub trait Users1 { full_name: &str, user_name: &str, password: &str, - encrypted_password: bool, + hashed_password: bool, auto_login: bool, data: std::collections::HashMap<&str, &zbus::zvariant::Value<'_>>, ) -> zbus::Result<(bool, Vec)>; /// SetRootPassword method - fn set_root_password(&self, value: &str, encrypted: bool) -> zbus::Result; + fn set_root_password(&self, value: &str, hashed: bool) -> zbus::Result; /// SetRootSSHKey method #[zbus(name = "SetRootSSHKey")] diff --git a/rust/agama-lib/src/users/settings.rs b/rust/agama-lib/src/users/settings.rs index bd5eea47af..18da06a493 100644 --- a/rust/agama-lib/src/users/settings.rs +++ b/rust/agama-lib/src/users/settings.rs @@ -43,8 +43,8 @@ pub struct FirstUserSettings { pub user_name: Option, /// First user's password (in clear text) pub password: Option, - /// Whether the password is encrypted or is plain text - pub encrypted_password: Option, + /// Whether the password is hashed or is plain text + pub hashed_password: Option, /// Whether auto-login should enabled or not pub autologin: Option, } @@ -58,9 +58,10 @@ pub struct RootUserSettings { /// Root's password (in clear text) #[serde(skip_serializing)] pub password: Option, - /// Whether the password is encrypted or is plain text + /// Whether the password is hashed or is plain text #[serde(skip_serializing)] - pub encrypted_password: Option, + pub hashed_password: Option, /// Root SSH public key + #[serde(skip_serializing_if = "Option::is_none")] pub ssh_public_key: Option, } diff --git a/rust/agama-lib/src/users/store.rs b/rust/agama-lib/src/users/store.rs index 67289d57d4..77995a027f 100644 --- a/rust/agama-lib/src/users/store.rs +++ b/rust/agama-lib/src/users/store.rs @@ -47,7 +47,7 @@ impl UsersStore { autologin: Some(first_user.autologin), full_name: Some(first_user.full_name), password: Some(first_user.password), - encrypted_password: Some(first_user.encrypted_password), + hashed_password: Some(first_user.hashed_password), }; let mut root_user = RootUserSettings::default(); let ssh_public_key = self.users_client.root_ssh_key().await?; @@ -78,17 +78,17 @@ impl UsersStore { full_name: settings.full_name.clone().unwrap_or_default(), autologin: settings.autologin.unwrap_or_default(), password: settings.password.clone().unwrap_or_default(), - encrypted_password: settings.encrypted_password.unwrap_or_default(), + hashed_password: settings.hashed_password.unwrap_or_default(), }; self.users_client.set_first_user(&first_user).await } async fn store_root_user(&self, settings: &RootUserSettings) -> Result<(), ServiceError> { - let encrypted_password = settings.encrypted_password.unwrap_or_default(); + let hashed_password = settings.hashed_password.unwrap_or_default(); if let Some(root_password) = &settings.password { self.users_client - .set_root_password(root_password, encrypted_password) + .set_root_password(root_password, hashed_password) .await?; } @@ -128,7 +128,7 @@ mod test { "fullName": "Tux", "userName": "tux", "password": "fish", - "encryptedPassword": false, + "hashedPassword": false, "autologin": true }"#, ); @@ -153,13 +153,13 @@ mod test { full_name: Some("Tux".to_owned()), user_name: Some("tux".to_owned()), password: Some("fish".to_owned()), - encrypted_password: Some(false), + hashed_password: Some(false), autologin: Some(true), }; let root_user = RootUserSettings { // FIXME this is weird: no matter what HTTP reports, we end up with None password: None, - encrypted_password: None, + hashed_password: None, ssh_public_key: Some("keykeykey".to_owned()), }; let expected = UserSettings { @@ -184,7 +184,7 @@ mod test { when.method(PUT) .path("/api/users/first") .header("content-type", "application/json") - .body(r#"{"fullName":"Tux","userName":"tux","password":"fish","encryptedPassword":false,"autologin":true}"#); + .body(r#"{"fullName":"Tux","userName":"tux","password":"fish","hashedPassword":false,"autologin":true}"#); then.status(200); }); // note that we use 2 requests for root @@ -192,14 +192,14 @@ mod test { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") - .body(r#"{"sshkey":null,"password":"1234","encryptedPassword":false}"#); + .body(r#"{"sshkey":null,"password":"1234","hashedPassword":false}"#); then.status(200).body("0"); }); let root_mock2 = server.mock(|when, then| { when.method(PATCH) .path("/api/users/root") .header("content-type", "application/json") - .body(r#"{"sshkey":"keykeykey","password":null,"encryptedPassword":null}"#); + .body(r#"{"sshkey":"keykeykey","password":null,"hashedPassword":null}"#); then.status(200).body("0"); }); let url = server.url("/api"); @@ -210,12 +210,12 @@ mod test { full_name: Some("Tux".to_owned()), user_name: Some("tux".to_owned()), password: Some("fish".to_owned()), - encrypted_password: Some(false), + hashed_password: Some(false), autologin: Some(true), }; let root_user = RootUserSettings { password: Some("1234".to_owned()), - encrypted_password: Some(false), + hashed_password: Some(false), ssh_public_key: Some("keykeykey".to_owned()), }; let settings = UserSettings { diff --git a/rust/agama-lib/src/utils.rs b/rust/agama-lib/src/utils.rs new file mode 100644 index 0000000000..f6fb4f9020 --- /dev/null +++ b/rust/agama-lib/src/utils.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Utility module for Agama. + +mod file_format; +mod transfer; + +pub use file_format::*; +pub use transfer::*; diff --git a/rust/agama-lib/src/utils/file_format.rs b/rust/agama-lib/src/utils/file_format.rs new file mode 100644 index 0000000000..48c857e3c3 --- /dev/null +++ b/rust/agama-lib/src/utils/file_format.rs @@ -0,0 +1,158 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! File format detection for Agama. +//! +//! It implements a simple API to detect the file formats that are relevent for Agama. + +use std::{ + io::Write, + path::Path, + process::{Command, Stdio}, +}; + +/// Relevant file formats for Agama. +#[derive(Debug, PartialEq, Copy, Clone)] +pub enum FileFormat { + Json, + Jsonnet, + Script, + Unknown, +} + +const JSONNETFMT_BIN: &str = "jsonnetfmt"; + +impl FileFormat { + /// Tries to guess the file format from the content of a file. + pub fn from_file>(file_path: P) -> Result { + let content = std::fs::read_to_string(file_path)?; + Ok(Self::from_string(&content)) + } + + /// Tries to guess the file format from a string. + pub fn from_string(content: &str) -> Self { + if Self::is_json(content) { + return Self::Json; + } else if Self::is_script(content) { + return Self::Script; + } else if Self::is_jsonnet(content) { + return Self::Jsonnet; + } + + Self::Unknown + } + + /// Whether the format is JSON. + /// + /// It tries to parse the content as JSON and returns `true` if it succeeds. + fn is_json(content: &str) -> bool { + let json = serde_json::from_str::(content); + json.is_ok() + } + + /// Whether the format is Jsonnet. + /// + /// It tries to process the content with the jsonnetfmt tool and returns `true` if it succeeds. + fn is_jsonnet(content: &str) -> bool { + let child = Command::new(JSONNETFMT_BIN) + .args(["-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + let Ok(mut child) = child else { + return false; + }; + + let Some(mut stdin) = child.stdin.take() else { + return false; + }; + + stdin + .write_all(content.as_bytes()) + .expect("Failed to write to stdin"); + drop(stdin); + + child.wait().is_ok_and(|s| s.success()) + } + + /// Whether is is a script. + /// + /// It returns `true` if the content starts with a shebang. However, it excludes the + /// case of a "jsonnet" script because it needs special handling: injecting the hardware + /// information and processing its output. + fn is_script(content: &str) -> bool { + let Some(first_line) = content.lines().next() else { + return false; + }; + first_line.starts_with("#!") && !first_line.contains("jsonnet") + } +} + +#[cfg(test)] +mod tests { + use super::FileFormat; + + #[test] + fn test_json() { + let content = r#" + { "name:": "value"} + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Json); + } + + #[test] + fn test_jsonnet() { + let content = r#" + { name: "value" } + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Jsonnet); + + let content = r#"#!/usr/bin/jsonnet + print + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Jsonnet); + } + + #[test] + fn test_script() { + let content = r#"#!/bin/bash + echo "Hello World" + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Script); + + let content = r#"#!/usr/bin/python3 + print + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Script); + } + + #[test] + fn test_unknown() { + let empty = ""; + assert_eq!(FileFormat::from_string(empty), FileFormat::Unknown); + + let text = r#" + Some text content. + "#; + assert_eq!(FileFormat::from_string(text), FileFormat::Unknown); + } +} diff --git a/rust/agama-lib/src/transfer.rs b/rust/agama-lib/src/utils/transfer.rs similarity index 54% rename from rust/agama-lib/src/transfer.rs rename to rust/agama-lib/src/utils/transfer.rs index 2566c93c7b..a9c9475387 100644 --- a/rust/agama-lib/src/transfer.rs +++ b/rust/agama-lib/src/utils/transfer.rs @@ -1,3 +1,23 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + //! File transfer API for Agama. //! //! Implement a file transfer API which, in the future, will support Agama specific URLs. Check the @@ -27,6 +47,8 @@ impl Transfer { /// * `out_fd`: where to write the data. pub fn get(url: &str, mut out_fd: impl Write) -> TransferResult<()> { let mut handle = Easy::new(); + handle.follow_location(true)?; + handle.fail_on_error(true)?; handle.url(url)?; let mut transfer = handle.transfer(); diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index efde389cf8..a365595124 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -25,7 +25,7 @@ async-trait = "0.1.83" axum = { version = "0.7.7", features = ["ws"] } serde_json = "1.0.128" tower-http = { version = "0.5.2", features = ["compression-br", "fs", "trace"] } -tracing-subscriber = "0.3.18" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tracing-journald = "0.3.0" tracing = "0.1.40" clap = { version = "4.5.19", features = ["derive", "wrap_help"] } diff --git a/rust/agama-server/src/bootloader.rs b/rust/agama-server/src/bootloader.rs new file mode 100644 index 0000000000..f0e99a215d --- /dev/null +++ b/rust/agama-server/src/bootloader.rs @@ -0,0 +1,21 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +pub mod web; diff --git a/rust/agama-server/src/bootloader/web.rs b/rust/agama-server/src/bootloader/web.rs new file mode 100644 index 0000000000..bcce7944f6 --- /dev/null +++ b/rust/agama-server/src/bootloader/web.rs @@ -0,0 +1,93 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! This module implements the web API for the storage service. +//! +//! The module offers one public function: +//! +//! * `storage_service` which returns the Axum service. +//! +//! stream is not needed, as we do not need to emit signals (for NOW). + +use agama_lib::{ + bootloader::{client::BootloaderClient, model::BootloaderSettings}, + error::ServiceError, +}; +use axum::{extract::State, routing::put, Json, Router}; + +use crate::error; + +#[derive(Clone)] +struct BootloaderState<'a> { + client: BootloaderClient<'a>, +} + +/// Sets up and returns the axum service for the storage module. +pub async fn bootloader_service(dbus: zbus::Connection) -> Result { + let client = BootloaderClient::new(dbus).await?; + let state = BootloaderState { client }; + let router = Router::new() + .route("/config", put(set_config).get(get_config)) + .with_state(state); + Ok(router) +} + +/// Returns the bootloader configuration. +/// +/// * `state` : service state. +#[utoipa::path( + get, + path = "/config", + context_path = "/api/bootloader", + operation_id = "get_bootloader_config", + responses( + (status = 200, description = "bootloader configuration", body = BootloaderSettings), + (status = 400, description = "The D-Bus service could not perform the action") + ) +)] +async fn get_config( + State(state): State>, +) -> Result, error::Error> { + // StorageSettings is just a wrapper over serde_json::value::RawValue + let settings = state.client.get_config().await?; + Ok(Json(settings)) +} + +/// Sets the bootloader configuration. +/// +/// * `state`: service state. +/// * `config`: bootloader configuration. +#[utoipa::path( + put, + path = "/config", + context_path = "/api/bootloader", + operation_id = "set_bootloader_config", + responses( + (status = 200, description = "Set the bootloader configuration"), + (status = 400, description = "The D-Bus service could not perform the action") + ) +)] +async fn set_config( + State(state): State>, + Json(settings): Json, +) -> Result, error::Error> { + state.client.set_config(&settings).await?; + Ok(Json(())) +} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index c26eaa774f..8ad2e6cb35 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -18,6 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +pub mod bootloader; pub mod cert; pub mod dbus; pub mod error; diff --git a/rust/agama-server/src/logs.rs b/rust/agama-server/src/logs.rs index 4ac44fb026..d9fd860947 100644 --- a/rust/agama-server/src/logs.rs +++ b/rust/agama-server/src/logs.rs @@ -22,17 +22,27 @@ use anyhow::Context; use libsystemd::logging; -use tracing_subscriber::prelude::*; +use tracing::level_filters::LevelFilter; +use tracing_subscriber::{prelude::*, EnvFilter}; /// Initializes the logging mechanism. /// /// It is based on [Tracing](https://github.com/tokio-rs/tracing), part of the Tokio ecosystem. pub fn init_logging() -> anyhow::Result<()> { + let filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .with_env_var("AGAMA_LOG") + .from_env_lossy(); + if logging::connected_to_journal() { let journald = tracing_journald::layer().context("could not connect to journald")?; - tracing_subscriber::registry().with(journald).init(); + tracing_subscriber::registry() + .with(filter) + .with(journald) + .init(); } else { let subscriber = tracing_subscriber::fmt() + .with_env_filter(filter) .with_file(true) .with_line_number(true) .compact() diff --git a/rust/agama-server/src/users/web.rs b/rust/agama-server/src/users/web.rs index de9c2901d7..260ec35133 100644 --- a/rust/agama-server/src/users/web.rs +++ b/rust/agama-server/src/users/web.rs @@ -90,7 +90,7 @@ async fn first_user_changed_stream( full_name: user.0, user_name: user.1, password: user.2, - encrypted_password: user.3, + hashed_password: user.3, autologin: user.4, }; return Some(Event::FirstUserChanged(user_struct)); @@ -243,7 +243,7 @@ async fn patch_root( } else { state .users - .set_root_password(&password, config.encrypted_password == Some(true)) + .set_root_password(&password, config.hashed_password == Some(true)) .await? } } diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 2a7ad06e75..8904565ff7 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -25,6 +25,7 @@ //! * Serve the code for the web user interface (not implemented yet). use crate::{ + bootloader::web::bootloader_service, error::Error, l10n::web::l10n_service, manager::web::{manager_service, manager_stream}, @@ -79,6 +80,7 @@ where .add_service("/manager", manager_service(dbus.clone()).await?) .add_service("/software", software_service(dbus.clone()).await?) .add_service("/storage", storage_service(dbus.clone()).await?) + .add_service("/bootloader", bootloader_service(dbus.clone()).await?) .add_service("/network", network_service(network_adapter, events).await?) .add_service("/questions", questions_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone()).await?) diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 1592161228..848b0b327c 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -24,6 +24,8 @@ mod network; pub use network::NetworkApiDocBuilder; mod storage; pub use storage::StorageApiDocBuilder; +mod bootloader; +pub use bootloader::BootloaderApiDocBuilder; mod software; pub use software::SoftwareApiDocBuilder; mod l10n; diff --git a/rust/agama-server/src/web/docs/bootloader.rs b/rust/agama-server/src/web/docs/bootloader.rs new file mode 100644 index 0000000000..974efa9135 --- /dev/null +++ b/rust/agama-server/src/web/docs/bootloader.rs @@ -0,0 +1,43 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; + +use super::ApiDocBuilder; +pub struct BootloaderApiDocBuilder; + +impl ApiDocBuilder for BootloaderApiDocBuilder { + fn title(&self) -> String { + "Bootloader HTTP API".to_string() + } + + fn paths(&self) -> Paths { + PathsBuilder::new() + .path_from::() + .path_from::() + .build() + } + + fn components(&self) -> Components { + ComponentsBuilder::new() + .schema_from::() + .build() + } +} diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 4024e507a9..ecf3c6384f 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,35 @@ +------------------------------------------------------------------- +Fri Dec 20 12:17:26 UTC 2024 - Josef Reidinger + +- Add bootloader.stopOnBootMenu section to profile to allow stop + during boot for openQA (gh#agama-project/agama#1840) + +------------------------------------------------------------------- +Thu Dec 19 11:39:14 UTC 2024 - Imobach Gonzalez Sosa + +- Fix several validation issues (gh#agama-project/agama#1845). + +------------------------------------------------------------------- +Wed Dec 18 12:32:00 UTC 2024 - Imobach Gonzalez Sosa + +- Introduce a new AGAMA_LOG environment variable to control what to + log (gh#agama-project/agama#1843). + +------------------------------------------------------------------- +Wed Dec 18 10:11:44 UTC 2024 - Imobach Gonzalez Sosa + +- Add jsonnet as a BuildRequires dependency because it is needed + when running tests (gh#agama-project/agama#1842). + +------------------------------------------------------------------- +Thu Dec 12 16:42:18 UTC 2024 - Imobach Gonzalez Sosa + +- Fix profile URL handling (bsc#1234362): + - Follow redirections. + - Determine the file format from the content instead of the + extension. It does not apply to AutoYaST profiles, where it still + uses the extension in the URL for backward compatibility. + ------------------------------------------------------------------- Tue Dec 3 12:58:48 UTC 2024 - Imobach Gonzalez Sosa diff --git a/rust/package/agama.spec b/rust/package/agama.spec index 4ac788432c..d7e45ce241 100644 --- a/rust/package/agama.spec +++ b/rust/package/agama.spec @@ -37,6 +37,7 @@ BuildRequires: dbus-1-daemon BuildRequires: clang-devel BuildRequires: pkgconfig(pam) # required by autoinstallation +BuildRequires: jsonnet Requires: jsonnet Requires: lshw # required by "agama logs store" diff --git a/rust/share/agama-web-server.service b/rust/share/agama-web-server.service index 9b987938eb..0c8b27a4d8 100644 --- a/rust/share/agama-web-server.service +++ b/rust/share/agama-web-server.service @@ -6,6 +6,7 @@ After=network-online.target agama.service agama-hostname.service BindsTo=agama.service [Service] +Environment="AGAMA_LOG=debug,zbus=info" Type=notify ExecStart=/usr/bin/agama-web-server serve --address :::80 --address2 :::443 PIDFile=/run/agama/web.pid diff --git a/service/.rubocop.yml b/service/.rubocop.yml index f5bea90ce8..2039f37050 100644 --- a/service/.rubocop.yml +++ b/service/.rubocop.yml @@ -31,3 +31,6 @@ Metrics/AbcSize: Metrics/ParameterLists: Max: 6 + +Metrics/ClassLength: + Max: 300 diff --git a/service/lib/agama/autoyast/root_reader.rb b/service/lib/agama/autoyast/root_reader.rb index 84737c8a80..6cc1866269 100755 --- a/service/lib/agama/autoyast/root_reader.rb +++ b/service/lib/agama/autoyast/root_reader.rb @@ -41,7 +41,7 @@ def read return {} unless root_user hsh = { "password" => root_user.password.value.to_s } - hsh["encryptedPassword"] = true if root_user.password.value.encrypted? + hsh["hashedPassword"] = true if root_user.password.value.encrypted? public_key = root_user.authorized_keys.first hsh["sshPublicKey"] = public_key if public_key diff --git a/service/lib/agama/autoyast/user_reader.rb b/service/lib/agama/autoyast/user_reader.rb index 84335845bb..5c01291075 100755 --- a/service/lib/agama/autoyast/user_reader.rb +++ b/service/lib/agama/autoyast/user_reader.rb @@ -46,7 +46,7 @@ def read "password" => user.password.value.to_s } - hsh["encryptedPassword"] = true if user.password.value.encrypted? + hsh["hashedPassword"] = true if user.password.value.encrypted? { "user" => hsh } end diff --git a/service/lib/agama/dbus/clients/storage.rb b/service/lib/agama/dbus/clients/storage.rb index f6201d1dc0..094b2eaccb 100644 --- a/service/lib/agama/dbus/clients/storage.rb +++ b/service/lib/agama/dbus/clients/storage.rb @@ -24,6 +24,7 @@ require "agama/dbus/clients/with_locale" require "agama/dbus/clients/with_progress" require "agama/dbus/clients/with_issues" +require "json" module Agama module DBus @@ -62,6 +63,24 @@ def finish dbus_object.Finish end + # Gets the current storage config. + # + # @return [Hash] + def config + # Use storage iface to avoid collision with bootloader iface + serialized_config = dbus_object[STORAGE_IFACE].GetConfig + JSON.parse(serialized_config, symbolize_names: true) + end + + # Sets the storage config. + # + # @param config [Hash] + def config=(config) + serialized_config = JSON.pretty_generate(config) + # Use storage iface to avoid collision with bootloader iface + dbus_object[STORAGE_IFACE].SetConfig(serialized_config) + end + private # @return [::DBus::Object] diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 0514b159b5..665ece1828 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -144,6 +144,40 @@ def deprecated_system dbus_reader(:deprecated_system, "b") end + BOOTLOADER_INTERFACE = "org.opensuse.Agama.Storage1.Bootloader" + private_constant :BOOTLOADER_INTERFACE + + # Applies the given serialized config according to the JSON schema. + # + # + # @raise If the config is not valid. + # + # @param serialized_config [String] Serialized storage config. + # @return [Integer] 0 success; 1 error + def load_bootloader_config_from_json(serialized_config) + logger.info("Setting bootloader config from D-Bus: #{serialized_config}") + + backend.bootloader.config.load_json(serialized_config) + + 0 + end + + # Gets and serializes the storage config used to calculate the current proposal. + # + # @return [String] Serialized config according to the JSON schema. + def bootloader_config_as_json + backend.bootloader.config.to_json + end + + dbus_interface BOOTLOADER_INTERFACE do + dbus_method(:SetConfig, "in serialized_config:s, out result:u") do |serialized_config| + load_bootloader_config_from_json(serialized_config) + end + dbus_method(:GetConfig, "out serialized_config:s") do + bootloader_config_as_json + end + end + # @todo Move device related properties here, for example, the list of system and staging # devices, dirty, etc. STORAGE_DEVICES_INTERFACE = "org.opensuse.Agama.Storage1.Devices" diff --git a/service/lib/agama/dbus/users.rb b/service/lib/agama/dbus/users.rb index e44e2b09a8..57b30a0b82 100644 --- a/service/lib/agama/dbus/users.rb +++ b/service/lib/agama/dbus/users.rb @@ -58,7 +58,7 @@ def issues USERS_INTERFACE = "org.opensuse.Agama.Users1" private_constant :USERS_INTERFACE - FUSER_SIG = "in FullName:s, in UserName:s, in Password:s, in EncryptedPassword:b, " \ + FUSER_SIG = "in FullName:s, in UserName:s, in Password:s, in HashedPassword:b, " \ "in AutoLogin:b, in data:a{sv}" private_constant :FUSER_SIG @@ -70,9 +70,9 @@ def issues dbus_reader :first_user, "(sssbba{sv})" dbus_method :SetRootPassword, - "in Value:s, in Encrypted:b, out result:u" do |value, encrypted| + "in Value:s, in Hashed:b, out result:u" do |value, hashed| logger.info "Setting Root Password" - backend.assign_root_password(value, encrypted) + backend.assign_root_password(value, hashed) dbus_properties_changed(USERS_INTERFACE, { "RootPasswordSet" => !value.empty? }, []) 0 @@ -99,10 +99,10 @@ def issues # It returns an Struct with the first field with the result of the operation as a boolean # and the second parameter as an array of issues found in case of failure FUSER_SIG + ", out result:(bas)" do - |full_name, user_name, password, encrypted_password, auto_login, data| + |full_name, user_name, password, hashed_password, auto_login, data| logger.info "Setting first user #{full_name}" user_issues = backend.assign_first_user(full_name, user_name, password, - encrypted_password, auto_login, data) + hashed_password, auto_login, data) if user_issues.empty? dbus_properties_changed(USERS_INTERFACE, { "FirstUser" => first_user }, []) diff --git a/service/lib/agama/manager.rb b/service/lib/agama/manager.rb index 3874d4b71d..69ef425dd8 100644 --- a/service/lib/agama/manager.rb +++ b/service/lib/agama/manager.rb @@ -83,30 +83,19 @@ def startup_phase service_status.idle end - def locale=(locale) - service_status.busy - change_process_locale(locale) - users.update_issues - start_progress_with_descriptions( - _("Load software translations"), - _("Load storage translations") - ) - progress.step { software.locale = locale } - progress.step { storage.locale = locale } - ensure - service_status.idle - finish_progress - end - # Runs the config phase def config_phase service_status.busy + first_time = installation_phase.startup? installation_phase.config start_progress_with_descriptions( _("Analyze disks"), _("Configure software") ) - progress.step { storage.probe } + # FIXME: hot-fix for bsc#1234711, see {#probe_and_recover_storage}. In autoinstallation, the + # storage config could be applied before probing. In that case, the config has to be + # recovered. + progress.step { first_time ? probe_and_recover_storage : storage.probe } progress.step { software.probe } logger.info("Config phase done") @@ -159,6 +148,21 @@ def install_phase end # rubocop:enable Metrics/AbcSize + def locale=(locale) + service_status.busy + change_process_locale(locale) + users.update_issues + start_progress_with_descriptions( + _("Load software translations"), + _("Load storage translations") + ) + progress.step { software.locale = locale } + progress.step { storage.locale = locale } + ensure + service_status.idle + finish_progress + end + # Software client # # @return [DBus::Clients::Software] @@ -273,5 +277,12 @@ def iguana? # @return [ServiceStatusRecorder] attr_reader :service_status_recorder + + # Probes storage and recover the current config, if any. + def probe_and_recover_storage + storage_config = storage.config + storage.probe + storage.config = storage_config unless storage_config.empty? + end end end diff --git a/service/lib/agama/storage/bootloader.rb b/service/lib/agama/storage/bootloader.rb new file mode 100644 index 0000000000..f1a57fdcea --- /dev/null +++ b/service/lib/agama/storage/bootloader.rb @@ -0,0 +1,89 @@ +# 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 "json" +require "bootloader/bootloader_factory" + +module Agama + module Storage + # Represents bootloader specific functionality + class Bootloader + # Represents bootloader settings + class Config + # If bootloader should stop on boot menu + attr_accessor :stop_on_boot_menu + + def initialize + @stop_on_boot_menu = false # false means use proposal, which has timeout + end + + def to_json(*_args) + result = {} + + # our json use camel case + result[:stopOnBootMenu] = stop_on_boot_menu + result.to_json + end + + def load_json(serialized_config) + hsh = JSON.parse(serialized_config, symbolize_names: true) + self.stop_on_boot_menu = hsh[:stopOnBootMenu] if hsh.include?(:stopOnBootMenu) + end + end + + attr_reader :config + + def initialize(logger) + @config = Config.new + @logger = logger + end + + def write_config + bootloader = ::Bootloader::BootloaderFactory.current + case @config.stop_on_boot_menu + when true + # grub2 based bootloaders + if bootloader.respond_to?(:grub_default) + # it is really string as timeout as we write directly to CFA, + # so it is string values from parser + bootloader.grub_default.timeout = "-1" + # systemd bootloader + elsif bootloader.respond_to?(:menu_timeout) + # here it is correct to have integer as yast2-bootloader translate it to + # "force-menu" string + bootloader.menu_timeout = -1 + else + @logger.info "bootloader #{bootloader.name} does not support forcing user input" + end + when false + # TODO: basically as we have single argument we repropose here. If more attributes comes + # we will need to do always propose first and then modify what is in config set + bootloader.propose + when nil + # not set, so do nothing and keep it as it is + else + @logger.error "unexpected value for stop_on_boot_menu #{@config.stop_on_boot_menu}" + end + end + end + end +end diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index d7ea94436c..4e5fb937ed 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -24,6 +24,7 @@ require "y2storage/storage_manager" require "y2storage/clients/inst_prepdisk" require "agama/storage/actions_generator" +require "agama/storage/bootloader" require "agama/storage/proposal" require "agama/storage/proposal_settings" require "agama/storage/callbacks" @@ -52,6 +53,9 @@ class Manager # @return [Config] attr_reader :config + # @return [Bootloader] + attr_reader :bootloader + # Constructor # # @param config [Config] @@ -61,6 +65,7 @@ def initialize(config, logger) @config = config @logger = logger + @bootloader = Bootloader.new(logger) register_proposal_callbacks on_progress_change { logger.info progress.to_s } end @@ -126,6 +131,8 @@ def install progress.step(_("Preparing bootloader proposal")) do # first make bootloader proposal to be sure that required packages are installed proposal = ::Bootloader::ProposalClient.new.make_proposal({}) + # then also apply changes to that proposal + bootloader.write_config logger.debug "Bootloader proposal #{proposal.inspect}" end progress.step(_("Adding storage-related packages")) { add_packages } diff --git a/service/lib/agama/users.rb b/service/lib/agama/users.rb index 84c17cef65..7fa0f921de 100644 --- a/service/lib/agama/users.rb +++ b/service/lib/agama/users.rb @@ -60,8 +60,8 @@ def root_ssh_key? !root_ssh_key.empty? end - def assign_root_password(value, encrypted) - pwd = if encrypted + def assign_root_password(value, hashed) + pwd = if hashed Y2Users::Password.create_encrypted(value) else Y2Users::Password.create_plain(value) @@ -99,16 +99,16 @@ def remove_root_password # @param full_name [String] # @param user_name [String] # @param password [String] - # @param encrypted_password [Boolean] true = encrypted password, false = plain text password + # @param hashed_password [Boolean] true = hashed password, false = plain text password # @param auto_login [Boolean] # @param _data [Hash] # @return [Array] the list of fatal issues found - def assign_first_user(full_name, user_name, password, encrypted_password, auto_login, _data) + def assign_first_user(full_name, user_name, password, hashed_password, auto_login, _data) remove_first_user user = Y2Users::User.new(user_name) user.gecos = [full_name] - user.password = if encrypted_password + user.password = if hashed_password Y2Users::Password.create_encrypted(password) else Y2Users::Password.create_plain(password) diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 99e9725b17..217912e49d 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,21 @@ +------------------------------------------------------------------- +Mon Dec 23 18:40:01 UTC 2024 - Josef Reidinger + +- Fix collision between hotfix and new bootlaoder dbus interface + (gh#agama-project/agama#1852) + +------------------------------------------------------------------- +Fri Dec 20 15:05:11 UTC 2024 - José Iván López González + +- Hotfix to avoid losing the storage config with auto installation + (bsc#1234711). + +------------------------------------------------------------------- +Fri Dec 20 12:18:56 UTC 2024 - Josef Reidinger + +- Add bootloader dbus interface to allow to set if bootloader + should stop on boot menu (gh#agama-project/agama#1840) + ------------------------------------------------------------------- Mon Dec 9 14:43:11 UTC 2024 - Ancor Gonzalez Sosa diff --git a/service/po/ca.po b/service/po/ca.po index a218ff7ed5..8f7cc64cc5 100644 --- a/service/po/ca.po +++ b/service/po/ca.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-26 02:49+0000\n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" "PO-Revision-Date: 2024-10-30 13:48+0000\n" "Last-Translator: David Medina \n" "Language-Team: Catalan \n" "Language-Team: Czech =2 && n<=4) ? 1 : 2;\n" "X-Generator: Weblate 5.8.3\n" -#. Runs the startup phase -#: service/lib/agama/manager.rb:91 -msgid "Load software translations" -msgstr "Načíst překlady softwaru" - -#: service/lib/agama/manager.rb:92 -msgid "Load storage translations" -msgstr "Načíst překlady paměti" - #. Runs the config phase -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Analyze disks" msgstr "Analyzovat disky" -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Configure software" msgstr "Konfigurovat software" #. Runs the install phase #. rubocop:disable Metrics/AbcSize -#: service/lib/agama/manager.rb:127 +#: service/lib/agama/manager.rb:116 msgid "Prepare disks" msgstr "Připravit disky" -#: service/lib/agama/manager.rb:128 +#: service/lib/agama/manager.rb:117 msgid "Install software" msgstr "Instalovat software" -#: service/lib/agama/manager.rb:129 +#: service/lib/agama/manager.rb:118 msgid "Configure the system" msgstr "Konfigurovat systém" +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/manager.rb:156 +msgid "Load software translations" +msgstr "Načíst překlady softwaru" + +#: service/lib/agama/manager.rb:157 +msgid "Load storage translations" +msgstr "Načíst překlady paměti" + #. Callback to handle unsigned files #. #. @param filename [String] File name @@ -236,37 +236,37 @@ msgid "Shrinking is not supported by this device" msgstr "Toto zařízení nepodporuje zmenšování" #. Probes storage devices and performs an initial proposal -#: service/lib/agama/storage/manager.rb:115 +#: service/lib/agama/storage/manager.rb:120 msgid "Activating storage devices" msgstr "Aktivuji úložná zařízení" -#: service/lib/agama/storage/manager.rb:116 +#: service/lib/agama/storage/manager.rb:121 msgid "Probing storage devices" msgstr "Sonduji úložná zařízení" -#: service/lib/agama/storage/manager.rb:117 +#: service/lib/agama/storage/manager.rb:122 msgid "Calculating the storage proposal" msgstr "Vypočítávání návrhu úložiště" -#: service/lib/agama/storage/manager.rb:118 +#: service/lib/agama/storage/manager.rb:123 msgid "Selecting Linux Security Modules" msgstr "Vybírám bezpečnostní moduly Linuxu" #. Prepares the partitioning to install the system -#: service/lib/agama/storage/manager.rb:126 +#: service/lib/agama/storage/manager.rb:131 msgid "Preparing bootloader proposal" msgstr "Připravuji návrh boot zavaděče" -#. first make bootloader proposal to be sure that required packages are installed -#: service/lib/agama/storage/manager.rb:131 +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:138 msgid "Adding storage-related packages" msgstr "Přidávám balíčky související s úložištěm" -#: service/lib/agama/storage/manager.rb:132 +#: service/lib/agama/storage/manager.rb:139 msgid "Preparing the storage devices" msgstr "Připravuji úložná zařízení" -#: service/lib/agama/storage/manager.rb:133 +#: service/lib/agama/storage/manager.rb:140 msgid "Writing bootloader sysconfig" msgstr "Zapisuji konfiguraci boot zavaděče v sysconfig" @@ -287,14 +287,14 @@ msgstr "Nastal problém při výpočtu nastavení úložiště" #. Returns an issue if there is no target device. #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:124 +#: service/lib/agama/storage/proposal_strategies/guided.rb:127 msgid "No device selected for installation" msgstr "Není vybráno zařízení pro instalaci" #. Returns an issue if any of the devices required for the proposal is not found #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:140 +#: service/lib/agama/storage/proposal_strategies/guided.rb:143 #, perl-brace-format msgid "The following selected device is not found in the system: %{devices}" msgid_plural "" diff --git a/service/po/de.po b/service/po/de.po index 481dcefb64..c56eb61b62 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-11-26 02:49+0000\n" -"PO-Revision-Date: 2024-08-29 19:47+0000\n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" +"PO-Revision-Date: 2024-12-12 06:48+0000\n" "Last-Translator: Ettore Atalan \n" "Language-Team: German \n" @@ -17,40 +17,40 @@ msgstr "" "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.6.2\n" - -#. Runs the startup phase -#: service/lib/agama/manager.rb:91 -msgid "Load software translations" -msgstr "Softwareübersetzungen laden" - -#: service/lib/agama/manager.rb:92 -msgid "Load storage translations" -msgstr "" +"X-Generator: Weblate 5.8.4\n" #. Runs the config phase -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Analyze disks" msgstr "Festplatten analysieren" -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Configure software" msgstr "Software konfigurieren" #. Runs the install phase #. rubocop:disable Metrics/AbcSize -#: service/lib/agama/manager.rb:127 +#: service/lib/agama/manager.rb:116 msgid "Prepare disks" msgstr "Festplatten vorbereiten" -#: service/lib/agama/manager.rb:128 +#: service/lib/agama/manager.rb:117 msgid "Install software" msgstr "Software installieren" -#: service/lib/agama/manager.rb:129 +#: service/lib/agama/manager.rb:118 msgid "Configure the system" msgstr "System konfigurieren" +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/manager.rb:156 +msgid "Load software translations" +msgstr "Softwareübersetzungen laden" + +#: service/lib/agama/manager.rb:157 +msgid "Load storage translations" +msgstr "" + #. Callback to handle unsigned files #. #. @param filename [String] File name @@ -148,14 +148,14 @@ msgstr "Kein Gerät für eine obligatorische Partition gefunden" #: service/lib/agama/storage/config_checker.rb:118 #, c-format msgid "Missing file system type for '%s'" -msgstr "" +msgstr "Fehlender Dateisystemtyp für ‚%s‘" #. TRANSLATORS: %{filesystem} is replaced by a file system type (e.g., "Btrfs") and #. %{path} is replaced by a mount path (e.g., "/home"). #: service/lib/agama/storage/config_checker.rb:145 #, perl-brace-format msgid "The file system type '%{filesystem}' is not suitable for '%{path}'" -msgstr "" +msgstr "Der Dateisystemtyp ‚%{filesystem}‘ ist nicht geeignet für ‚%{path}‘" #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1', 'random_swap'). @@ -190,6 +190,8 @@ msgstr "" msgid "" "The device '%s' is used several times as target device for physical volumes" msgstr "" +"Das Gerät ‚%s‘ wird mehrfach als Zielgerät für physische Datenträger " +"verwendet" #. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). #: service/lib/agama/storage/config_checker.rb:350 @@ -207,16 +209,17 @@ msgstr "" #: service/lib/agama/storage/config_checker.rb:401 #, c-format msgid "There is no target device for LVM physical volumes with alias '%s'" -msgstr "" +msgstr "Es gibt kein Zielgerät für physische LVM-Volumes mit dem Alias ‚%s‘" #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1'). #: service/lib/agama/storage/config_checker.rb:434 -#, fuzzy, perl-brace-format +#, perl-brace-format msgid "" "'%{crypt_method}' is not a suitable method to encrypt the physical volumes." msgstr "" -"'%{crypt_method}' ist keine geeignete Methode zur Verschlüsselung des Geräts." +"%{crypt_method}' ist keine geeignete Methode zur Verschlüsselung der " +"physischen Datenträger." #. Text of the reason preventing to shrink because there is no content. #. @@ -240,37 +243,37 @@ msgid "Shrinking is not supported by this device" msgstr "Verkleinern wird von diesem Gerät nicht unterstützt" #. Probes storage devices and performs an initial proposal -#: service/lib/agama/storage/manager.rb:115 +#: service/lib/agama/storage/manager.rb:120 msgid "Activating storage devices" msgstr "Speichergeräte werden aktiviert" -#: service/lib/agama/storage/manager.rb:116 +#: service/lib/agama/storage/manager.rb:121 msgid "Probing storage devices" msgstr "Speichergeräte werden untersucht" -#: service/lib/agama/storage/manager.rb:117 +#: service/lib/agama/storage/manager.rb:122 msgid "Calculating the storage proposal" msgstr "Speichervorschlag wird berechnet" -#: service/lib/agama/storage/manager.rb:118 +#: service/lib/agama/storage/manager.rb:123 msgid "Selecting Linux Security Modules" msgstr "Linux-Sicherheitsmodule werden ausgewählt" #. Prepares the partitioning to install the system -#: service/lib/agama/storage/manager.rb:126 +#: service/lib/agama/storage/manager.rb:131 msgid "Preparing bootloader proposal" msgstr "Bootloader-Vorschlag wird vorbereitet" -#. first make bootloader proposal to be sure that required packages are installed -#: service/lib/agama/storage/manager.rb:131 +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:138 msgid "Adding storage-related packages" msgstr "Speicherbezogene Pakete werden hinzugefügt" -#: service/lib/agama/storage/manager.rb:132 +#: service/lib/agama/storage/manager.rb:139 msgid "Preparing the storage devices" msgstr "Speichergeräte werden vorbereitet" -#: service/lib/agama/storage/manager.rb:133 +#: service/lib/agama/storage/manager.rb:140 msgid "Writing bootloader sysconfig" msgstr "Bootloader-Systemkonfiguration wird geschrieben" @@ -293,14 +296,14 @@ msgstr "Bei der Berechnung der Speichereinrichtung ist ein Problem aufgetreten" #. Returns an issue if there is no target device. #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:124 +#: service/lib/agama/storage/proposal_strategies/guided.rb:127 msgid "No device selected for installation" msgstr "Kein Gerät für die Installation ausgewählt" #. Returns an issue if any of the devices required for the proposal is not found #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:140 +#: service/lib/agama/storage/proposal_strategies/guided.rb:143 #, perl-brace-format msgid "The following selected device is not found in the system: %{devices}" msgid_plural "" diff --git a/service/po/es.po b/service/po/es.po index 453c9c5ed5..fefb11966a 100644 --- a/service/po/es.po +++ b/service/po/es.po @@ -7,9 +7,9 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-26 02:49+0000\n" -"PO-Revision-Date: 2024-09-30 18:48+0000\n" -"Last-Translator: Victor hck \n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" +"PO-Revision-Date: 2024-12-11 16:48+0000\n" +"Last-Translator: \"Marina J. Donis\" \n" "Language-Team: Spanish \n" "Language: es\n" @@ -17,40 +17,40 @@ msgstr "" "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.7.2\n" - -#. Runs the startup phase -#: service/lib/agama/manager.rb:91 -msgid "Load software translations" -msgstr "Cargar traducciones de software" - -#: service/lib/agama/manager.rb:92 -msgid "Load storage translations" -msgstr "Cargar traducciones de almacenamiento" +"X-Generator: Weblate 5.8.4\n" #. Runs the config phase -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Analyze disks" msgstr "Analizar discos" -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Configure software" msgstr "Configurar software" #. Runs the install phase #. rubocop:disable Metrics/AbcSize -#: service/lib/agama/manager.rb:127 +#: service/lib/agama/manager.rb:116 msgid "Prepare disks" msgstr "Preparar discos" -#: service/lib/agama/manager.rb:128 +#: service/lib/agama/manager.rb:117 msgid "Install software" msgstr "Instalar software" -#: service/lib/agama/manager.rb:129 +#: service/lib/agama/manager.rb:118 msgid "Configure the system" msgstr "Configurar el sistema" +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/manager.rb:156 +msgid "Load software translations" +msgstr "Cargar traducciones de software" + +#: service/lib/agama/manager.rb:157 +msgid "Load storage translations" +msgstr "Cargar traducciones de almacenamiento" + #. Callback to handle unsigned files #. #. @param filename [String] File name @@ -148,7 +148,7 @@ msgstr "No se encontró ningún dispositivo para una partición obligatoria" #: service/lib/agama/storage/config_checker.rb:118 #, c-format msgid "Missing file system type for '%s'" -msgstr "" +msgstr "Falta el tipo de sistema de archivos para \"%s\"" #. TRANSLATORS: %{filesystem} is replaced by a file system type (e.g., "Btrfs") and #. %{path} is replaced by a mount path (e.g., "/home"). @@ -156,6 +156,8 @@ msgstr "" #, perl-brace-format msgid "The file system type '%{filesystem}' is not suitable for '%{path}'" msgstr "" +"El tipo de sistema de archivos para \"%{filesystem}\" no es adecuado para " +"\"%{path}\"" #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1', 'random_swap'). @@ -188,32 +190,37 @@ msgstr "'%{crypt_method}' no es un método adecuado para cifrar el dispositivo." msgid "" "The device '%s' is used several times as target device for physical volumes" msgstr "" +"El dispositivo \"%s\" se utiliza varias veces como dispositivo de destino " +"para volúmenes físicos" #. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). #: service/lib/agama/storage/config_checker.rb:350 -#, fuzzy, c-format +#, c-format msgid "There is no LVM thin pool volume with alias '%s'" -msgstr "No hay ningún volumen de grupo ligero LVM con alias %s" +msgstr "No hay ningún volumen de grupo ligero LVM con alias \"%s\"" #. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). #: service/lib/agama/storage/config_checker.rb:375 -#, fuzzy, c-format +#, c-format msgid "There is no LVM physical volume with alias '%s'" -msgstr "No existe ningún volumen físico LVM con alias %s" +msgstr "No existe ningún volumen físico LVM con alias \"%s\"" #. TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). #: service/lib/agama/storage/config_checker.rb:401 -#, fuzzy, c-format +#, c-format msgid "There is no target device for LVM physical volumes with alias '%s'" -msgstr "No existe ningún volumen físico LVM con alias %s" +msgstr "" +"No existe dispositivo de destino para volúmenes físicos LVM con alias \"%s\"" #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1'). #: service/lib/agama/storage/config_checker.rb:434 -#, fuzzy, perl-brace-format +#, perl-brace-format msgid "" "'%{crypt_method}' is not a suitable method to encrypt the physical volumes." -msgstr "'%{crypt_method}' no es un método adecuado para cifrar el dispositivo." +msgstr "" +"\"%{crypt_method}\" no es un método adecuado para cifrar los volúmenes " +"físicos." #. Text of the reason preventing to shrink because there is no content. #. @@ -237,37 +244,37 @@ msgid "Shrinking is not supported by this device" msgstr "Este dispositivo no admite la reducción" #. Probes storage devices and performs an initial proposal -#: service/lib/agama/storage/manager.rb:115 +#: service/lib/agama/storage/manager.rb:120 msgid "Activating storage devices" msgstr "Activar dispositivos de almacenamiento" -#: service/lib/agama/storage/manager.rb:116 +#: service/lib/agama/storage/manager.rb:121 msgid "Probing storage devices" msgstr "Probando los dispositivos de almacenamiento" -#: service/lib/agama/storage/manager.rb:117 +#: service/lib/agama/storage/manager.rb:122 msgid "Calculating the storage proposal" msgstr "Calcular la propuesta de almacenamiento" -#: service/lib/agama/storage/manager.rb:118 +#: service/lib/agama/storage/manager.rb:123 msgid "Selecting Linux Security Modules" msgstr "Seleccionar módulos de seguridad de Linux" #. Prepares the partitioning to install the system -#: service/lib/agama/storage/manager.rb:126 +#: service/lib/agama/storage/manager.rb:131 msgid "Preparing bootloader proposal" msgstr "Preparando la propuesta del gestor de arranque" -#. first make bootloader proposal to be sure that required packages are installed -#: service/lib/agama/storage/manager.rb:131 +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:138 msgid "Adding storage-related packages" msgstr "Agregar paquetes relacionados con el almacenamiento" -#: service/lib/agama/storage/manager.rb:132 +#: service/lib/agama/storage/manager.rb:139 msgid "Preparing the storage devices" msgstr "Preparando los dispositivos de almacenamiento" -#: service/lib/agama/storage/manager.rb:133 +#: service/lib/agama/storage/manager.rb:140 msgid "Writing bootloader sysconfig" msgstr "Escribiendo el gestor de arranque sysconfig" @@ -289,14 +296,14 @@ msgstr "Ocurrió un problema al calcular la configuración de almacenamiento" #. Returns an issue if there is no target device. #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:124 +#: service/lib/agama/storage/proposal_strategies/guided.rb:127 msgid "No device selected for installation" msgstr "No se seleccionó ningún dispositivo para la instalación" #. Returns an issue if any of the devices required for the proposal is not found #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:140 +#: service/lib/agama/storage/proposal_strategies/guided.rb:143 #, perl-brace-format msgid "The following selected device is not found in the system: %{devices}" msgid_plural "" diff --git a/service/po/fr.po b/service/po/fr.po index 488218c197..7bc9543ee7 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-11-26 02:49+0000\n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" "PO-Revision-Date: 2024-04-19 23:43+0000\n" "Last-Translator: faila fail \n" "Language-Team: French 1;\n" "X-Generator: Weblate 4.9.1\n" -#. Runs the startup phase -#: service/lib/agama/manager.rb:91 -msgid "Load software translations" -msgstr "" - -#: service/lib/agama/manager.rb:92 -msgid "Load storage translations" -msgstr "" - #. Runs the config phase -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Analyze disks" msgstr "" -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 #, fuzzy msgid "Configure software" msgstr "Sonde les logiciels" #. Runs the install phase #. rubocop:disable Metrics/AbcSize -#: service/lib/agama/manager.rb:127 +#: service/lib/agama/manager.rb:116 msgid "Prepare disks" msgstr "" -#: service/lib/agama/manager.rb:128 +#: service/lib/agama/manager.rb:117 #, fuzzy msgid "Install software" msgstr "Installation des logiciels" -#: service/lib/agama/manager.rb:129 +#: service/lib/agama/manager.rb:118 msgid "Configure the system" msgstr "" +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/manager.rb:156 +msgid "Load software translations" +msgstr "" + +#: service/lib/agama/manager.rb:157 +msgid "Load storage translations" +msgstr "" + #. Callback to handle unsigned files #. #. @param filename [String] File name @@ -233,37 +233,37 @@ msgid "Shrinking is not supported by this device" msgstr "" #. Probes storage devices and performs an initial proposal -#: service/lib/agama/storage/manager.rb:115 +#: service/lib/agama/storage/manager.rb:120 msgid "Activating storage devices" msgstr "Activation des périphériques de stockage" -#: service/lib/agama/storage/manager.rb:116 +#: service/lib/agama/storage/manager.rb:121 msgid "Probing storage devices" msgstr "Sonde les périphériques de stockage" -#: service/lib/agama/storage/manager.rb:117 +#: service/lib/agama/storage/manager.rb:122 msgid "Calculating the storage proposal" msgstr "Calcul de la proposition de stockage" -#: service/lib/agama/storage/manager.rb:118 +#: service/lib/agama/storage/manager.rb:123 msgid "Selecting Linux Security Modules" msgstr "Sélection des modules de sécurité Linux" #. Prepares the partitioning to install the system -#: service/lib/agama/storage/manager.rb:126 +#: service/lib/agama/storage/manager.rb:131 msgid "Preparing bootloader proposal" msgstr "Préparation du chargeur d'amorçage envisagé" -#. first make bootloader proposal to be sure that required packages are installed -#: service/lib/agama/storage/manager.rb:131 +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:138 msgid "Adding storage-related packages" msgstr "Ajout des paquets relatifs au stockage" -#: service/lib/agama/storage/manager.rb:132 +#: service/lib/agama/storage/manager.rb:139 msgid "Preparing the storage devices" msgstr "Préparation des périphériques de stockage" -#: service/lib/agama/storage/manager.rb:133 +#: service/lib/agama/storage/manager.rb:140 msgid "Writing bootloader sysconfig" msgstr "Écriture du sysconfig du chargeur d'amorçage" @@ -285,14 +285,14 @@ msgstr "" #. Returns an issue if there is no target device. #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:124 +#: service/lib/agama/storage/proposal_strategies/guided.rb:127 msgid "No device selected for installation" msgstr "Aucun périphérique n'a été sélectionné pour l'installation" #. Returns an issue if any of the devices required for the proposal is not found #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:140 +#: service/lib/agama/storage/proposal_strategies/guided.rb:143 #, perl-brace-format msgid "The following selected device is not found in the system: %{devices}" msgid_plural "" diff --git a/service/po/id.po b/service/po/id.po index 8b90a0b995..78470592e0 100644 --- a/service/po/id.po +++ b/service/po/id.po @@ -7,51 +7,49 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-26 02:49+0000\n" -"PO-Revision-Date: 2023-12-28 21:02+0000\n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" +"PO-Revision-Date: 2024-12-25 18:50+0000\n" "Last-Translator: Arif Budiman \n" -"Language-Team: Indonesian \n" +"Language-Team: Indonesian \n" "Language: id\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 4.9.1\n" - -#. Runs the startup phase -#: service/lib/agama/manager.rb:91 -msgid "Load software translations" -msgstr "" - -#: service/lib/agama/manager.rb:92 -msgid "Load storage translations" -msgstr "" +"X-Generator: Weblate 5.9.2\n" #. Runs the config phase -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Analyze disks" -msgstr "" +msgstr "Menganalisis disk" -#: service/lib/agama/manager.rb:107 -#, fuzzy +#: service/lib/agama/manager.rb:93 msgid "Configure software" -msgstr "Memeriksa Perangkat Lunak" +msgstr "Mengkonfigurasi perangkat lunak" #. Runs the install phase #. rubocop:disable Metrics/AbcSize -#: service/lib/agama/manager.rb:127 +#: service/lib/agama/manager.rb:116 msgid "Prepare disks" -msgstr "" +msgstr "Siapkan disk" -#: service/lib/agama/manager.rb:128 -#, fuzzy +#: service/lib/agama/manager.rb:117 msgid "Install software" -msgstr "Menginstal Perangkat Lunak" +msgstr "Menginstal perangkat lunak" -#: service/lib/agama/manager.rb:129 +#: service/lib/agama/manager.rb:118 msgid "Configure the system" -msgstr "" +msgstr "Mengkonfigurasi sistem" + +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/manager.rb:156 +msgid "Load software translations" +msgstr "Memuat terjemahan perangkat lunak" + +#: service/lib/agama/manager.rb:157 +msgid "Load storage translations" +msgstr "Memuat terjemahan penyimpanan" #. Callback to handle unsigned files #. @@ -60,12 +58,12 @@ msgstr "" #: service/lib/agama/software/callbacks/signature.rb:63 #, perl-brace-format msgid "The file %{filename} from repository %{repo_name} (%{repo_url})" -msgstr "File %{filename} dari repositori %{repo_name} (%{repo_url})" +msgstr "Berkas %{filename} dari repositori %{repo_name} (%{repo_url})" #: service/lib/agama/software/callbacks/signature.rb:67 #, perl-brace-format msgid "The file %{filename}" -msgstr "File %{filename}" +msgstr "Berkas %{filename}" #: service/lib/agama/software/callbacks/signature.rb:71 #, perl-brace-format @@ -73,7 +71,7 @@ msgid "" "%{source} is not digitally signed. The origin and integrity of the file " "cannot be verified. Use it anyway?" msgstr "" -"%{source} tidak ditandatangani secara digital. Asal dan integritas file " +"%{source} tidak ditandatangani secara digital. Asal dan integritas berkas " "tidak dapat diverifikasi. Tetap menggunakannya?" #. Callback to handle signature verification failures @@ -140,24 +138,24 @@ msgstr "Ditemukan %s masalah ketergantungan." #. @return [Agama::Issue] #: service/lib/agama/storage/config_checker.rb:87 msgid "No device found for a mandatory drive" -msgstr "" +msgstr "Perangkat tidak ditemukan untuk drive wajib" #: service/lib/agama/storage/config_checker.rb:89 msgid "No device found for a mandatory partition" -msgstr "" +msgstr "Perangkat tidak ditemukan untuk partisi wajib" #. TRANSLATORS: %s is the replaced by a mount path (e.g., "/home"). #: service/lib/agama/storage/config_checker.rb:118 #, c-format msgid "Missing file system type for '%s'" -msgstr "" +msgstr "Tidak ada tipe sistem berkas untuk '%s'" #. TRANSLATORS: %{filesystem} is replaced by a file system type (e.g., "Btrfs") and #. %{path} is replaced by a mount path (e.g., "/home"). #: service/lib/agama/storage/config_checker.rb:145 #, perl-brace-format msgid "The file system type '%{filesystem}' is not suitable for '%{path}'" -msgstr "" +msgstr "Tipe sistem berkas '%{filesystem}' tidak cocok untuk '%{path}'" #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1', 'random_swap'). @@ -166,20 +164,22 @@ msgstr "" msgid "" "No passphrase provided (required for using the method '%{crypt_method}')." msgstr "" +"Kata sandi tidak disediakan (diperlukan untuk menggunakan metode " +"'%{crypt_method}')." #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1', 'random_swap'). #: service/lib/agama/storage/config_checker.rb:196 #, perl-brace-format msgid "Encryption method '%{crypt_method}' is not available in this system." -msgstr "" +msgstr "Metode enkripsi '%{crypt_method}' tidak tersedia di sistem ini." #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1', 'random_swap'). #: service/lib/agama/storage/config_checker.rb:226 #, perl-brace-format msgid "'%{crypt_method}' is not a suitable method to encrypt the device." -msgstr "" +msgstr "'%{crypt_method}' bukan metode yang cocok untuk mengenkripsi perangkat." #. TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). #: service/lib/agama/storage/config_checker.rb:276 @@ -187,24 +187,26 @@ msgstr "" msgid "" "The device '%s' is used several times as target device for physical volumes" msgstr "" +"Perangkat '%s' digunakan beberapa kali sebagai perangkat target untuk volume " +"fisik" #. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). #: service/lib/agama/storage/config_checker.rb:350 #, c-format msgid "There is no LVM thin pool volume with alias '%s'" -msgstr "" +msgstr "Tidak ada volume pool tipis LVM dengan alias '%s'" #. TRANSLATORS: %s is the replaced by a device alias (e.g., "pv1"). #: service/lib/agama/storage/config_checker.rb:375 #, c-format msgid "There is no LVM physical volume with alias '%s'" -msgstr "" +msgstr "Tidak ada volume fisik LVM dengan alias '%s'" #. TRANSLATORS: %s is the replaced by a device alias (e.g., "disk1"). #: service/lib/agama/storage/config_checker.rb:401 #, c-format msgid "There is no target device for LVM physical volumes with alias '%s'" -msgstr "" +msgstr "Tidak ada perangkat target untuk volume fisik LVM dengan alias '%s'" #. TRANSLATORS: 'crypt_method' is the identifier of the method to encrypt the device #. (e.g., 'luks1'). @@ -213,6 +215,7 @@ msgstr "" msgid "" "'%{crypt_method}' is not a suitable method to encrypt the physical volumes." msgstr "" +"'%{crypt_method}' bukan metode yang cocok untuk mengenkripsi volume fisik." #. Text of the reason preventing to shrink because there is no content. #. @@ -223,46 +226,50 @@ 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 "" +"Baik sistem berkas maupun sistem penyimpanan tidak terdeteksi pada " +"perangkat. Jika perangkat memiliki sistem berkas atau sistem penyimpanan " +"yang tidak didukung, mengubah ukuran kemungkinan besar akan menyebabkan " +"hilangnya data." #. 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 "Penyusutan tidak didukung oleh perangkat ini" #. Probes storage devices and performs an initial proposal -#: service/lib/agama/storage/manager.rb:115 +#: service/lib/agama/storage/manager.rb:120 msgid "Activating storage devices" msgstr "Mengaktifkan perangkat penyimpanan" -#: service/lib/agama/storage/manager.rb:116 +#: service/lib/agama/storage/manager.rb:121 msgid "Probing storage devices" msgstr "Memeriksa perangkat penyimpanan" -#: service/lib/agama/storage/manager.rb:117 +#: service/lib/agama/storage/manager.rb:122 msgid "Calculating the storage proposal" msgstr "Menghitung proposal penyimpanan" -#: service/lib/agama/storage/manager.rb:118 +#: service/lib/agama/storage/manager.rb:123 msgid "Selecting Linux Security Modules" msgstr "Memilih Modul Keamanan Linux" #. Prepares the partitioning to install the system -#: service/lib/agama/storage/manager.rb:126 +#: service/lib/agama/storage/manager.rb:131 msgid "Preparing bootloader proposal" msgstr "Mempersiapkan proposal bootloader" -#. first make bootloader proposal to be sure that required packages are installed -#: service/lib/agama/storage/manager.rb:131 +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:138 msgid "Adding storage-related packages" msgstr "Menambahkan paket terkait penyimpanan" -#: service/lib/agama/storage/manager.rb:132 +#: service/lib/agama/storage/manager.rb:139 msgid "Preparing the storage devices" msgstr "Mempersiapkan perangkat penyimpanan" -#: service/lib/agama/storage/manager.rb:133 +#: service/lib/agama/storage/manager.rb:140 msgid "Writing bootloader sysconfig" msgstr "Menulis sysconfig bootloader" @@ -271,37 +278,39 @@ msgstr "Menulis sysconfig bootloader" #. @return [Issue] #: service/lib/agama/storage/proposal.rb:287 msgid "Cannot accommodate the required file systems for installation" -msgstr "" +msgstr "Tidak dapat mengakomodasi sistem berkas yang diperlukan untuk instalasi" #. Issue to communicate a generic Y2Storage error. #. #. @return [Issue] #: service/lib/agama/storage/proposal.rb:298 msgid "A problem ocurred while calculating the storage setup" -msgstr "" +msgstr "Terjadi masalah saat menghitung pengaturan penyimpanan" #. Returns an issue if there is no target device. #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:124 +#: service/lib/agama/storage/proposal_strategies/guided.rb:127 msgid "No device selected for installation" -msgstr "" +msgstr "Tidak ada perangkat yang dipilih untuk pemasangan" #. Returns an issue if any of the devices required for the proposal is not found #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:140 +#: service/lib/agama/storage/proposal_strategies/guided.rb:143 #, perl-brace-format msgid "The following selected device is not found in the system: %{devices}" msgid_plural "" "The following selected devices are not found in the system: %{devices}" msgstr[0] "" +"Perangkat yang dipilih berikut ini tidak ditemukan dalam sistem: %{devices}" #. Recalculates the list of issues #: service/lib/agama/users.rb:154 msgid "" "Defining a user, setting the root password or a SSH public key is required" msgstr "" +"Wajib menentukan pengguna, mengatur kata sandi root atau kunci publik SSH" #~ msgid "Probing Storage" #~ msgstr "Memeriksa Penyimpanan" diff --git a/service/po/ja.po b/service/po/ja.po index 35cb32b9b6..43119ac8ae 100644 --- a/service/po/ja.po +++ b/service/po/ja.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-26 02:49+0000\n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" "PO-Revision-Date: 2024-10-30 00:48+0000\n" "Last-Translator: Yasuhiko Kamata \n" "Language-Team: Japanese \n" "Language-Team: Georgian \n" "Language-Team: Norwegian Bokmål \n" "Language-Team: Portuguese (Brazil) 1;\n" "X-Generator: Weblate 5.8.3\n" -#. Runs the startup phase -#: service/lib/agama/manager.rb:91 -msgid "Load software translations" -msgstr "Carregar traduções de software" - -#: service/lib/agama/manager.rb:92 -msgid "Load storage translations" -msgstr "Traduções de armazenamento de carga" - #. Runs the config phase -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Analyze disks" msgstr "Analizar discos" -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Configure software" msgstr "Configurar software" #. Runs the install phase #. rubocop:disable Metrics/AbcSize -#: service/lib/agama/manager.rb:127 +#: service/lib/agama/manager.rb:116 msgid "Prepare disks" msgstr "Preparar discos" -#: service/lib/agama/manager.rb:128 +#: service/lib/agama/manager.rb:117 msgid "Install software" msgstr "Instalar software" -#: service/lib/agama/manager.rb:129 +#: service/lib/agama/manager.rb:118 msgid "Configure the system" msgstr "Configurar o sistema" +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/manager.rb:156 +msgid "Load software translations" +msgstr "Carregar traduções de software" + +#: service/lib/agama/manager.rb:157 +msgid "Load storage translations" +msgstr "Traduções de armazenamento de carga" + #. Callback to handle unsigned files #. #. @param filename [String] File name @@ -241,37 +241,37 @@ msgid "Shrinking is not supported by this device" msgstr "O encolhimento não é suportado por este dispositivo" #. Probes storage devices and performs an initial proposal -#: service/lib/agama/storage/manager.rb:115 +#: service/lib/agama/storage/manager.rb:120 msgid "Activating storage devices" msgstr "Ativando dispositivos de armazenamento" -#: service/lib/agama/storage/manager.rb:116 +#: service/lib/agama/storage/manager.rb:121 msgid "Probing storage devices" msgstr "Sondando dispositivos de armazenamento" -#: service/lib/agama/storage/manager.rb:117 +#: service/lib/agama/storage/manager.rb:122 msgid "Calculating the storage proposal" msgstr "Calculando a proposta de armazenamento" -#: service/lib/agama/storage/manager.rb:118 +#: service/lib/agama/storage/manager.rb:123 msgid "Selecting Linux Security Modules" msgstr "Selecionando módulos de segurança do Linux" #. Prepares the partitioning to install the system -#: service/lib/agama/storage/manager.rb:126 +#: service/lib/agama/storage/manager.rb:131 msgid "Preparing bootloader proposal" msgstr "Preparando proposta de bootloader" -#. first make bootloader proposal to be sure that required packages are installed -#: service/lib/agama/storage/manager.rb:131 +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:138 msgid "Adding storage-related packages" msgstr "Adicionando pacotes relacionados ao armazenamento" -#: service/lib/agama/storage/manager.rb:132 +#: service/lib/agama/storage/manager.rb:139 msgid "Preparing the storage devices" msgstr "Preparando os dispositivos de armazenamento" -#: service/lib/agama/storage/manager.rb:133 +#: service/lib/agama/storage/manager.rb:140 msgid "Writing bootloader sysconfig" msgstr "Escrevendo sysconfig do bootloader" @@ -293,14 +293,14 @@ msgstr "Ocorreu um problema ao calcular a configuração de armazenamento" #. Returns an issue if there is no target device. #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:124 +#: service/lib/agama/storage/proposal_strategies/guided.rb:127 msgid "No device selected for installation" msgstr "Nenhum dispositivo selecionado para instalação" #. Returns an issue if any of the devices required for the proposal is not found #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:140 +#: service/lib/agama/storage/proposal_strategies/guided.rb:143 #, perl-brace-format msgid "The following selected device is not found in the system: %{devices}" msgid_plural "" diff --git a/service/po/ru.po b/service/po/ru.po index 91e6384003..358ca10b46 100644 --- a/service/po/ru.po +++ b/service/po/ru.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-26 02:49+0000\n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" "PO-Revision-Date: 2024-06-26 10:46+0000\n" "Last-Translator: Aleksey Fedorov \n" "Language-Team: Russian =20) ? 1 : 2;\n" "X-Generator: Weblate 5.6\n" -#. Runs the startup phase -#: service/lib/agama/manager.rb:91 -msgid "Load software translations" -msgstr "" - -#: service/lib/agama/manager.rb:92 -msgid "Load storage translations" -msgstr "" - #. Runs the config phase -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Analyze disks" msgstr "Анализ дисков" -#: service/lib/agama/manager.rb:107 +#: service/lib/agama/manager.rb:93 msgid "Configure software" msgstr "Настройка программного обеспечения" #. Runs the install phase #. rubocop:disable Metrics/AbcSize -#: service/lib/agama/manager.rb:127 +#: service/lib/agama/manager.rb:116 msgid "Prepare disks" msgstr "Подготовка дисков" -#: service/lib/agama/manager.rb:128 +#: service/lib/agama/manager.rb:117 msgid "Install software" msgstr "Установка программного обеспечения" -#: service/lib/agama/manager.rb:129 +#: service/lib/agama/manager.rb:118 msgid "Configure the system" msgstr "Настройка системы" +#. rubocop:enable Metrics/AbcSize +#: service/lib/agama/manager.rb:156 +msgid "Load software translations" +msgstr "" + +#: service/lib/agama/manager.rb:157 +msgid "Load storage translations" +msgstr "" + #. Callback to handle unsigned files #. #. @param filename [String] File name @@ -232,37 +232,37 @@ msgid "Shrinking is not supported by this device" msgstr "" #. Probes storage devices and performs an initial proposal -#: service/lib/agama/storage/manager.rb:115 +#: service/lib/agama/storage/manager.rb:120 msgid "Activating storage devices" msgstr "Активация устройств хранения" -#: service/lib/agama/storage/manager.rb:116 +#: service/lib/agama/storage/manager.rb:121 msgid "Probing storage devices" msgstr "Поиск устройств хранения" -#: service/lib/agama/storage/manager.rb:117 +#: service/lib/agama/storage/manager.rb:122 msgid "Calculating the storage proposal" msgstr "Расчет предложения по хранению" -#: service/lib/agama/storage/manager.rb:118 +#: service/lib/agama/storage/manager.rb:123 msgid "Selecting Linux Security Modules" msgstr "Выбор модулей безопасности Linux" #. Prepares the partitioning to install the system -#: service/lib/agama/storage/manager.rb:126 +#: service/lib/agama/storage/manager.rb:131 msgid "Preparing bootloader proposal" msgstr "Подготовка предложения по загрузчику" -#. first make bootloader proposal to be sure that required packages are installed -#: service/lib/agama/storage/manager.rb:131 +#. then also apply changes to that proposal +#: service/lib/agama/storage/manager.rb:138 msgid "Adding storage-related packages" msgstr "Добавление пакетов, связанных с хранилищем" -#: service/lib/agama/storage/manager.rb:132 +#: service/lib/agama/storage/manager.rb:139 msgid "Preparing the storage devices" msgstr "Подготовка устройств хранения" -#: service/lib/agama/storage/manager.rb:133 +#: service/lib/agama/storage/manager.rb:140 msgid "Writing bootloader sysconfig" msgstr "Запись системной конфигурации загрузчика" @@ -283,14 +283,14 @@ msgstr "Возникла проблема при расчёте конфигур #. Returns an issue if there is no target device. #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:124 +#: service/lib/agama/storage/proposal_strategies/guided.rb:127 msgid "No device selected for installation" msgstr "Не выбрано устройство для установки" #. Returns an issue if any of the devices required for the proposal is not found #. #. @return [Issue, nil] -#: service/lib/agama/storage/proposal_strategies/guided.rb:140 +#: service/lib/agama/storage/proposal_strategies/guided.rb:143 #, perl-brace-format msgid "The following selected device is not found in the system: %{devices}" msgid_plural "" diff --git a/service/po/sv.po b/service/po/sv.po index ba4c6948af..27ec82f7cc 100644 --- a/service/po/sv.po +++ b/service/po/sv.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-11-26 02:49+0000\n" +"POT-Creation-Date: 2024-12-22 02:45+0000\n" "PO-Revision-Date: 2024-10-29 12:48+0000\n" "Last-Translator: Luna Jernberg \n" "Language-Team: Swedish \n" "Language-Team: Turkish \n" "Language-Team: Chinese (Simplified) /dev/null && pwd ) podman create -ti --rm --entrypoint '["sh", "-c"]' --name agama_ruby_tests -v $SCRIPT_DIR/..:/checkout registry.opensuse.org/yast/head/containers_tumbleweed/yast-ruby sh podman start agama_ruby_tests -podman exec agama_ruby_tests zypper --non-interactive install yast2-iscsi-client ruby3.2-rubygem-eventmachine +podman exec agama_ruby_tests zypper --non-interactive install yast2-iscsi-client yast2-bootloader ruby3.3-rubygem-eventmachine if podman exec --workdir /checkout/service agama_ruby_tests rake test:unit; then if [ "$KEEP_RUNNING" != "1" ]; then podman stop agama_ruby_tests diff --git a/service/test/agama/storage/bootloader_test.rb b/service/test/agama/storage/bootloader_test.rb new file mode 100644 index 0000000000..40a5d9c315 --- /dev/null +++ b/service/test/agama/storage/bootloader_test.rb @@ -0,0 +1,51 @@ +# 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/storage/bootloader" + +describe Agama::Storage::Bootloader::Config do + subject(:config) { described_class.new } + + describe "#to_json" do + it "serializes its content with keys as camelCase" do + config.stop_on_boot_menu = true + expect(config.to_json).to eq "{\"stopOnBootMenu\":true}" + end + + it "can serialize in a way that #load_json can restore it" do + config.stop_on_boot_menu = false + json = config.to_json + config.stop_on_boot_menu = true + config.load_json(json) + expect(config.stop_on_boot_menu).to eq false + end + end + + describe "#load_json" do + it "loads config from given json" do + content = "{\"stopOnBootMenu\":true}" + config.load_json(content) + expect(config.stop_on_boot_menu).to eq true + end + end +end diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index ed357566b4..4b8b665d8c 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -55,6 +55,8 @@ allow(Agama::DBus::Clients::Software).to receive(:instance).and_return(software) allow(Bootloader::FinishClient).to receive(:new).and_return(bootloader_finish) allow(Agama::Security).to receive(:new).and_return(security) + # mock writting config as proposal call can do storage probing, which fails in CI + allow_any_instance_of(Agama::Storage::Bootloader).to receive(:write_config) allow(Agama::HTTP::Clients::Scripts).to receive(:new).and_return(scripts_client) allow(Yast::Installation).to receive(:destdir).and_return(File.join(tmp_dir, "mnt")) stub_const("Agama::Storage::Finisher::CopyLogsStep::SCRIPTS_DIR", diff --git a/service/test/agama/users_test.rb b/service/test/agama/users_test.rb index 4314ec25eb..38b93ae1c9 100644 --- a/service/test/agama/users_test.rb +++ b/service/test/agama/users_test.rb @@ -37,15 +37,15 @@ describe "#assign_root_password" do let(:root_user) { instance_double(Y2Users::User) } - context "when the password is encrypted" do - it "sets the password as encrypted" do - subject.assign_root_password("encrypted", true) + context "when the password is hashed" do + it "sets the password as hashed" do + subject.assign_root_password("hashed", true) root_user = users_config.users.root - expect(root_user.password).to eq(Y2Users::Password.create_encrypted("encrypted")) + expect(root_user.password).to eq(Y2Users::Password.create_encrypted("hashed")) end end - context "when the password is not encrypted" do + context "when the password is not hashed" do it "sets the password in clear text" do subject.assign_root_password("12345", false) root_user = users_config.users.root diff --git a/web/jest.config.js b/web/jest.config.js index 670ff97b57..728e681612 100644 --- a/web/jest.config.js +++ b/web/jest.config.js @@ -135,7 +135,7 @@ module.exports = { // A list of paths to modules that run some code to configure or set up the testing framework before each test // setupFilesAfterEnv: [], - setupFilesAfterEnv: ["/src/setupTests.js"], + setupFilesAfterEnv: ["/src/setupTests.ts"], // The number of seconds after which a test is considered as slow and reported as such in the results. // slowTestThreshold: 5, diff --git a/web/package-lock.json b/web/package-lock.json index 3d3ad4045e..24a40993f2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -93,7 +93,6 @@ "ts-loader": "^9.5.1", "tsconfig-paths-webpack-plugin": "^4.0.0", "typedoc": "^0.27.4", - "typedoc-plugin-external-module-map": "^2.1.0", "typedoc-plugin-merge-modules": "^6.0.0", "typedoc-plugin-missing-exports": "^3.0.0", "typescript": "^5.7.2", @@ -17658,36 +17657,6 @@ "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x" } }, - "node_modules/typedoc-plugin-external-module-map": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/typedoc-plugin-external-module-map/-/typedoc-plugin-external-module-map-2.1.0.tgz", - "integrity": "sha512-xw5nwrlNsfOLWcjUW6JhG55doxjLseH9UQwn3apsXhIeank5Ni2S6ffxeKavtCr8eDIyddal6QNwCraKa8xp4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "^20.14.14" - }, - "peerDependencies": { - "typedoc": ">=0.26 <2.0" - } - }, - "node_modules/typedoc-plugin-external-module-map/node_modules/@types/node": { - "version": "20.17.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz", - "integrity": "sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/typedoc-plugin-external-module-map/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "license": "MIT" - }, "node_modules/typedoc-plugin-merge-modules": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/typedoc-plugin-merge-modules/-/typedoc-plugin-merge-modules-6.1.0.tgz", diff --git a/web/package.json b/web/package.json index 4a5ff44728..4bac7b46d0 100644 --- a/web/package.json +++ b/web/package.json @@ -96,7 +96,6 @@ "ts-loader": "^9.5.1", "tsconfig-paths-webpack-plugin": "^4.0.0", "typedoc": "^0.27.4", - "typedoc-plugin-external-module-map": "^2.1.0", "typedoc-plugin-merge-modules": "^6.0.0", "typedoc-plugin-missing-exports": "^3.0.0", "typescript": "^5.7.2", diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index f32f71c230..398cf686d1 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Fri Dec 20 12:53:41 UTC 2024 - David Diaz + +- Fix netmask handling to avoid a silent connection form error + (gh#agama-project/agama#1846). + ------------------------------------------------------------------- Tue Dec 10 14:43:08 UTC 2024 - David Diaz diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index b5fdf6f6ba..b106985e13 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -110,7 +110,7 @@ describe("App", () => { }); mockProducts = [tumbleweed, microos]; - mockRootUser = { password: true, encryptedPassword: false, sshkey: "FAKE-SSH-KEY" }; + mockRootUser = { password: true, hashedPassword: false, sshkey: "FAKE-SSH-KEY" }; }); afterEach(() => { @@ -165,7 +165,7 @@ describe("App", () => { describe("when there are no authentication method for root user", () => { beforeEach(() => { - mockRootUser = { password: false, encryptedPassword: false, sshkey: "" }; + mockRootUser = { password: false, hashedPassword: false, sshkey: "" }; }); it("redirects to root user edition", async () => { @@ -176,7 +176,7 @@ describe("App", () => { describe("when only root password is set", () => { beforeEach(() => { - mockRootUser = { password: true, encryptedPassword: false, sshkey: "" }; + mockRootUser = { password: true, hashedPassword: false, sshkey: "" }; }); it("renders the application content", async () => { installerRender(, { withL10n: true }); @@ -186,7 +186,7 @@ describe("App", () => { describe("when only root SSH public key is set", () => { beforeEach(() => { - mockRootUser = { password: false, encryptedPassword: false, sshkey: "FAKE-SSH-KEY" }; + mockRootUser = { password: false, hashedPassword: false, sshkey: "FAKE-SSH-KEY" }; }); it("renders the application content", async () => { installerRender(, { withL10n: true }); diff --git a/web/src/Protected.jsx b/web/src/Protected.tsx similarity index 100% rename from web/src/Protected.jsx rename to web/src/Protected.tsx diff --git a/web/src/agama.js b/web/src/agama.js deleted file mode 100644 index e89e0ded3f..0000000000 --- a/web/src/agama.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright (c) [2024] SUSE LLC - * - * All Rights Reserved. - * - * This program is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation; either version 2 of the License, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, contact SUSE LLC. - * - * To contact SUSE LLC about this file by physical or electronic mail, you may - * find current contact information at www.suse.com. - */ - -/** - * This module provides a global "agama" object which can be use from other - * scripts like "po.js". - */ - -const agama = { - // the current language - language: "en", -}; - -// mapping with the current translations -let translations = {}; -// function used for computing the plural form index -let plural_fn; - -// set the current translations, called from po..js -agama.locale = function locale(po) { - if (po) { - Object.assign(translations, po); - - const header = po[""]; - if (header) { - if (header["plural-forms"]) plural_fn = header["plural-forms"]; - if (header.language) agama.language = header.language; - } - } else if (po === null) { - translations = {}; - plural_fn = undefined; - agama.language = "en"; - } -}; - -/** - * get a translation for a singular text - * @param {string} str input text - * @return translated text or the original text if the translation is not found - */ -agama.gettext = function gettext(str) { - if (translations) { - const translated = translations[str]; - if (translated?.[0]) return translated[0]; - } - - // fallback, return the original text - return str; -}; - -/** - * get a translation for a plural text - * @param {string} str1 input singular text - * @param {string} strN input plural text - * @param {number} n the actual number which decides whether to use the - * singular or plural form (of which plural form if there are several of them) - * @return translated text or the original text if the translation is not found - */ -agama.ngettext = function ngettext(str1, strN, n) { - if (translations && plural_fn) { - // plural form translations are indexed by the singular variant - const translation = translations[str1]; - - if (translation) { - const plural_index = plural_fn(n); - - // the plural function either returns direct index (integer) in the plural - // translations or a boolean indicating simple plural form which - // needs to be converted to index 0 (singular) or 1 (plural) - const index = plural_index === true ? 1 : plural_index || 0; - - if (translation[index]) return translation[index]; - } - } - - // fallback, return the original text - return n === 1 ? str1 : strN; -}; - -export default agama; diff --git a/web/src/agama.ts b/web/src/agama.ts new file mode 100644 index 0000000000..9ecc8a57cf --- /dev/null +++ b/web/src/agama.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) [2024] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation; either version 2 of the License, or (at your option) + * any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +/** + * This module provides a global "agama" object which can be use from other + * scripts like "po.js". + */ + +// mapping with the current translations +let translations = {}; +// function used for computing the plural form index +let plural_fn: (n: number) => boolean; + +const agama = { + // the current language + language: "en", + + // set the current translations, called from po..js + locale: (po) => { + if (po) { + Object.assign(translations, po); + + const header = po[""]; + if (header) { + if (header["plural-forms"]) plural_fn = header["plural-forms"]; + if (header.language) agama.language = header.language; + } + } else if (po === null) { + translations = {}; + plural_fn = undefined; + agama.language = "en"; + } + }, + + /** + * Get a translation for a singular text + * @param str input text + * @return translated text or the original text if the translation is not found + */ + gettext: (str: string): string => { + if (translations) { + const translated = translations[str]; + if (translated?.[0]) return translated[0]; + } + + // fallback, return the original text + return str; + }, + + /** + * get a translation for a plural text + * @param str1 input singular text + * @param strN input plural text + * @param n the actual number which decides whether to use the + * singular or plural form (of which plural form if there are several of them) + * @return translated text or the original text if the translation is not found + */ + ngettext: (str1: string, strN: string, n: number) => { + if (translations && plural_fn) { + // plural form translations are indexed by the singular variant + const translation = translations[str1]; + + if (translation) { + const plural_index = plural_fn(n); + + // the plural function either returns direct index (integer) in the plural + // translations or a boolean indicating simple plural form which + // needs to be converted to index 0 (singular) or 1 (plural) + const index = plural_index === true ? 1 : plural_index || 0; + + if (translation[index]) return translation[index]; + } + } + + // fallback, return the original text + return n === 1 ? str1 : strN; + }, +}; + +export default agama; diff --git a/web/src/client/index.js b/web/src/client/index.ts similarity index 55% rename from web/src/client/index.js rename to web/src/client/index.ts index 5e161ef5f3..97826e5cd5 100644 --- a/web/src/client/index.js +++ b/web/src/client/index.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) [2021-2023] SUSE LLC + * Copyright (c) [2021-2024] SUSE LLC * * All Rights Reserved. * @@ -20,28 +20,40 @@ * find current contact information at www.suse.com. */ -// @ts-check - import { WSClient } from "./ws"; -/** - * @typedef {object} InstallerClient - * @property {() => boolean} isConnected - determines whether the client is connected - * @property {() => boolean} isRecoverable - determines whether the client is recoverable after disconnected - * @property {(handler: () => void) => (() => void)} onConnect - registers a handler to run - * @property {(handler: () => void) => (() => void)} onDisconnect - registers a handler to run - * when the connection is lost. It returns a function to deregister the - * handler. - * @property {(handler: (any) => void) => (() => void)} onEvent - registers a handler to run on events - */ +type VoidFn = () => void; +type BooleanFn = () => boolean; +type EventHandlerFn = (event) => void; + +export type InstallerClient = { + /** Whether the client is connected. */ + isConnected: BooleanFn; + /** Whether the client is recoverable after disconnecting. */ + isRecoverable: BooleanFn; + /** + * Registers a handler to run when connection is set. It returns a function + * for deregistering the handler. + */ + onConnect: (handler: VoidFn) => VoidFn; + /** + * Registers a handler to run when connection is lost. It returns a function + * for deregistering the handler. + */ + onDisconnect: (handler: VoidFn) => VoidFn; + /** + * Registers a handler to run on events. It returns a function for + * deregistering the handler. + */ + onEvent: (handler: EventHandlerFn) => VoidFn; +}; /** * Creates the Agama client * - * @param {URL} url - URL of the HTTP API. - * @return {InstallerClient} + * @param url - URL of the HTTP API. */ -const createClient = (url) => { +const createClient = (url: URL): InstallerClient => { url.hash = ""; url.pathname = url.pathname.concat("api/ws"); url.protocol = url.protocol === "http:" ? "ws" : "wss"; @@ -53,9 +65,9 @@ const createClient = (url) => { return { isConnected, isRecoverable, - onConnect: (handler) => ws.onOpen(handler), - onDisconnect: (handler) => ws.onClose(handler), - onEvent: (handler) => ws.onEvent(handler), + onConnect: (handler: VoidFn) => ws.onOpen(handler), + onDisconnect: (handler: VoidFn) => ws.onClose(handler), + onEvent: (handler: EventHandlerFn) => ws.onEvent(handler), }; }; diff --git a/web/src/client/ws.js b/web/src/client/ws.ts similarity index 85% rename from web/src/client/ws.js rename to web/src/client/ws.ts index b63c8e2a51..608001f2cd 100644 --- a/web/src/client/ws.js +++ b/web/src/client/ws.ts @@ -20,19 +20,13 @@ * find current contact information at www.suse.com. */ -// @ts-check - -/** - * @callback RemoveFn - * @return {void} - */ +type RemoveFn = () => void; +type BaseHandlerFn = () => void; +type EventHandlerFn = (event) => void; /** * Enum for the WebSocket states. - * - * */ - const SocketStates = Object.freeze({ CONNECTED: 0, CONNECTING: 1, @@ -52,10 +46,25 @@ const ATTEMPT_INTERVAL = 1000; * HTTPClient API. */ class WSClient { + url: string; + + client: WebSocket; + + handlers: { + open: Array; + close: Array; + error: Array; + events: Array; + }; + + reconnectAttempts: number; + + timeout: ReturnType; + /** - * @param {URL} url - Websocket URL. + * @param url - Websocket URL. */ - constructor(url) { + constructor(url: URL) { this.url = url.toString(); this.handlers = { @@ -126,13 +135,10 @@ class WSClient { /** * Registers a handler for events. * - * The handler is executed for all the events. It is up to the callback to - * filter the relevant events. - * - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} + * The handler is executed for all events. It is up to the callback to + * filter the relevant ones for it. */ - onEvent(func) { + onEvent(func: EventHandlerFn): RemoveFn { this.handlers.events.push(func); return () => { const position = this.handlers.events.indexOf(func); @@ -144,11 +150,8 @@ class WSClient { * Registers a handler for close socket. * * The handler is executed when the socket is close. - * - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} */ - onClose(func) { + onClose(func: BaseHandlerFn): RemoveFn { this.handlers.close.push(func); return () => { @@ -161,10 +164,8 @@ class WSClient { * Registers a handler for open socket. * * The handler is executed when the socket is open. - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} */ - onOpen(func) { + onOpen(func: BaseHandlerFn): RemoveFn { this.handlers.open.push(func); return () => { @@ -177,11 +178,8 @@ class WSClient { * Registers a handler for socket errors. * * The handler is executed when an error is reported by the socket. - * - * @param {(object) => void} func - Handler function to register. - * @return {RemoveFn} */ - onError(func) { + onError(func: BaseHandlerFn): RemoveFn { this.handlers.error.push(func); return () => { @@ -195,9 +193,9 @@ class WSClient { * * Dispatchs an event by running all the handlers. * - * @param {object} event - Event object, which is basically a websocket message. + * @param event - Event object, which is basically a websocket message. */ - dispatchEvent(event) { + dispatchEvent(event: MessageEvent) { const eventObject = JSON.parse(event.data); this.handlers.events.forEach((f) => f(eventObject)); } diff --git a/web/src/components/core/EmailInput.test.jsx b/web/src/components/core/EmailInput.test.tsx similarity index 74% rename from web/src/components/core/EmailInput.test.jsx rename to web/src/components/core/EmailInput.test.tsx index b4572709f3..1a050e9f15 100644 --- a/web/src/components/core/EmailInput.test.jsx +++ b/web/src/components/core/EmailInput.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -23,9 +23,35 @@ import React, { useState } from "react"; import { screen } from "@testing-library/react"; -import EmailInput from "./EmailInput"; +import EmailInput, { EmailInputProps } from "./EmailInput"; import { plainRender } from "~/test-utils"; +/** + * Controlled component for testing the EmailInputProps + * + * Instead of testing if given callbacks are called, below tests are going to + * check the rendered result to be more aligned with the React Testing Library + * principles, https://testing-library.com/docs/guiding-principles/ + * + */ +const EmailInputTest = (props: EmailInputProps) => { + const [email, setEmail] = useState(""); + const [isValid, setIsValid] = useState(true); + + return ( + <> + setEmail(v)} + onValidate={setIsValid} + /> + {email &&

Email value updated!

} + {isValid === false &&

Email is not valid!

} + + ); +}; + describe("EmailInput component", () => { it("renders an email input", () => { plainRender( @@ -36,27 +62,6 @@ describe("EmailInput component", () => { expect(inputField).toHaveAttribute("type", "email"); }); - // Using a controlled component for testing the rendered result instead of testing if - // the given onChange callback is called. The former is more aligned with the - // React Testing Library principles, https://testing-library.com/docs/guiding-principles/ - const EmailInputTest = (props) => { - const [email, setEmail] = useState(""); - const [isValid, setIsValid] = useState(true); - - return ( - <> - setEmail(v)} - onValidate={setIsValid} - /> - {email &&

Email value updated!

} - {isValid === false &&

Email is not valid!

} - - ); - }; - it("triggers onChange callback", async () => { const { user } = plainRender(); const emailInput = screen.getByRole("textbox", { name: "Test email" }); diff --git a/web/src/components/core/EmailInput.jsx b/web/src/components/core/EmailInput.tsx similarity index 67% rename from web/src/components/core/EmailInput.jsx rename to web/src/components/core/EmailInput.tsx index 6bda2447b5..5d3bf09335 100644 --- a/web/src/components/core/EmailInput.jsx +++ b/web/src/components/core/EmailInput.tsx @@ -21,28 +21,25 @@ */ import React, { useEffect, useState } from "react"; -import { InputGroup, TextInput } from "@patternfly/react-core"; -import { noop } from "~/utils"; +import { InputGroup, TextInput, TextInputProps } from "@patternfly/react-core"; +import { isEmpty, noop } from "~/utils"; /** * Email validation. * * Code inspired by https://github.com/manishsaraan/email-validator/blob/master/index.js - * - * @param {string} email - * @returns {boolean} */ -const validateEmail = (email) => { +const validateEmail = (email: string) => { const regexp = /^[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; - const validateFormat = (email) => { + const validateFormat = (email: string) => { const parts = email.split("@"); return parts.length === 2 && regexp.test(email); }; - const validateSizes = (email) => { + const validateSizes = (email: string) => { const [account, address] = email.split("@"); if (account.length > 64) return false; @@ -58,27 +55,29 @@ const validateEmail = (email) => { return validateFormat(email) && validateSizes(email); }; +export type EmailInputProps = TextInputProps & { onValidate?: (isValid: boolean) => void }; + /** * Renders an email input field which validates its value. * @component * - * @param {(boolean) => void} onValidate - Callback to be called every time the input value is + * @param onValidate - Callback to be called every time the input value is * validated. - * @param {Object} props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, + * @param props - Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, * except `type` and `validated` which are managed by the component. */ -export default function EmailInput({ onValidate = noop, ...props }) { +export default function EmailInput({ onValidate = noop, value, ...props }: EmailInputProps) { const [isValid, setIsValid] = useState(true); useEffect(() => { - const isValid = props.value.length === 0 || validateEmail(props.value); + const isValid = typeof value === "string" && (isEmpty(value) || validateEmail(value)); setIsValid(isValid); - onValidate(isValid); - }, [onValidate, props.value, setIsValid]); + typeof onValidate === "function" && onValidate(isValid); + }, [onValidate, value, setIsValid]); return ( - + ); } diff --git a/web/src/components/core/ExpandableSelector.test.jsx b/web/src/components/core/ExpandableSelector.test.tsx similarity index 96% rename from web/src/components/core/ExpandableSelector.test.jsx rename to web/src/components/core/ExpandableSelector.test.tsx index bc1f776d71..9f1f9e6815 100644 --- a/web/src/components/core/ExpandableSelector.test.jsx +++ b/web/src/components/core/ExpandableSelector.test.tsx @@ -24,8 +24,13 @@ import React from "react"; import { screen, within } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import { ExpandableSelector } from "~/components/core"; +import { ExpandableSelectorColumn } from "./ExpandableSelector"; -const sda = { +let consoleErrorSpy: jest.SpyInstance; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +const sda: any = { sid: "59", isDrive: true, type: "disk", @@ -112,18 +117,20 @@ const vg = { lvs: [lv1], }; -const columns = [ - { name: "Device", value: (item) => item.name }, +const columns: ExpandableSelectorColumn[] = [ + // FIXME: do not use any but the right types once storage part is rewritten. + // Or even better, write a test not coupled to storage + { name: "Device", value: (item: any) => item.name }, { name: "Content", - value: (item) => { + value: (item: any) => { if (item.isDrive) return item.systems.map((s, i) =>

{s}

); if (item.type === "vg") return `${item.lvs.length} logical volume(s)`; return item.content; }, }, - { name: "Size", value: (item) => item.size }, + { name: "Size", value: (item: any) => item.size }, ]; const onChangeFn = jest.fn(); @@ -141,11 +148,12 @@ const commonProps = { describe("ExpandableSelector", () => { beforeAll(() => { - jest.spyOn(console, "error").mockImplementation(); + consoleErrorSpy = jest.spyOn(console, "error"); + consoleErrorSpy.mockImplementation(); }); afterAll(() => { - console.error.mockRestore(); + consoleErrorSpy.mockRestore(); }); beforeEach(() => { diff --git a/web/src/components/core/ExpandableSelector.jsx b/web/src/components/core/ExpandableSelector.tsx similarity index 68% rename from web/src/components/core/ExpandableSelector.jsx rename to web/src/components/core/ExpandableSelector.tsx index 37860fa459..b8c04f58fa 100644 --- a/web/src/components/core/ExpandableSelector.jsx +++ b/web/src/components/core/ExpandableSelector.tsx @@ -20,11 +20,10 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useState } from "react"; import { Table, + TableProps, Thead, Tr, Th, @@ -34,10 +33,7 @@ import { RowSelectVariant, } from "@patternfly/react-table"; -/** - * @typedef {import("@patternfly/react-table").TableProps} TableProps - * @typedef {import("react").RefAttributes} HTMLTableProps - */ +/* eslint-disable @typescript-eslint/no-explicit-any */ /** * An object for sharing data across nested maps @@ -47,26 +43,48 @@ import { * places, as it is the case of the rowIndex prop here. * * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions#passing_arguments - * - * @typedef {object} SharedData - * @property {number} rowIndex - The current row index, to be incremented each time a table row is generated. */ -/** - * @typedef {object} ExpandableSelectorColumn - * @property {string} name - The column header text. - * @property {(object) => React.ReactNode} value - A function receiving - * the item to work with and returning the column value. - * @property {string} [classNames] - space-separated list of additional CSS class names. - */ +type SharedData = { + rowIndex: number; +}; + +export type ExpandableSelectorColumn = { + /** The column header text */ + name: string; + /** A function receiving the item to work with and returns the column value */ + value: (item: object) => React.ReactNode; + /** Space-separated list of additional CSS class names */ + classNames?: string; +}; + +export type ExpandableSelectorProps = { + /** Collection of objects defining columns. */ + columns?: ExpandableSelectorColumn[]; + /** Whether multiple selection is allowed. */ + isMultiple?: boolean; + /** Collection of items to be rendered. */ + items?: object[]; + /** The key for retrieving the item id. */ + itemIdKey?: string; + /** Lookup method to retrieve children from given item. */ + itemChildren?: (item: object) => object[]; + /** Whether an item will be selectable or not. */ + itemSelectable?: (item: object) => boolean; + /** Callback to add additional CSS class names to item row. */ + itemClassNames?: (item: object) => string | undefined; + /** Collection of selected items. */ + itemsSelected?: object[]; + /** Ids of initially expanded items. */ + initialExpandedKeys?: any[]; + /** Callback to be triggered when selection changes. */ + onSelectionChange?: (selection: object[]) => void; +} & TableProps; /** * Internal component for building the table header - * - * @param {object} props - * @param {ExpandableSelectorColumn[]} props.columns */ -const TableHeader = ({ columns }) => ( +const TableHeader = ({ columns }: { columns: ExpandableSelectorColumn[] }) => ( @@ -86,14 +104,14 @@ const TableHeader = ({ columns }) => ( * It logs information to console.error if given value does not match * expectations. * - * @param {*} selection - The value to check. - * @param {boolean} allowMultiple - Whether the returned collection can have + * @param selection - The value to check. + * @param allowMultiple - Whether the returned collection can have * more than one item - * @return {Array} Empty array if given value is not valid. The first element if + * @return Empty array if given value is not valid. The first element if * it is a collection with more than one but selector does not allow multiple. * The original value otherwise. */ -const sanitizeSelection = (selection, allowMultiple) => { +const sanitizeSelection = (selection: any[], allowMultiple: boolean): any[] => { if (!Array.isArray(selection)) { console.error("`itemSelected` prop must be an array. Ignoring given value", selection); return []; @@ -117,20 +135,6 @@ const sanitizeSelection = (selection, allowMultiple) => { * * @note It only accepts one nesting level. * - * @typedef {object} ExpandableSelectorBaseProps - * @property {ExpandableSelectorColumn[]} [columns=[]] - Collection of objects defining columns. - * @property {boolean} [isMultiple=false] - Whether multiple selection is allowed. - * @property {object[]} [items=[]] - Collection of items to be rendered. - * @property {string} [itemIdKey="id"] - The key for retrieving the item id. - * @property {(item: object) => Array} [itemChildren=() => []] - Lookup method to retrieve children from given item. - * @property {(item: object) => boolean} [itemSelectable=() => true] - Whether an item will be selectable or not. - * @property {(item: object) => (string|undefined)} [itemClassNames=() => ""] - Callback that allows adding additional CSS class names to item row. - * @property {object[]} [itemsSelected=[]] - Collection of selected items. - * @property {any[]} [initialExpandedKeys=[]] - Ids of initially expanded items. - * @property {(selection: Array) => void} [onSelectionChange=noop] - Callback to be triggered when selection changes. - * - * @typedef {ExpandableSelectorBaseProps & TableProps & HTMLTableProps} ExpandableSelectorProps - * * @param {ExpandableSelectorProps} props */ export default function ExpandableSelector({ @@ -145,10 +149,10 @@ export default function ExpandableSelector({ initialExpandedKeys = [], onSelectionChange, ...tableProps -}) { +}: ExpandableSelectorProps) { const [expandedItemsKeys, setExpandedItemsKeys] = useState(initialExpandedKeys); const selection = sanitizeSelection(itemsSelected, isMultiple); - const isItemSelected = (item) => { + const isItemSelected = (item: object) => { const selected = selection.find((selectionItem) => { return ( Object.hasOwn(selectionItem, itemIdKey) && selectionItem[itemIdKey] === item[itemIdKey] @@ -157,8 +161,8 @@ export default function ExpandableSelector({ return selected !== undefined || selection.includes(item); }; - const isItemExpanded = (key) => expandedItemsKeys.includes(key); - const toggleExpanded = (key) => { + const isItemExpanded = (key: string | number) => expandedItemsKeys.includes(key); + const toggleExpanded = (key: string | number) => { if (isItemExpanded(key)) { setExpandedItemsKeys(expandedItemsKeys.filter((k) => k !== key)); } else { @@ -166,7 +170,7 @@ export default function ExpandableSelector({ } }; - const updateSelection = (item) => { + const updateSelection = (item: object) => { if (!isMultiple) { onSelectionChange([item]); return; @@ -182,11 +186,11 @@ export default function ExpandableSelector({ /** * Render method for building the markup for an item child * - * @param {object} item - The child to be rendered - * @param {boolean} isExpanded - Whether the child should be shown or not - * @param {SharedData} sharedData - An object holding shared data + * @param item - The child to be rendered + * @param isExpanded - Whether the child should be shown or not + * @param sharedData - An object holding shared data */ - const renderItemChild = (item, isExpanded, sharedData) => { + const renderItemChild = (item: object, isExpanded: boolean, sharedData: SharedData) => { const rowIndex = sharedData.rowIndex++; const selectProps = { @@ -212,10 +216,10 @@ export default function ExpandableSelector({ /** * Render method for building the markup for item * - * @param {object} item - The item to be rendered - * @param {SharedData} sharedData - An object holding shared data + * @param item - The item to be rendered + * @param sharedData - An object holding shared data */ - const renderItem = (item, sharedData) => { + const renderItem = (item: object, sharedData: SharedData) => { const itemKey = item[itemIdKey]; const rowIndex = sharedData.rowIndex++; const children = itemChildren(item); diff --git a/web/src/components/core/IssuesHint.test.jsx b/web/src/components/core/IssuesHint.test.tsx similarity index 97% rename from web/src/components/core/IssuesHint.test.jsx rename to web/src/components/core/IssuesHint.test.tsx index a4a0d5a19e..6bead73a40 100644 --- a/web/src/components/core/IssuesHint.test.jsx +++ b/web/src/components/core/IssuesHint.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/core/IssuesHint.jsx b/web/src/components/core/IssuesHint.tsx similarity index 91% rename from web/src/components/core/IssuesHint.jsx rename to web/src/components/core/IssuesHint.tsx index 9a9c92a6d9..86ee00cad7 100644 --- a/web/src/components/core/IssuesHint.jsx +++ b/web/src/components/core/IssuesHint.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -23,6 +23,7 @@ import React from "react"; import { Hint, HintBody, List, ListItem, Stack } from "@patternfly/react-core"; import { _ } from "~/i18n"; +import { Issue } from "~/types/issues"; export default function IssuesHint({ issues }) { if (issues === undefined || issues.length === 0) return; @@ -35,7 +36,7 @@ export default function IssuesHint({ issues }) { {_("Before starting the installation, you need to address the following problems:")}

- {issues.map((i, idx) => ( + {issues.map((i: Issue, idx: number) => ( {i.description} ))} diff --git a/web/src/components/core/ListSearch.test.jsx b/web/src/components/core/ListSearch.test.tsx similarity index 98% rename from web/src/components/core/ListSearch.test.jsx rename to web/src/components/core/ListSearch.test.tsx index 1baf97cfea..d29e368dfb 100644 --- a/web/src/components/core/ListSearch.test.jsx +++ b/web/src/components/core/ListSearch.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * diff --git a/web/src/components/core/ListSearch.jsx b/web/src/components/core/ListSearch.tsx similarity index 76% rename from web/src/components/core/ListSearch.jsx rename to web/src/components/core/ListSearch.tsx index 1b3467f1f0..780feb574b 100644 --- a/web/src/components/core/ListSearch.jsx +++ b/web/src/components/core/ListSearch.tsx @@ -25,44 +25,47 @@ import { SearchInput } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { noop, useDebounce } from "~/utils"; -const search = (elements, term) => { +type ListSearchProps = { + /** Text to display as placeholder for the search input. */ + placeholder?: string; + /** List of elements in which to search. */ + elements: T[]; + /** Callback to be called with the filtered list of elements. */ + onChange: (elements: T[]) => void; +}; + +function search(elements: T[], term: string): T[] { const value = term.toLowerCase(); - const match = (element) => { + const match = (element: T) => { return Object.values(element).join("").toLowerCase().includes(value); }; return elements.filter(match); -}; +} /** - * TODO: Rename and/or refactor? * Input field for searching in a given list of elements. * @component - * - * @param {object} props - * @param {string} [props.placeholder] - * @param {object[]} [props.elements] - List of elements in which to search. - * @param {(elements: object[]) => void} [props.onChange] - Callback to be called with the filtered list of elements. */ -export default function ListSearch({ +export default function ListSearch({ placeholder = _("Search"), elements = [], onChange: onChangeProp = noop, -}) { +}: ListSearchProps) { const [value, setValue] = useState(""); const [resultSize, setResultSize] = useState(elements.length); - const updateResult = (result) => { + const updateResult = (result: T[]) => { setResultSize(result.length); onChangeProp(result); }; - const searchHandler = useDebounce((term) => { + const searchHandler = useDebounce((term: string) => { updateResult(search(elements, term)); }, 500); - const onChange = (value) => { + const onChange = (value: string) => { setValue(value); searchHandler(value); }; diff --git a/web/src/components/core/NumericTextInput.test.jsx b/web/src/components/core/NumericTextInput.test.tsx similarity index 95% rename from web/src/components/core/NumericTextInput.test.jsx rename to web/src/components/core/NumericTextInput.test.tsx index 2078a24e5c..760aac843e 100644 --- a/web/src/components/core/NumericTextInput.test.jsx +++ b/web/src/components/core/NumericTextInput.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022-2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -29,7 +29,7 @@ import { NumericTextInput } from "~/components/core"; // the given onChange callback is called. The former is more aligned with the // React Testing Library principles, https://testing-library.com/docs/guiding-principles const Input = ({ value: initialValue = "" }) => { - const [value, setValue] = useState(initialValue); + const [value, setValue] = useState(initialValue); return ; }; diff --git a/web/src/components/core/NumericTextInput.jsx b/web/src/components/core/NumericTextInput.tsx similarity index 66% rename from web/src/components/core/NumericTextInput.jsx rename to web/src/components/core/NumericTextInput.tsx index 1ecd2aa984..50302af7b2 100644 --- a/web/src/components/core/NumericTextInput.jsx +++ b/web/src/components/core/NumericTextInput.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -20,19 +20,14 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React from "react"; -import { TextInput } from "@patternfly/react-core"; +import { TextInput, TextInputProps } from "@patternfly/react-core"; import { noop } from "~/utils"; -/** - * Callback function for notifying a valid input change - * - * @callback onChangeFn - * @param {string|number} the input value - * @return {void} - */ +type NumericTextInputProps = { + value: string | number; + onChange: (value: string | number) => void; +} & Omit; /** * Helper component for having an input text limited to not signed numbers @@ -41,17 +36,16 @@ import { noop } from "~/utils"; * Based on {@link https://www.patternfly.org/components/forms/text-input PF/TextInput} * * @note It allows empty value too. - * - * @param {object} props - * @param {string|number} props.value - the input value - * @param {onChangeFn} props.onChange - the callback to be called when the entered value match the input pattern - * @param {import("@patternfly/react-core").TextInputProps} props.textInputProps */ -export default function NumericTextInput({ value = "", onChange = noop, ...textInputProps }) { +export default function NumericTextInput({ + value = "", + onChange = noop, + ...textInputProps +}: NumericTextInputProps) { // NOTE: Using \d* instead of \d+ at the beginning to allow empty const pattern = /^\d*\.?\d*$/; - const handleOnChange = (_, value) => { + const handleOnChange: TextInputProps["onChange"] = (_, value) => { if (pattern.test(value)) { onChange(value); } diff --git a/web/src/components/core/Page.test.tsx b/web/src/components/core/Page.test.tsx index 242e22a875..5c90132aa1 100644 --- a/web/src/components/core/Page.test.tsx +++ b/web/src/components/core/Page.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023-2024] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -46,7 +46,7 @@ describe("Page", () => { it("renders given children", () => { plainRender( -

{_("The Page Component")}

+

The Page Component

, ); screen.getByRole("heading", { name: "The Page Component" }); @@ -177,7 +177,7 @@ describe("Page", () => { }); describe("Page.Header", () => { it("renders a node that sticks to top", () => { - plainRender({_("The Header")}); + plainRender(The Header); const content = screen.getByText("The Header"); const container = content.parentNode as HTMLElement; expect(container.classList.contains("pf-m-sticky-top")).toBe(true); @@ -186,19 +186,19 @@ describe("Page", () => { describe("Page.Section", () => { it("outputs to console.error if both are missing, title and aria-label", () => { - plainRender({_("Content")}); + plainRender(Content); expect(console.error).toHaveBeenCalledWith(expect.stringContaining("must have either")); }); it("renders a section node", () => { - plainRender({_("The Content")}); + plainRender(The Content); const section = screen.getByRole("region"); within(section).getByText("The Content"); }); it("adds the aria-labelledby attribute when title is given but aria-label is not", () => { const { rerender } = plainRender( - {_("The Content")}, + The Content, ); const section = screen.getByRole("region"); expect(section).toHaveAttribute("aria-labelledby"); @@ -206,7 +206,7 @@ describe("Page", () => { // aria-label is given through Page.Section props rerender( - {_("The Content")} + The Content , ); expect(section).not.toHaveAttribute("aria-labelledby"); @@ -214,25 +214,25 @@ describe("Page", () => { // aria-label is given through pfCardProps rerender( - {_("The Content")} + The Content , ); expect(section).not.toHaveAttribute("aria-labelledby"); // None was given, title nor aria-label - rerender({_("The Content")}); + rerender(The Content); expect(section).not.toHaveAttribute("aria-labelledby"); }); it("renders given content props (title, value, description, actions, and children (content)", () => { plainRender( {_("Disable")}} + title="A section" + value="Enabled" + description="Testing section with title, value, description, content, and actions" + actions={Disable} > - {_("The Content")} + The Content , ); const section = screen.getByRole("region"); diff --git a/web/src/components/core/PasswordInput.test.jsx b/web/src/components/core/PasswordInput.test.tsx similarity index 91% rename from web/src/components/core/PasswordInput.test.jsx rename to web/src/components/core/PasswordInput.test.tsx index a0e872da6f..ad4f5aca27 100644 --- a/web/src/components/core/PasswordInput.test.jsx +++ b/web/src/components/core/PasswordInput.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2025] SUSE LLC * * All Rights Reserved. * @@ -24,19 +24,18 @@ import React, { useState } from "react"; import { screen } from "@testing-library/react"; import { plainRender } from "~/test-utils"; import userEvent from "@testing-library/user-event"; -import PasswordInput from "./PasswordInput"; -import { _ } from "~/i18n"; +import PasswordInput, { PasswordInputProps } from "./PasswordInput"; describe("PasswordInput Component", () => { it("renders a password input", () => { - plainRender(); + plainRender(); const inputField = screen.getByLabelText("User password"); expect(inputField).toHaveAttribute("type", "password"); }); it("allows revealing the password", async () => { - plainRender(); + plainRender(); const passwordInput = screen.getByLabelText("User password"); const button = screen.getByRole("button"); @@ -48,7 +47,7 @@ describe("PasswordInput Component", () => { it("applies autoFocus behavior correctly", () => { plainRender( - , + , ); const inputField = screen.getByLabelText("User password"); @@ -58,7 +57,7 @@ describe("PasswordInput Component", () => { // Using a controlled component for testing the rendered result instead of testing if // the given onChange callback is called. The former is more aligned with the // React Testing Library principles, https://testing-library.com/docs/guiding-principles/ - const PasswordInputTest = (props) => { + const PasswordInputTest = (props: PasswordInputProps) => { const [password, setPassword] = useState(null); return ( diff --git a/web/src/components/core/PasswordInput.jsx b/web/src/components/core/PasswordInput.tsx similarity index 85% rename from web/src/components/core/PasswordInput.jsx rename to web/src/components/core/PasswordInput.tsx index 4f31fa24c5..ee373c1def 100644 --- a/web/src/components/core/PasswordInput.jsx +++ b/web/src/components/core/PasswordInput.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2023] SUSE LLC + * Copyright (c) [2023-2024] SUSE LLC * * All Rights Reserved. * @@ -20,29 +20,26 @@ * find current contact information at www.suse.com. */ -// @ts-check - import React, { useState } from "react"; -import { Button, InputGroup, TextInput } from "@patternfly/react-core"; +import { Button, InputGroup, TextInput, TextInputProps } from "@patternfly/react-core"; import { _ } from "~/i18n"; import { Icon } from "~/components/layout"; /** - * @typedef {import("@patternfly/react-core").TextInputProps} TextInputProps - * * Props matching the {@link https://www.patternfly.org/components/forms/text-input PF/TextInput}, * except `type` that will be forced to 'password'. - * @typedef {Omit & { inputRef?: React.Ref }} PasswordInputProps */ +export type PasswordInputProps = Omit & { + inputRef?: React.Ref; +}; /** * Renders a password input field and a toggle button that can be used to reveal * and hide the password * @component * - * @param {PasswordInputProps} props */ -export default function PasswordInput({ id, inputRef, ...props }) { +export default function PasswordInput({ id, inputRef, ...props }: PasswordInputProps) { const [showPassword, setShowPassword] = useState(false); const visibilityIconName = showPassword ? "visibility_off" : "visibility"; diff --git a/web/src/components/core/Popup.test.jsx b/web/src/components/core/Popup.test.tsx similarity index 92% rename from web/src/components/core/Popup.test.jsx rename to web/src/components/core/Popup.test.tsx index 8bb003d8ec..4b29e1c098 100644 --- a/web/src/components/core/Popup.test.jsx +++ b/web/src/components/core/Popup.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -26,14 +26,15 @@ import { screen, within } from "@testing-library/react"; import { installerRender } from "~/test-utils"; import { Popup } from "~/components/core"; +import { PopupProps } from "./Popup"; -let isOpen; -let isLoading; +let isOpen: boolean; +let isLoading: boolean; const confirmFn = jest.fn(); const cancelFn = jest.fn(); const loadingText = "Loading text"; -const TestingPopup = (props) => { +const TestingPopup = (props: PopupProps) => { const [isMounted, setIsMounted] = useState(true); if (!isMounted) return null; @@ -64,7 +65,7 @@ describe("Popup", () => { }); it("renders nothing", async () => { - installerRender(); + installerRender(Testing); const dialog = screen.queryByRole("dialog"); expect(dialog).toBeNull(); @@ -78,7 +79,7 @@ describe("Popup", () => { }); it("renders the popup content inside a PF/Modal", async () => { - installerRender(); + installerRender(Testing); const dialog = await screen.findByRole("dialog"); expect(dialog.classList.contains("pf-v5-c-modal-box")).toBe(true); @@ -87,7 +88,7 @@ describe("Popup", () => { }); it("does not display a progress message", async () => { - installerRender(); + installerRender(Testing); const dialog = await screen.findByRole("dialog"); @@ -95,7 +96,7 @@ describe("Popup", () => { }); it("renders the popup actions inside a PF/Modal footer", async () => { - installerRender(); + installerRender(Testing); const dialog = await screen.findByRole("dialog"); // NOTE: Sadly, PF Modal/ModalFooter does not have a footer or navigation role. @@ -115,7 +116,7 @@ describe("Popup", () => { }); it("displays progress message instead of the content", async () => { - installerRender(); + installerRender(Testing); const dialog = await screen.findByRole("dialog"); diff --git a/web/src/components/core/Popup.jsx b/web/src/components/core/Popup.tsx similarity index 74% rename from web/src/components/core/Popup.jsx rename to web/src/components/core/Popup.tsx index b11964c047..ec2ba7f6a8 100644 --- a/web/src/components/core/Popup.jsx +++ b/web/src/components/core/Popup.tsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2024] SUSE LLC * * All Rights Reserved. * @@ -20,19 +20,24 @@ * find current contact information at www.suse.com. */ -// @ts-check - -import React from "react"; -import { Button, Modal } from "@patternfly/react-core"; +import React, { isValidElement } from "react"; +import { Button, ButtonProps, Modal, ModalProps } from "@patternfly/react-core"; import { Loading } from "~/components/layout"; import { _ } from "~/i18n"; import { partition } from "~/utils"; -/** - * @typedef {import("@patternfly/react-core").ModalProps} ModalProps - * @typedef {import("@patternfly/react-core").ButtonProps} ButtonProps - * @typedef {Omit} ButtonWithoutVariantProps - */ +type ButtonWithoutVariantProps = Omit; +type PredefinedAction = React.PropsWithChildren; +export type PopupProps = { + /** The block/height size for the dialog. Default is "auto". */ + blockSize?: "auto" | "small" | "medium" | "large"; + /** The inline/width size for the dialog. Default is "medium". */ + inlineSize?: "auto" | "small" | "medium" | "large"; + /** Whether it should display a loading indicator instead of the requested content. */ + isLoading?: boolean; + /** Text displayed when `isLoading` is set to `true` */ + loadingText?: string; +} & Omit; /** * Wrapper component for holding Popup actions @@ -41,20 +46,18 @@ import { partition } from "~/utils"; * Popup.Action or PF/Button * * @see Popup examples. - * - * @param {object} props - * @param {React.ReactNode} [props.children] - a collection of Action components */ -const Actions = ({ children }) => <>{children}; +const Actions = ({ children }: React.PropsWithChildren) => <>{children}; /** * A convenient component representing a Popup action * * Built on top of {@link https://www.patternfly.org/components/button PF/Button} * - * @param {ButtonProps} props */ -const Action = ({ children, ...buttonProps }) => ; +const Action = ({ children, ...buttonProps }: React.PropsWithChildren) => ( + +); /** * A Popup primary action @@ -71,9 +74,8 @@ const Action = ({ children, ...buttonProps }) =>