Skip to content

Commit

Permalink
feat(storage): allow selecting space policy (#1791)
Browse files Browse the repository at this point in the history
* Adds HTTP/D-Bus API to apply changes in the config model, see
#1763.
* UI: allow selecting the space policy for each device.
* UI: adapt page for configuring the custom space policy of a device. 

![localhost_8080_
(19)](https://github.com/user-attachments/assets/276dff10-beaa-4d86-9081-12699a270c28)
  • Loading branch information
joseivanlopez authored Dec 19, 2024
2 parents 9d9b014 + 767cc3f commit 31f5052
Show file tree
Hide file tree
Showing 84 changed files with 2,829 additions and 455 deletions.
4 changes: 4 additions & 0 deletions doc/dbus/bus/org.opensuse.Agama.Storage1.bus.xml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@
<method name="GetConfig">
<arg name="serialized_config" direction="out" type="s"/>
</method>
<method name="SetConfigModel">
<arg name="serialized_model" direction="in" type="s"/>
<arg name="result" direction="out" type="u"/>
</method>
<method name="GetConfigModel">
<arg name="serialized_model" direction="out" type="s"/>
</method>
Expand Down
25 changes: 25 additions & 0 deletions doc/dbus/org.opensuse.Agama.Storage1.doc.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@
-->
<arg name="serialized_config" direction="out" type="s"/>
</method>
<!--
Sets the storage config model.
-->
<method name="SetConfigModel">
<!--
E.g.,
{
"drives": [
{
"name": "/dev/vda",
"partitions": [
{ "mountPath": "/" }
]
}
]
}
-->
<arg name="serialized_model" direction="in" type="s"/>
<!--
Whether the proposal was correctly calculated with the given config model:
0: success
1: failure
-->
<arg name="result" direction="out" type="u"/>
</method>
<!--
Gets the storage config model.
-->
Expand Down
267 changes: 267 additions & 0 deletions doc/storage_proposal_from_profile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
# Calculating a proposal from a profile

The Agama proposal can be calculated either from a very detailed JSON profile or from a "sparse
profile". The information not provided by the profile is automatically inferred (solved) by Agama.
Several layers are involved in the process of obtaining the final storage config used by the Agama
proposal, as shown in the following diagram:

```
JSON profile ------------> JSON profile (solved) ------------> Storage::Config ------------> Storage::Config (solved)
| | |
(JSON solver) (config conversion) (config solver)
```

## JSON solver

The JSON profile provides the *generator* concept. A *generator* allows indicating what volumes to
create without explicitly defining them. The JSON solver (`Agama::Storage::JSONConfigSolver` class)
takes care of replacing the volume generator by the corresponding JSON volumes according to the
product.

For example, a JSON profile like this:

~~~json
{
"drives": [
{
"partitions": [ { "generate": "default" } ]
}
]
}
~~~

would be solved to something like:

~~~json
{
"drives": [
{
"partitions": [
{ "filesystem": { "path": "/" } },
{ "filesystem": { "path": "swap" } }
]
}
]
}
~~~

The volumes are solved with their very minimum information (i.e., a mount path). The resulting
solved JSON is used for getting the storage config object.

## Config conversion

The class `Agama::Storage::ConfigConversions::FromJSON` takes a solved JSON profile and generates a
`Agama::Storage::Config` object. The resulting config only contains the information provided by the
profile. For example, if the profile does not specify a file system type for a partition, then the
config would not have any information about the file system to use for such a partition.

If something is not provided by the profile (e.g., "boot", "size", "filesystem"), then the config
marks that values as default ones. For example:

```json
{
"drives": [
{
"partitions": [
{ "filesystem": { "path": "/" } }
]
}
]
}
```

generates a config with these default values:

```ruby
config.boot.device.default? #=> true

partition = config.drives.first.partitions.first
partition.size.default? #=> true
partition.filesystem.type.default? #=> true
```

The configs set as default and any other missing value have to be solved to a value provided by the
product definition.

## Config solver

The config solver (`Agama::Storage::ConfigSolver` class) assigns a value to all the unknown
properties of a config object. As result, the config object is totally complete and ready to be used
by the agama proposal.

### How sizes are solved

A volume size in the profile:

* Can be totally omitted.
* Can omit the max size.
* Can use "current" as value for min and/or max.

Let's see each case.

#### Omitting size completely

```json
"partitions": [
{ "filesystem": { "path": "/" } }
]
```

In this case, the config conversion would generate something like:

```ruby
partition.size.default? #=> true
partition.size.min #=> nil
partition.size.max #=> nil
```

If the size is default, then the config solver always assigns a value for `#min` and `#max`
according to the product definition and ignoring the current values assigned to `#min` and `#max`.
The solver takes into account the mount path, the fallback devices and swap config in order to set
the proper sizes.

If the size is default and the volume already exists, then the solver sets the current size of the
volume to both `#min` and `#max` sizes.

#### Omitting the max size

```json
"partitions": [
{
"size": { "min": "10 GiB" },
"filesystem": { "path": "/" }
}
]
```

The config conversion generates:

```ruby
partition.size.default? #=> false
partition.size.min #=> Y2Storage::DiskSize.GiB(10)
partition.size.max #=> Y2Storage::DiskSize.Unlimited
```

Note that `#max` is set to unlimited when it does not appear in the profile. In this case, nothing
has to be solved because both `#min` and `#max` have a value.

#### Using "current"

Both *min* and *max* sizes admit "current" as a valid size value in the JSON profile. The "current"
value stands for the current size of the volume. Using "current" is useful for growing or shrinking
a device.

The config conversion knows nothing about the current size of a volume, so it simply replaces
"current" values by `nil`.

For example, in this case:

```json
"partitions": [
{
"search": "/dev/vda1",
"size": { "min": "current" },
"filesystem": { "path": "/" }
}
]
```

the config conversion generates a size with `nil` for `#min`:

```ruby
partition.size.default? #=> false
partition.size.min #=> nil
partition.size.max #=> Y2Storage::DiskSize.Unlimited
```

The config solver replaces the `nil` sizes by the device size. In the example before, let's say that
/dev/vda1 has 10 GiB, so the resulting config would be:

```ruby
partition.size.default? #=> false
partition.size.min #=> Y2Storage::DiskSize.GiB(10)
partition.size.max #=> Y2Storage::DiskSize.Unlimited
```

##### Use case: growing a device

```json
"partitions": [
{
"search": "/dev/vda1",
"size": { "min": "current" },
"filesystem": { "path": "/" }
}
]
```

```ruby
partition.size.default? #=> false
partition.size.min #=> Y2Storage::DiskSize.GiB(10)
partition.size.max #=> Y2Storage::DiskSize.Unlimited
```

##### Use case: shrinking a device

```json
"partitions": [
{
"search": "/dev/vda1",
"size": { "min": 0, "max": "current" },
"filesystem": { "path": "/" }
}
]
```

```ruby
partition.size.default? #=> false
partition.size.min #=> 0
partition.size.max #=> Y2Storage::DiskSize.GiB(10)
```

##### Use case: keeping a device size

Note that this is equivalent to omitting the size.

```json
"partitions": [
{
"search": "/dev/vda1",
"size": { "min": "current", "max": "current" },
"filesystem": { "path": "/" }
}
]
```

```ruby
partition.size.default? #=> false
partition.size.min #=> Y2Storage::DiskSize.GiB(10)
partition.size.max #=> Y2Storage::DiskSize.GiB(10)
```

##### Use case: fallback for not found devices

A profile can specify an "advanced search" to indicate that a volume has to be created if it is not
found in the system.

```json
"partitions": [
{
"search": {
"condition": { "name": "/dev/vda1" },
"ifNotFound": "create"
},
"size": { "min": "current" },
"filesystem": { "path": "/" }
}
]
```

If the device does not exist, then "current" cannot be replaced by any device size. In this case,
the config solver uses the default size defined by the product as fallback for "current".

```ruby
partition.size.default? #=> false
partition.size.min #=> Y2Storage::DiskSize.GiB(15)
partition.size.max #=> Y2Storage::DiskSize.Unlimited
```
8 changes: 8 additions & 0 deletions rust/agama-lib/src/storage/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ impl<'a> StorageClient<'a> {
Ok(settings)
}

/// Set the storage config according to the JSON schema
pub async fn set_config_model(&self, model: Box<RawValue>) -> Result<u32, ServiceError> {
Ok(self
.storage_proxy
.set_config_model(serde_json::to_string(&model).unwrap().as_str())
.await?)
}

/// Get the storage config model according to the JSON schema
pub async fn get_config_model(&self) -> Result<Box<RawValue>, ServiceError> {
let serialized_config_model = self.storage_proxy.get_config_model().await?;
Expand Down
3 changes: 3 additions & 0 deletions rust/agama-lib/src/storage/proxies/storage1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ pub trait Storage1 {
/// Get the current storage config according to the JSON schema
fn get_config(&self) -> zbus::Result<String>;

/// Set the storage config model according to the JSON schema
fn set_config_model(&self, model: &str) -> zbus::Result<u32>;

/// Get the storage config model according to the JSON schema
fn get_config_model(&self) -> zbus::Result<String>;

Expand Down
Loading

0 comments on commit 31f5052

Please sign in to comment.