diff --git a/rust/agama-lib/src/dbus.rs b/rust/agama-lib/src/dbus.rs index 863b42cb65..02ca6f302c 100644 --- a/rust/agama-lib/src/dbus.rs +++ b/rust/agama-lib/src/dbus.rs @@ -1,7 +1,48 @@ +use anyhow::Context; use std::collections::HashMap; -use zbus::zvariant; +use zbus::zvariant::{self, OwnedValue, Value}; + +use crate::error::ServiceError; /// Nested hash to send to D-Bus. pub type NestedHash<'a> = HashMap<&'a str, HashMap<&'a str, zvariant::Value<'a>>>; /// Nested hash as it comes from D-Bus. pub type OwnedNestedHash = HashMap>; + +/// Helper to get property of given type from ManagedObjects map or any generic D-Bus Hash with variant as value +pub fn get_property<'a, T>( + properties: &'a HashMap, + name: &str, +) -> Result +where + T: TryFrom>, + >>::Error: Into, +{ + let value: Value = properties + .get(name) + .ok_or(zbus::zvariant::Error::Message(format!( + "Failed to find property '{}'", + name + )))? + .into(); + + T::try_from(value).map_err(|e| e.into()) +} + +/// It is similar helper like get_property with difference that name does not need to be in HashMap. +/// In such case `None` is returned, so type has to be enclosed in `Option`. +pub fn get_optional_property<'a, T>( + properties: &'a HashMap, + name: &str, +) -> Result, zbus::zvariant::Error> +where + T: TryFrom>, + >>::Error: Into, +{ + if let Some(value) = properties.get(name) { + let value: Value = value.into(); + T::try_from(value).map(|v| Some(v)).map_err(|e| e.into()) + } else { + Ok(None) + } +} diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index b1df6b842e..30f61af834 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -2,7 +2,7 @@ use curl; use serde_json; use std::io; use thiserror::Error; -use zbus; +use zbus::{self, zvariant}; #[derive(Error, Debug)] pub enum ServiceError { @@ -10,6 +10,10 @@ pub enum ServiceError { DBus(#[from] zbus::Error), #[error("Could not connect to Agama bus at '{0}': {1}")] DBusConnectionError(String, #[source] zbus::Error), + #[error("D-Bus protocol error: {0}")] + DBusProtocol(#[from] zbus::fdo::Error), + #[error("Unexpected type on D-Bus '{0}'")] + ZVariant(#[from] zvariant::Error), // it's fine to say only "Error" because the original // specific error will be printed too #[error("Error: {0}")] diff --git a/rust/agama-lib/src/storage.rs b/rust/agama-lib/src/storage.rs index dfb9105357..a6796ae7f5 100644 --- a/rust/agama-lib/src/storage.rs +++ b/rust/agama-lib/src/storage.rs @@ -1,6 +1,7 @@ //! Implements support for handling the storage settings -mod client; +pub mod client; +pub mod device; mod proxies; mod settings; mod store; diff --git a/rust/agama-lib/src/storage/client.rs b/rust/agama-lib/src/storage/client.rs index 06455992b8..3828c02720 100644 --- a/rust/agama-lib/src/storage/client.rs +++ b/rust/agama-lib/src/storage/client.rs @@ -1,12 +1,17 @@ //! Implements a client to access Agama's storage service. -use super::proxies::{BlockDeviceProxy, ProposalCalculatorProxy, ProposalProxy, Storage1Proxy}; +use super::device::{BlockDevice, Device, DeviceInfo}; +use super::proxies::{DeviceProxy, ProposalCalculatorProxy, ProposalProxy, Storage1Proxy}; use super::StorageSettings; +use crate::dbus::{get_optional_property, get_property}; use crate::error::ServiceError; +use anyhow::{anyhow, Context}; use futures_util::future::join_all; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use zbus::zvariant::OwnedObjectPath; +use zbus::fdo::ObjectManagerProxy; +use zbus::names::{InterfaceName, OwnedInterfaceName}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue}; use zbus::Connection; /// Represents a storage device @@ -16,11 +21,100 @@ pub struct StorageDevice { description: String, } +/// Represents a single change action done to storage +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Action { + device: String, + text: String, + subvol: bool, + delete: bool, +} + +/// Represents value for target key of Volume +/// It is snake cased when serializing to be compatible with yast2-storage-ng. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum VolumeTarget { + Default, + NewPartition, + NewVg, + Device, + Filesystem, +} + +impl TryFrom> for VolumeTarget { + type Error = zbus::zvariant::Error; + + fn try_from(value: zbus::zvariant::Value) -> Result { + let svalue: String = value.try_into()?; + match svalue.as_str() { + "default" => Ok(VolumeTarget::Default), + "new_partition" => Ok(VolumeTarget::NewPartition), + "new_vg" => Ok(VolumeTarget::NewVg), + "device" => Ok(VolumeTarget::Device), + "filesystem" => Ok(VolumeTarget::Filesystem), + _ => Err(zbus::zvariant::Error::Message( + format!("Wrong value for Target: {}", svalue).to_string(), + )), + } + } +} + +/// Represents volume outline aka requirements for volume +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct VolumeOutline { + required: bool, + fs_types: Vec, + support_auto_size: bool, + snapshots_configurable: bool, + snaphosts_affect_sizes: bool, + size_relevant_volumes: Vec, +} + +impl TryFrom> for VolumeOutline { + type Error = zbus::zvariant::Error; + + fn try_from(value: zbus::zvariant::Value) -> Result { + let mvalue: HashMap = value.try_into()?; + let res = VolumeOutline { + required: get_property(&mvalue, "Required")?, + fs_types: get_property(&mvalue, "FsTypes")?, + support_auto_size: get_property(&mvalue, "SupportAutoSize")?, + snapshots_configurable: get_property(&mvalue, "SnapshotsConfigurable")?, + snaphosts_affect_sizes: get_property(&mvalue, "SnapshotsAffectSizes")?, + size_relevant_volumes: get_property(&mvalue, "SizeRelevantVolumes")?, + }; + + Ok(res) + } +} + +/// Represents a single volume +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Volume { + mount_path: String, + mount_options: Vec, + target: VolumeTarget, + target_device: Option, + min_size: u64, + max_size: Option, + auto_size: bool, + snapshots: Option, + transactional: Option, + outline: Option, +} + /// D-Bus client for the storage service +#[derive(Clone)] pub struct StorageClient<'a> { pub connection: Connection, calculator_proxy: ProposalCalculatorProxy<'a>, storage_proxy: Storage1Proxy<'a>, + object_manager_proxy: ObjectManagerProxy<'a>, + proposal_proxy: ProposalProxy<'a>, } impl<'a> StorageClient<'a> { @@ -28,6 +122,12 @@ impl<'a> StorageClient<'a> { Ok(Self { calculator_proxy: ProposalCalculatorProxy::new(&connection).await?, storage_proxy: Storage1Proxy::new(&connection).await?, + object_manager_proxy: ObjectManagerProxy::builder(&connection) + .destination("org.opensuse.Agama.Storage1")? + .path("/org/opensuse/Agama/Storage1")? + .build() + .await?, + proposal_proxy: ProposalProxy::new(&connection).await?, connection, }) } @@ -40,6 +140,27 @@ impl<'a> StorageClient<'a> { Ok(ProposalProxy::new(&self.connection).await?) } + pub async fn devices_dirty_bit(&self) -> Result { + Ok(self.storage_proxy.deprecated_system().await?) + } + + pub async fn actions(&self) -> Result, ServiceError> { + let actions = self.proposal_proxy.actions().await?; + let mut result: Vec = Vec::with_capacity(actions.len()); + + for i in actions { + let action = Action { + device: get_property(&i, "Device")?, + text: get_property(&i, "Text")?, + subvol: get_property(&i, "Subvol")?, + delete: get_property(&i, "Delete")?, + }; + result.push(action); + } + + Ok(result) + } + /// Returns the available devices /// /// These devices can be used for installing the system. @@ -55,22 +176,38 @@ impl<'a> StorageClient<'a> { join_all(devices).await.into_iter().collect() } + pub async fn volume_for(&self, mount_path: &str) -> Result { + let volume_hash = self.calculator_proxy.default_volume(mount_path).await?; + let volume = Volume { + mount_path: get_property(&volume_hash, "MountPath")?, + mount_options: get_property(&volume_hash, "MountOptions")?, + target: get_property(&volume_hash, "Target")?, + target_device: get_optional_property(&volume_hash, "TargetDevice")?, + min_size: get_property(&volume_hash, "MinSize")?, + max_size: get_optional_property(&volume_hash, "MaxSize")?, + auto_size: get_property(&volume_hash, "AutoSize")?, + snapshots: get_optional_property(&volume_hash, "Snapshots")?, + transactional: get_optional_property(&volume_hash, "Transactional")?, + outline: get_optional_property(&volume_hash, "Outline")?, + }; + + Ok(volume) + } + /// Returns the storage device for the given D-Bus path async fn storage_device( &self, dbus_path: OwnedObjectPath, ) -> Result { - let proxy = BlockDeviceProxy::builder(&self.connection) + let proxy = DeviceProxy::builder(&self.connection) .path(dbus_path)? .build() .await?; - let name = proxy.name().await?; - // TODO: The description is not used yet. Decide what info to show, for example the device - // size, see https://crates.io/crates/size. - let description = name.clone(); - - Ok(StorageDevice { name, description }) + Ok(StorageDevice { + name: proxy.name().await?, + description: proxy.description().await?, + }) } /// Returns the boot device proposal setting @@ -140,4 +277,106 @@ impl<'a> StorageClient<'a> { Ok(self.calculator_proxy.calculate(dbus_settings).await?) } + + async fn build_device( + &self, + object: &( + OwnedObjectPath, + HashMap>, + ), + ) -> Result { + let interfaces = &object.1; + Ok(Device { + device_info: self.build_device_info(object).await?, + component: None, + drive: None, + block_device: self.build_block_device(interfaces).await?, + filesystem: None, + lvm_lv: None, + lvm_vg: None, + md: None, + multipath: None, + partition: None, + partition_table: None, + raid: None, + }) + } + + pub async fn system_devices(&self) -> Result, ServiceError> { + let objects = self.object_manager_proxy.get_managed_objects().await?; + let mut result = vec![]; + for object in objects { + let path = &object.0; + if !path.as_str().contains("Storage1/system") { + continue; + } + + result.push(self.build_device(&object).await?) + } + + Ok(result) + } + + pub async fn staging_devices(&self) -> Result, ServiceError> { + let objects = self.object_manager_proxy.get_managed_objects().await?; + let mut result = vec![]; + for object in objects { + let path = &object.0; + if !path.as_str().contains("Storage1/staging") { + continue; + } + + result.push(self.build_device(&object).await?) + } + + Ok(result) + } + + async fn build_device_info( + &self, + object: &( + OwnedObjectPath, + HashMap>, + ), + ) -> Result { + let interfaces = &object.1; + let interface: OwnedInterfaceName = + InterfaceName::from_static_str_unchecked("org.opensuse.Agama.Storage1.Device").into(); + let properties = interfaces.get(&interface); + // All devices has to implement device info, so report error if it is not there + if let Some(properties) = properties { + Ok(DeviceInfo { + sid: get_property(properties, "SID")?, + name: get_property(properties, "Name")?, + description: get_property(properties, "Description")?, + }) + } else { + let message = + format!("storage device {} is missing Device interface", object.0).to_string(); + Err(zbus::zvariant::Error::Message(message).into()) + } + } + + async fn build_block_device( + &self, + interfaces: &HashMap>, + ) -> Result, ServiceError> { + let interface: OwnedInterfaceName = + InterfaceName::from_static_str_unchecked("org.opensuse.Agama.Storage1.Block").into(); + let properties = interfaces.get(&interface); + if let Some(properties) = properties { + Ok(Some(BlockDevice { + active: get_property(properties, "Active")?, + encrypted: get_property(properties, "Encrypted")?, + recoverable_size: get_property(properties, "RecoverableSize")?, + size: get_property(properties, "Size")?, + start: get_property(properties, "Start")?, + systems: get_property(properties, "Systems")?, + udev_ids: get_property(properties, "UdevIds")?, + udev_paths: get_property(properties, "UdevPaths")?, + })) + } else { + Ok(None) + } + } } diff --git a/rust/agama-lib/src/storage/device.rs b/rust/agama-lib/src/storage/device.rs new file mode 100644 index 0000000000..5a27b7831f --- /dev/null +++ b/rust/agama-lib/src/storage/device.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; + +/// Information about system device created by composition to reflect different devices on system +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Device { + pub device_info: DeviceInfo, + pub block_device: Option, + pub component: Option, + pub drive: Option, + pub filesystem: Option, + pub lvm_lv: Option, + pub lvm_vg: Option, + pub md: Option, + pub multipath: Option, + pub partition: Option, + pub partition_table: Option, + pub raid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct DeviceInfo { + pub sid: u32, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BlockDevice { + pub active: bool, + pub encrypted: bool, + pub recoverable_size: u64, + pub size: u64, + pub start: u64, + pub systems: Vec, + pub udev_ids: Vec, + pub udev_paths: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Component {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Drive {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Filesystem {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LvmLv {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LvmVg {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MD {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Multipath {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Partition {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PartitionTable {} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Raid {} diff --git a/rust/agama-lib/src/storage/proxies.rs b/rust/agama-lib/src/storage/proxies.rs index 29f08c79cd..3bea233564 100644 --- a/rust/agama-lib/src/storage/proxies.rs +++ b/rust/agama-lib/src/storage/proxies.rs @@ -103,21 +103,30 @@ trait Proposal { #[dbus_proxy( interface = "org.opensuse.Agama.Storage1.Block", - default_service = "org.opensuse.Agama.Storage1" + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" )] -trait BlockDevice { +trait Block { /// Active property #[dbus_proxy(property)] fn active(&self) -> zbus::Result; - /// Name property + /// Encrypted property #[dbus_proxy(property)] - fn name(&self) -> zbus::Result; + fn encrypted(&self) -> zbus::Result; + + /// RecoverableSize property + #[dbus_proxy(property)] + fn recoverable_size(&self) -> zbus::Result; /// Size property #[dbus_proxy(property)] fn size(&self) -> zbus::Result; + /// Start property + #[dbus_proxy(property)] + fn start(&self) -> zbus::Result; + /// Systems property #[dbus_proxy(property)] fn systems(&self) -> zbus::Result>; @@ -130,3 +139,91 @@ trait BlockDevice { #[dbus_proxy(property)] fn udev_paths(&self) -> zbus::Result>; } + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.Drive", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait Drive { + /// Bus property + #[dbus_proxy(property)] + fn bus(&self) -> zbus::Result; + + /// BusId property + #[dbus_proxy(property)] + fn bus_id(&self) -> zbus::Result; + + /// Driver property + #[dbus_proxy(property)] + fn driver(&self) -> zbus::Result>; + + /// Info property + #[dbus_proxy(property)] + fn info(&self) -> zbus::Result>; + + /// Model property + #[dbus_proxy(property)] + fn model(&self) -> zbus::Result; + + /// Transport property + #[dbus_proxy(property)] + fn transport(&self) -> zbus::Result; + + /// Type property + #[dbus_proxy(property)] + fn type_(&self) -> zbus::Result; + + /// Vendor property + #[dbus_proxy(property)] + fn vendor(&self) -> zbus::Result; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.Multipath", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait Multipath { + /// Wires property + #[dbus_proxy(property)] + fn wires(&self) -> zbus::Result>; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.PartitionTable", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait PartitionTable { + /// Partitions property + #[dbus_proxy(property)] + fn partitions(&self) -> zbus::Result>; + + /// Type property + #[dbus_proxy(property)] + fn type_(&self) -> zbus::Result; + + /// UnusedSlots property + #[dbus_proxy(property)] + fn unused_slots(&self) -> zbus::Result>; +} + +#[dbus_proxy( + interface = "org.opensuse.Agama.Storage1.Device", + default_service = "org.opensuse.Agama.Storage1", + default_path = "/org/opensuse/Agama/Storage1" +)] +trait Device { + /// Description property + #[dbus_proxy(property)] + fn description(&self) -> zbus::Result; + + /// Name property + #[dbus_proxy(property)] + fn name(&self) -> zbus::Result; + + /// SID property + #[dbus_proxy(property, name = "SID")] + fn sid(&self) -> zbus::Result; +} diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 8c2602f7b9..46f0cfc909 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -5,6 +5,7 @@ pub mod manager; pub mod network; pub mod questions; pub mod software; +pub mod storage; pub mod users; pub mod web; pub use web::service; diff --git a/rust/agama-server/src/storage.rs b/rust/agama-server/src/storage.rs new file mode 100644 index 0000000000..22dd60eeea --- /dev/null +++ b/rust/agama-server/src/storage.rs @@ -0,0 +1,2 @@ +pub mod web; +pub use web::{storage_service, storage_streams}; diff --git a/rust/agama-server/src/storage/web.rs b/rust/agama-server/src/storage/web.rs new file mode 100644 index 0000000000..15c14e3487 --- /dev/null +++ b/rust/agama-server/src/storage/web.rs @@ -0,0 +1,93 @@ +//! This module implements the web API for the storage service. +//! +//! The module offers two public functions: +//! +//! * `storage_service` which returns the Axum service. +//! * `storage_stream` which offers an stream that emits the storage events coming from D-Bus. + +use std::collections::HashMap; + +use agama_lib::{ + error::ServiceError, + storage::{ + client::{Action, Volume}, + device::Device, + StorageClient, + }, +}; +use anyhow::anyhow; +use axum::{ + extract::{Query, State}, + routing::get, + Json, Router, +}; + +use crate::{ + error::Error, + web::{ + common::{issues_router, progress_router, service_status_router, EventStreams}, + Event, + }, +}; + +pub async fn storage_streams(dbus: zbus::Connection) -> Result { + let result: EventStreams = vec![]; // TODO: + Ok(result) +} + +#[derive(Clone)] +struct StorageState<'a> { + client: StorageClient<'a>, +} + +/// Sets up and returns the axum service for the software module. +pub async fn storage_service(dbus: zbus::Connection) -> Result { + const DBUS_SERVICE: &str = "org.opensuse.Agama.Storage1"; + const DBUS_PATH: &str = "/org/opensuse/Agama/Storage1"; + + let status_router = service_status_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let progress_router = progress_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + let issues_router = issues_router(&dbus, DBUS_SERVICE, DBUS_PATH).await?; + + let client = StorageClient::new(dbus.clone()).await?; + let state = StorageState { client }; + let router = Router::new() + .route("/devices/dirty", get(devices_dirty)) + .route("/devices/system", get(system_devices)) + .route("/devices/result", get(staging_devices)) + .route("/product/volume_for", get(volume_for)) + .route("/proposal/actions", get(actions)) + .merge(status_router) + .merge(progress_router) + .nest("/issues", issues_router) + .with_state(state); + Ok(router) +} + +async fn devices_dirty(State(state): State>) -> Result, Error> { + Ok(Json(state.client.devices_dirty_bit().await?)) +} + +async fn system_devices(State(state): State>) -> Result>, Error> { + Ok(Json(state.client.system_devices().await?)) +} + +async fn staging_devices( + State(state): State>, +) -> Result>, Error> { + Ok(Json(state.client.staging_devices().await?)) +} + +async fn actions(State(state): State>) -> Result>, Error> { + Ok(Json(state.client.actions().await?)) +} + +async fn volume_for( + State(state): State>, + Query(params): Query>, +) -> Result, Error> { + let mount_path = params + .get("mount_path") + .ok_or(anyhow!("Missing mount_path parameter"))?; + Ok(Json(state.client.volume_for(mount_path).await?)) +} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index a7b52d247c..9a9b3c39c4 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -11,6 +11,7 @@ use crate::{ network::{web::network_service, NetworkManagerAdapter}, questions::web::{questions_service, questions_stream}, software::web::{software_service, software_streams}, + storage::web::{storage_service, storage_streams}, users::web::{users_service, users_streams}, web::common::{issues_stream, progress_stream, service_status_stream}, }; @@ -58,6 +59,7 @@ where .add_service("/l10n", l10n_service(dbus.clone(), events.clone()).await?) .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( "/network", network_service(dbus.clone(), network_adapter).await?, @@ -110,6 +112,9 @@ async fn run_events_monitor(dbus: zbus::Connection, events: EventsSender) -> Res for (id, user_stream) in users_streams(dbus.clone()).await? { stream.insert(id, user_stream); } + for (id, storage_stream) in storage_streams(dbus.clone()).await? { + stream.insert(id, storage_stream); + } for (id, software_stream) in software_streams(dbus.clone()).await? { stream.insert(id, software_stream); }