diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 822aa1f34e..74e6dbac4c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -187,6 +187,7 @@ dependencies = [ "agama-network", "agama-software", "agama-storage", + "agama-users", "agama-utils", "async-trait", "gettext-rs", @@ -339,6 +340,25 @@ dependencies = [ "url", ] +[[package]] +name = "agama-users" +version = "0.1.0" +dependencies = [ + "agama-locale-data", + "agama-utils", + "anyhow", + "async-trait", + "gettext-rs", + "regex", + "test-context", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-test", + "tracing", + "zbus", +] + [[package]] name = "agama-utils" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c440a2347e..b51c5698f8 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -14,6 +14,7 @@ members = [ "agama-storage", "agama-transfer", "agama-utils", + "agama-users", "suseconnect-agama", "suseconnect-agama/suseconnect-agama-sys", "xtask", diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index fc0a785f49..cd2c18bdf1 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -13,6 +13,7 @@ agama-network = { path = "../agama-network" } agama-software = { path = "../agama-software" } agama-storage = { path = "../agama-storage" } agama-utils = { path = "../agama-utils" } +agama-users = { path = "../agama-users" } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } async-trait = "0.1.83" diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 4378e6b798..692dada137 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -32,6 +32,7 @@ pub use agama_l10n as l10n; pub use agama_network as network; pub use agama_software as software; pub use agama_storage as storage; +pub use agama_users as users; pub mod test_utils; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 156efdfbb0..f7fe1ff372 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,7 +18,9 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{bootloader, files, hardware, hostname, l10n, message, network, software, storage}; +use crate::{ + bootloader, files, hardware, hostname, l10n, message, network, software, storage, users, +}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ @@ -83,6 +85,8 @@ pub enum Error { Hardware(#[from] hardware::Error), #[error("Cannot dispatch this action in {current} stage (expected {expected}).")] UnexpectedStage { current: Stage, expected: Stage }, + #[error(transparent)] + Users(#[from] users::service::Error), } pub struct Starter { @@ -99,6 +103,7 @@ pub struct Starter { issues: Option>, progress: Option>, hardware: Option, + users: Option>, } impl Starter { @@ -121,6 +126,7 @@ impl Starter { issues: None, progress: None, hardware: None, + users: None, } } @@ -172,6 +178,11 @@ impl Starter { self } + pub fn with_users(mut self, users: Handler) -> Self { + self.users = Some(users); + self + } + /// Starts the service and returns a handler to communicate with it. pub async fn start(self) -> Result, Error> { let issues = match self.issues { @@ -256,6 +267,15 @@ impl Starter { None => hardware::Registry::new_from_system(), }; + let users = match self.users { + Some(users) => users, + None => { + users::Service::starter(self.events.clone(), issues.clone()) + .start() + .await? + } + }; + let mut service = Service { questions: self.questions, progress, @@ -273,6 +293,7 @@ impl Starter { config: Config::default(), system: manager::SystemInfo::default(), product: None, + users: users, }; service.setup().await?; @@ -297,6 +318,7 @@ pub struct Service { product: Option>>, config: Config, system: manager::SystemInfo, + users: Handler, } impl Service { @@ -370,6 +392,10 @@ impl Service { .call(l10n::message::SetConfig::new(config.l10n.clone())) .await?; + self.users + .call(users::message::SetConfig::new(config.users.clone())) + .await?; + self.storage .call(storage::message::SetConfig::new( Arc::clone(product), @@ -484,6 +510,7 @@ impl MessageHandler for Service { let storage = self.storage.call(storage::message::GetSystem).await?; let network = self.network.get_system().await?; let software = self.software.call(software::message::GetSystem).await?; + Ok(SystemInfo { hostname, l10n, @@ -512,6 +539,7 @@ impl MessageHandler for Service { let questions = self.questions.call(question::message::GetConfig).await?; let network = self.network.get_config().await?; let storage = self.storage.call(storage::message::GetConfig).await?; + let users = self.users.call(users::message::GetConfig).await?; Ok(Config { bootloader, @@ -522,6 +550,7 @@ impl MessageHandler for Service { software: Some(software), storage, files: None, + users: Some(users), }) } } @@ -568,6 +597,7 @@ impl MessageHandler for Service { let software = self.software.call(software::message::GetProposal).await?; let storage = self.storage.call(storage::message::GetProposal).await?; let network = self.network.get_proposal().await?; + let users = self.users.call(users::message::GetProposal).await?; Ok(Some(Proposal { hostname, @@ -575,6 +605,7 @@ impl MessageHandler for Service { network, software, storage, + users, })) } } diff --git a/rust/agama-users/Cargo.toml b/rust/agama-users/Cargo.toml new file mode 100644 index 0000000000..46bba682f5 --- /dev/null +++ b/rust/agama-users/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "agama-users" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +anyhow = "1.0.99" +thiserror = "2.0.16" +agama-locale-data = { path = "../agama-locale-data" } +agama-utils = { path = "../agama-utils" } +regex = "1.11.2" +tracing = "0.1.41" +gettext-rs = { version = "0.7.2", features = ["gettext-system"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.17" +zbus = "5.11.0" +async-trait = "0.1.89" + +[dev-dependencies] +test-context = "0.4.1" +tokio-test = "0.4.4" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(ci)'] } diff --git a/rust/agama-users/src/lib.rs b/rust/agama-users/src/lib.rs new file mode 100644 index 0000000000..a91e231186 --- /dev/null +++ b/rust/agama-users/src/lib.rs @@ -0,0 +1,32 @@ +// Copyright (c) [2025] 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 service; +pub use service::{Service, Starter}; + +pub mod message; + +mod model; +pub use model::{Model, ModelAdapter}; + +#[cfg(test)] +mod tests { + use super::*; +} diff --git a/rust/agama-users/src/message.rs b/rust/agama-users/src/message.rs new file mode 100644 index 0000000000..c192fedf2e --- /dev/null +++ b/rust/agama-users/src/message.rs @@ -0,0 +1,59 @@ +// Copyright (c) [2025] 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 agama_utils::{ + actor::Message, + api::{self}, +}; + +#[derive(Clone)] +pub struct SetSystem { + pub system: Option, +} + +impl Message for SetSystem { + type Reply = (); +} + +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = api::users::Config; +} + +pub struct SetConfig { + pub config: Option, +} + +impl SetConfig { + pub fn new(config: Option) -> Self { + Self { config } + } +} + +impl Message for SetConfig { + type Reply = (); +} + +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} diff --git a/rust/agama-users/src/model.rs b/rust/agama-users/src/model.rs new file mode 100644 index 0000000000..6e86f9f578 --- /dev/null +++ b/rust/agama-users/src/model.rs @@ -0,0 +1,39 @@ +// 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 crate::service; + +/// Abstract the users-related configuration from the underlying system. +pub trait ModelAdapter: Send + 'static { + /// Apply the changes to target system. It is expected to be called almost + /// at the end of the installation. + fn install(&self) -> Result<(), service::Error> { + Ok(()) + } +} + +/// [ModelAdapter] implementation for systemd-based systems. +pub struct Model {} + +impl ModelAdapter for Model { + fn install(&self) -> Result<(), service::Error> { + Ok(()) + } +} diff --git a/rust/agama-users/src/service.rs b/rust/agama-users/src/service.rs new file mode 100644 index 0000000000..75bb92127b --- /dev/null +++ b/rust/agama-users/src/service.rs @@ -0,0 +1,191 @@ +// Copyright (c) [2025] 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 crate::message; +use crate::model::ModelAdapter; +use crate::Model; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + self, + event::{self, Event}, + users::Config, + Issue, Scope, + }, + issue, +}; +use async_trait::async_trait; +use tokio::sync::broadcast; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + IssueService(#[from] issue::service::Error), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error(transparent)] + Actor(#[from] actor::Error), +} + +/// Builds and spawns the users service. +pub struct Starter { + model: Option>, + issues: Handler, + events: event::Sender, +} + +impl Starter { + pub fn new(events: event::Sender, issues: Handler) -> Self { + Self { + model: None, + events, + issues, + } + } + + /// Uses the given model. + /// + /// * `model`: model to use. It must implement the [ModelAdapter] trait. + pub fn with_model(mut self, model: T) -> Self { + self.model = Some(Box::new(model)); + self + } + + /// Starts the service and returns a handler to communicate with it. + pub async fn start(self) -> Result, Error> { + let model = match self.model { + Some(model) => model, + None => Box::new(Model {}), + }; + let service = Service { + full_config: Config::new(), + model: model, + issues: self.issues, + events: self.events, + }; + let handler = actor::spawn(service); + + Ok(handler) + } +} + +/// Users service. +pub struct Service { + // complete users config + full_config: Config, + // service's backend which gets data from real world + model: Box, + // infrastructure stuff + issues: Handler, + events: event::Sender, +} + +impl Service { + pub fn starter(events: event::Sender, issues: Handler) -> Starter { + Starter::new(events, issues) + } + + fn get_proposal(&self) -> Option { + if self.find_issues().is_empty() { + return self.full_config.to_api(); + } + + None + } + + fn find_issues(&self) -> Vec { + let mut issues = vec![]; + + // At least one user is mandatory + // - typicaly root or + // - first user which will operate throught sudo + if self.full_config.root.is_none() && self.full_config.first_user.is_none() { + issues.push(Issue::new( + "No user defined", + "At least one user has to be defined", + )); + } + + issues + } +} + +impl Actor for Service { + type Error = Error; +} + +// Small inconsistency here: +// - in top level manager service is Config used just for what was +// entered by user, manager service is responsible for caching those +// - in all "sub" services like l10n, or this users, Config is full +// service configuration. So GetConfig in those sub services does +// what GetExtendedConfig in manager +// - GetExtendedConfig doesn't make sense for sub services thought +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetConfig) -> Result { + Ok(self.full_config.clone()) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle( + &mut self, + message: message::SetConfig, + ) -> Result<(), Error> { + let mut base_config = Config::new(); + + let config = if let Some(config) = &message.config { + base_config = config.clone(); + base_config + } else { + base_config + }; + + if config == self.full_config { + return Ok(()); + } + + self.full_config = config; + + self.issues + .cast(issue::message::Set::new(Scope::Users, self.find_issues()))?; + self.events.send(Event::ProposalChanged { + scope: Scope::Users, + })?; + + Ok(()) + } +} + +// Basically same thing as GetConfig (in case of this service). +// Only difference is that GetProposal checks for an issues. +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::GetProposal, + ) -> Result, Error> { + Ok(self.get_proposal()) + } +} diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 7224392e2a..f0c7696508 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -61,3 +61,4 @@ pub mod query; pub mod question; pub mod software; pub mod storage; +pub mod users; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index 7c71de6181..2b0ed46a5f 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -21,7 +21,7 @@ use crate::api::{ bootloader, files, hostname, l10n, network, question, software::{self, ProductConfig}, - storage, + storage, users, }; use merge::Merge; use serde::{Deserialize, Serialize}; @@ -48,6 +48,8 @@ pub struct Config { pub storage: Option, #[serde(flatten, skip_serializing_if = "Option::is_none")] pub files: Option, + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub users: Option, } impl Config { diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index 2a6d53d9ff..8ef7aecb12 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{hostname, l10n, network, software}; +use crate::api::{hostname, l10n, network, software, users}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -34,4 +34,6 @@ pub struct Proposal { pub software: Option, #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub users: Option, } diff --git a/rust/agama-utils/src/api/users.rs b/rust/agama-utils/src/api/users.rs new file mode 100644 index 0000000000..a77e14a141 --- /dev/null +++ b/rust/agama-utils/src/api/users.rs @@ -0,0 +1,25 @@ +// Copyright (c) [2025] 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 contains all Agama public types that might be available over +//! the HTTP and WebSocket API. + +mod config; +pub use config::Config; diff --git a/rust/agama-utils/src/api/users/config.rs b/rust/agama-utils/src/api/users/config.rs new file mode 100644 index 0000000000..5d1d5e662b --- /dev/null +++ b/rust/agama-utils/src/api/users/config.rs @@ -0,0 +1,142 @@ +// 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 merge::Merge; +use serde::{Deserialize, Serialize}; + +/// User settings +/// +/// Holds the user settings for the installation. +#[derive(Clone, Debug, Default, Merge, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + #[merge(strategy = merge::option::overwrite_none)] + #[serde(rename = "user")] + #[serde(skip_serializing_if = "Option::is_none")] + pub first_user: Option, + #[merge(strategy = merge::option::overwrite_none)] + #[serde(skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +impl Config { + pub fn new() -> Self { + Self { + first_user: None, + root: None, + } + } + + pub fn to_api(&self) -> Option { + if self.root.is_none() && self.first_user.is_none() { + return None; + } + + Some(self.clone()) + } +} + +/// First user settings +/// +/// Holds the settings for the first user. +#[derive(Clone, Debug, Default, Merge, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct FirstUserConfig { + /// First user's full name + #[merge(strategy = merge::option::overwrite_none)] + pub full_name: Option, + /// First user password + #[serde(flatten)] + #[serde(skip_serializing_if = "Option::is_none")] + #[merge(strategy = merge::option::overwrite_none)] + pub password: Option, + /// First user's username + #[merge(strategy = merge::option::overwrite_none)] + pub user_name: Option, +} + +impl FirstUserConfig { + /// Whether it is a valid user. + pub fn is_valid(&self) -> bool { + self.user_name.is_some() + } +} + +/// Represents a user password. +/// +/// It holds the password and whether it is a hashed or a plain text password. +#[derive(Clone, Debug, Merge, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserPassword { + /// User password + #[merge(strategy = overwrite_if_not_empty)] + pub password: String, + /// Whether the password is hashed or is plain text + #[merge(strategy = merge::bool::overwrite_false)] + #[serde(default)] + pub hashed_password: bool, +} + +fn overwrite_if_not_empty(old: &mut String, new: String) { + if !new.is_empty() { + *old = new; + } +} + +/// Root user settings +/// +/// Holds the settings for the root user. +#[derive(Clone, Debug, Default, Merge, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RootUserConfig { + /// Root user password + #[merge(strategy = merge::option::overwrite_none)] + #[serde(flatten)] + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + /// Root SSH public key + #[merge(strategy = merge::option::overwrite_none)] + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_public_key: Option, +} + +impl RootUserConfig { + pub fn is_empty(&self) -> bool { + self.password.is_none() && self.ssh_public_key.is_none() + } +} + +#[cfg(test)] +mod test { + use super::{FirstUserConfig, RootUserConfig, UserPassword}; + + #[test] + fn test_parse_user_password() { + let password_str = r#"{ "password": "$a$b123", "hashedPassword": true }"#; + let password: UserPassword = serde_json::from_str(&password_str).unwrap(); + assert_eq!(&password.password, "$a$b123"); + assert_eq!(password.hashed_password, true); + + let password_str = r#"{ "password": "$a$b123" }"#; + let password: UserPassword = serde_json::from_str(&password_str).unwrap(); + assert_eq!(&password.password, "$a$b123"); + assert_eq!(password.hashed_password, false); + } +}