From f2e2ee22ce20045a078f434ed17425b4236625de Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 16 Dec 2025 11:01:18 +0000 Subject: [PATCH 01/15] Added the new API v2 hostname service --- rust/Cargo.lock | 27 +- rust/Cargo.toml | 2 +- rust/agama-hostname/Cargo.toml | 15 + rust/agama-hostname/src/dbus.rs | 155 ++++++++++ rust/agama-hostname/src/lib.rs | 45 +++ rust/agama-hostname/src/message.rs | 99 +++++++ rust/agama-hostname/src/model.rs | 104 +++++++ rust/agama-hostname/src/monitor.rs | 86 ++++++ rust/agama-hostname/src/service.rs | 265 ++++++++++++++++++ rust/agama-manager/Cargo.toml | 1 + rust/agama-manager/src/lib.rs | 1 + rust/agama-manager/src/service.rs | 32 ++- rust/agama-server/src/hostname/web.rs | 93 ------ rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/web.rs | 1 - rust/agama-server/src/web/docs.rs | 2 - rust/agama-utils/src/api.rs | 1 + rust/agama-utils/src/api/config.rs | 4 +- .../docs => agama-utils/src/api}/hostname.rs | 28 +- rust/agama-utils/src/api/hostname/config.rs | 32 +++ rust/agama-utils/src/api/hostname/proposal.rs | 28 ++ .../src/api/hostname/system_info.rs | 28 ++ rust/agama-utils/src/api/proposal.rs | 4 +- rust/agama-utils/src/api/scope.rs | 1 + rust/agama-utils/src/api/system_info.rs | 3 +- rust/xtask/src/main.rs | 5 +- 26 files changed, 931 insertions(+), 132 deletions(-) create mode 100644 rust/agama-hostname/Cargo.toml create mode 100644 rust/agama-hostname/src/dbus.rs create mode 100644 rust/agama-hostname/src/lib.rs create mode 100644 rust/agama-hostname/src/message.rs create mode 100644 rust/agama-hostname/src/model.rs create mode 100644 rust/agama-hostname/src/monitor.rs create mode 100644 rust/agama-hostname/src/service.rs delete mode 100644 rust/agama-server/src/hostname/web.rs rename rust/{agama-server/src/web/docs => agama-utils/src/api}/hostname.rs (55%) create mode 100644 rust/agama-utils/src/api/hostname/config.rs create mode 100644 rust/agama-utils/src/api/hostname/proposal.rs create mode 100644 rust/agama-utils/src/api/hostname/system_info.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 17db8941e2..aeb4a752a5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -63,6 +63,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "agama-hostname" +version = "0.1.0" +dependencies = [ + "agama-utils", + "anyhow", + "async-trait", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "zbus", +] + [[package]] name = "agama-l10n" version = "0.1.0" @@ -144,6 +158,7 @@ name = "agama-manager" version = "0.1.0" dependencies = [ "agama-files", + "agama-hostname", "agama-l10n", "agama-network", "agama-software", @@ -4769,9 +4784,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -4781,9 +4796,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -4792,9 +4807,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 008e765de0..7fccc9c5b1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,7 +2,7 @@ members = [ "agama-autoinstall", "agama-cli", - "agama-files", + "agama-files", "agama-hostname", "agama-l10n", "agama-lib", "agama-locale-data", diff --git a/rust/agama-hostname/Cargo.toml b/rust/agama-hostname/Cargo.toml new file mode 100644 index 0000000000..cf28ada8a5 --- /dev/null +++ b/rust/agama-hostname/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agama-hostname" +version = "0.1.0" +rust-version.workspace = true +edition.workspace = true + +[dependencies] +agama-utils = { version = "0.1.0", path = "../agama-utils" } +anyhow = "1.0.100" +async-trait = "0.1.89" +thiserror = "2.0.17" +tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync"] } +tokio-stream = "0.1.17" +tracing = "0.1.43" +zbus = "5.12.0" diff --git a/rust/agama-hostname/src/dbus.rs b/rust/agama-hostname/src/dbus.rs new file mode 100644 index 0000000000..5c4630d61f --- /dev/null +++ b/rust/agama-hostname/src/dbus.rs @@ -0,0 +1,155 @@ +//! # D-Bus interface proxy for: `org.freedesktop.hostname1` +//! +//! This code was generated by `zbus-xmlgen` `4.1.0` from D-Bus introspection data. +//! Source: `Interface '/org/freedesktop/hostname1' from service 'org.freedesktop.hostname1' on system bus`. +//! +//! 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::PeerProxy`] +//! * [`zbus::fdo::IntrospectableProxy`] +//! * [`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( + interface = "org.freedesktop.hostname1", + default_service = "org.freedesktop.hostname1", + default_path = "/org/freedesktop/hostname1" +)] +pub trait Hostname1 { + /// Describe method + fn describe(&self) -> zbus::Result; + + /// GetHardwareSerial method + fn get_hardware_serial(&self) -> zbus::Result; + + /// GetProductUUID method + #[zbus(name = "GetProductUUID")] + fn get_product_uuid(&self, interactive: bool) -> zbus::Result>; + + /// SetChassis method + fn set_chassis(&self, chassis: &str, interactive: bool) -> zbus::Result<()>; + + /// SetDeployment method + fn set_deployment(&self, deployment: &str, interactive: bool) -> zbus::Result<()>; + + /// SetHostname method + fn set_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// SetIconName method + fn set_icon_name(&self, icon: &str, interactive: bool) -> zbus::Result<()>; + + /// SetLocation method + fn set_location(&self, location: &str, interactive: bool) -> zbus::Result<()>; + + /// SetPrettyHostname method + fn set_pretty_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// SetStaticHostname method + fn set_static_hostname(&self, hostname: &str, interactive: bool) -> zbus::Result<()>; + + /// BootID property + #[zbus(property, name = "BootID")] + fn boot_id(&self) -> zbus::Result>; + + /// Chassis property + #[zbus(property)] + fn chassis(&self) -> zbus::Result; + + /// DefaultHostname property + #[zbus(property)] + fn default_hostname(&self) -> zbus::Result; + + /// Deployment property + #[zbus(property)] + fn deployment(&self) -> zbus::Result; + + /// FirmwareDate property + #[zbus(property)] + fn firmware_date(&self) -> zbus::Result; + + /// FirmwareVendor property + #[zbus(property)] + fn firmware_vendor(&self) -> zbus::Result; + + /// FirmwareVersion property + #[zbus(property)] + fn firmware_version(&self) -> zbus::Result; + + /// HardwareModel property + #[zbus(property)] + fn hardware_model(&self) -> zbus::Result; + + /// HardwareVendor property + #[zbus(property)] + fn hardware_vendor(&self) -> zbus::Result; + + /// HomeURL property + #[zbus(property, name = "HomeURL")] + fn home_url(&self) -> zbus::Result; + + /// Hostname property + #[zbus(property)] + fn hostname(&self) -> zbus::Result; + + /// HostnameSource property + #[zbus(property)] + fn hostname_source(&self) -> zbus::Result; + + /// IconName property + #[zbus(property)] + fn icon_name(&self) -> zbus::Result; + + /// KernelName property + #[zbus(property)] + fn kernel_name(&self) -> zbus::Result; + + /// KernelRelease property + #[zbus(property)] + fn kernel_release(&self) -> zbus::Result; + + /// KernelVersion property + #[zbus(property)] + fn kernel_version(&self) -> zbus::Result; + + /// Location property + #[zbus(property)] + fn location(&self) -> zbus::Result; + + /// MachineID property + #[zbus(property, name = "MachineID")] + fn machine_id(&self) -> zbus::Result>; + + /// OperatingSystemCPEName property + #[zbus(property, name = "OperatingSystemCPEName")] + fn operating_system_cpename(&self) -> zbus::Result; + + /// OperatingSystemPrettyName property + #[zbus(property)] + fn operating_system_pretty_name(&self) -> zbus::Result; + + /// OperatingSystemSupportEnd property + #[zbus(property)] + fn operating_system_support_end(&self) -> zbus::Result; + + /// PrettyHostname property + #[zbus(property)] + fn pretty_hostname(&self) -> zbus::Result; + + /// StaticHostname property + #[zbus(property)] + fn static_hostname(&self) -> zbus::Result; + + /// VSockCID property + #[zbus(property, name = "VSockCID")] + fn vsock_cid(&self) -> zbus::Result; +} diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs new file mode 100644 index 0000000000..09679f4627 --- /dev/null +++ b/rust/agama-hostname/src/lib.rs @@ -0,0 +1,45 @@ +// 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 crate implements the support for localization handling in Agama. +//! It takes care of setting the locale, keymap and timezone for Agama itself +//! and the target system. +//! +//! From a technical point of view, it includes: +//! +//! * The [UserConfig] struct that defines the settings the user can +//! alter for the target system. +//! * The [Proposal] struct that describes how the system will look like after +//! the installation. +//! * The [SystemInfo] which includes information about the system +//! where Agama is running. +//! * An [specific event type](Event) for localization-related events. +//! +//! The service can be started by calling the [start_service] function, which +//! returns a [agama_utils::actors::ActorHandler] to interact with the system. + +pub mod service; +pub use service::{Service, Starter}; + +mod dbus; +pub mod message; +mod model; +pub use model::{Model, ModelAdapter}; +mod monitor; diff --git a/rust/agama-hostname/src/message.rs b/rust/agama-hostname/src/message.rs new file mode 100644 index 0000000000..94caa29bd0 --- /dev/null +++ b/rust/agama-hostname/src/message.rs @@ -0,0 +1,99 @@ +// 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::hostname::{Config, Proposal, SystemInfo}, +}; + +#[derive(Clone)] +pub struct GetSystem; + +impl Message for GetSystem { + type Reply = SystemInfo; +} + +pub struct SetSystem { + pub config: T, +} + +impl Message for SetSystem { + type Reply = (); +} + +impl SetSystem { + pub fn new(config: T) -> Self { + Self { config } + } +} + +pub struct GetConfig; + +impl Message for GetConfig { + type Reply = Config; +} + +pub struct SetConfig { + pub config: Option, +} + +impl Message for SetConfig { + type Reply = (); +} + +impl SetConfig { + pub fn new(config: Option) -> Self { + Self { config } + } + + pub fn with(config: T) -> Self { + Self { + config: Some(config), + } + } +} + +pub struct GetProposal; + +impl Message for GetProposal { + type Reply = Option; +} + +pub struct UpdateHostname { + pub name: String, +} + +impl Message for UpdateHostname { + type Reply = (); +} + +pub struct UpdateStaticHostname { + pub name: String, +} + +impl Message for UpdateStaticHostname { + type Reply = (); +} + +pub struct Install; + +impl Message for Install { + type Reply = (); +} diff --git a/rust/agama-hostname/src/model.rs b/rust/agama-hostname/src/model.rs new file mode 100644 index 0000000000..00e77ad960 --- /dev/null +++ b/rust/agama-hostname/src/model.rs @@ -0,0 +1,104 @@ +// 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; +use agama_utils::api::hostname::SystemInfo; +use std::{fs, path::PathBuf, process::Command}; + +/// Abstract the hostname-related configuration from the underlying system. +/// +/// It offers an API to query and set the transient or static hostname of a +/// system. This trait can be implemented to replace the real system during +/// tests. +pub trait ModelAdapter: Send + 'static { + /// Reads the system info. + fn read_system_info(&self) -> SystemInfo { + SystemInfo { + r#static: self.static_hostname().unwrap_or_default(), + hostname: self.hostname().unwrap_or_default(), + } + } + + /// Current system hostname. + fn hostname(&self) -> Result; + + /// Current system static hostname. + fn static_hostname(&self) -> Result; + + /// Change the system static hostname. + fn set_static(&mut self, name: String) -> Result<(), service::Error>; + + /// Change the system hostname + fn set_hostname(&mut self, name: String) -> Result<(), service::Error>; + + /// 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>; +} + +/// [ModelAdapter] implementation for systemd-based systems. +pub struct Model; + +impl ModelAdapter for Model { + fn static_hostname(&self) -> Result { + let output = Command::new("hostnamectl") + .args(["hostname", "--static"]) + .output()?; + let output = String::from_utf8_lossy(&output.stdout).trim().parse(); + + Ok(output.unwrap_or_default()) + } + + fn hostname(&self) -> Result { + let output = Command::new("hostnamectl") + .args(["hostname", "--transient"]) + .output()?; + let output = String::from_utf8_lossy(&output.stdout).trim().parse(); + + Ok(output.unwrap_or_default()) + } + + fn set_static(&mut self, name: String) -> Result<(), service::Error> { + Command::new("hostnamectl") + .args(["set-hostname", "--static", name.as_str()]) + .output()?; + + Ok(()) + } + + fn set_hostname(&mut self, name: String) -> Result<(), service::Error> { + Command::new("hostnamectl") + .args(["set-hostname", "--transient", name.as_str()]) + .output()?; + Ok(()) + } + + // Copy the static hostname to the target system + fn install(&self) -> Result<(), service::Error> { + const ROOT: &str = "/mnt"; + const HOSTNAME_PATH: &str = "/etc/hostname"; + let from = PathBuf::from(HOSTNAME_PATH); + if fs::exists(from.clone())? { + let to = PathBuf::from(ROOT).join(HOSTNAME_PATH); + fs::copy(from, to)?; + } + Ok(()) + } +} diff --git a/rust/agama-hostname/src/monitor.rs b/rust/agama-hostname/src/monitor.rs new file mode 100644 index 0000000000..b52f2d7543 --- /dev/null +++ b/rust/agama-hostname/src/monitor.rs @@ -0,0 +1,86 @@ +// 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, service::Service}; +use agama_utils::{ + actor::Handler, + dbus::{get_property, to_owned_hash}, +}; +use tokio_stream::StreamExt; +use zbus::fdo::{PropertiesChangedStream, PropertiesProxy}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + DBus(#[from] zbus::Error), +} + +pub struct Monitor { + handler: Handler, + stream: PropertiesChangedStream, +} + +impl Monitor { + pub async fn new(handler: Handler) -> Result { + let dbus = zbus::Connection::system().await?; + let proxy = PropertiesProxy::builder(&dbus) + .path("/org/freedesktop/hostname1")? + .destination("org.freedesktop.hostname1")? + .build() + .await?; + let stream = proxy + .receive_properties_changed() + .await + .map_err(Error::DBus)?; + Ok(Self { handler, stream }) + } + + pub async fn run(&mut self) { + while let Some(changes) = self.stream.next().await { + let Ok(args) = changes.args() else { + continue; + }; + + let changes = args.changed_properties(); + let Ok(changes) = to_owned_hash(changes) else { + continue; + }; + + if let Ok(name) = get_property::(&changes, "Hostname") { + let _ = self.handler.call(message::UpdateHostname { name }).await; + } + if let Ok(name) = get_property::(&changes, "StaticHostname") { + let _ = self + .handler + .call(message::UpdateStaticHostname { name }) + .await; + } + } + } +} + +/// Spawns a Tokio task for the monitor. +/// +/// * `monitor`: monitor to spawn. +pub fn spawn(mut monitor: Monitor) { + tokio::spawn(async move { + monitor.run().await; + }); +} diff --git a/rust/agama-hostname/src/service.rs b/rust/agama-hostname/src/service.rs new file mode 100644 index 0000000000..dfc0d9de58 --- /dev/null +++ b/rust/agama-hostname/src/service.rs @@ -0,0 +1,265 @@ +// 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::monitor::Monitor; +use crate::{message, monitor}; +use crate::{Model, ModelAdapter}; +use agama_utils::{ + actor::{self, Actor, Handler, MessageHandler}, + api::{ + self, + event::{self, Event}, + hostname::{Proposal, SystemInfo}, + Issue, Scope, + }, + issue, +}; +use async_trait::async_trait; +use tokio::sync::broadcast; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Invalid hostname")] + InvalidHostname, + #[error(transparent)] + Event(#[from] broadcast::error::SendError), + #[error(transparent)] + IssueService(#[from] issue::service::Error), + #[error(transparent)] + Actor(#[from] actor::Error), + #[error(transparent)] + IO(#[from] std::io::Error), + #[error(transparent)] + Generic(#[from] anyhow::Error), + #[error("There is no proposal for hostname")] + MissingProposal, +} + +/// Builds and spawns the hostname service. +/// +/// This struct allows to build a hostname service. It allows replacing +/// the "model" for a custom one. +/// +/// It spawns two Tokio tasks: +/// +/// - The main service, which is reponsible for holding and applying the configuration. +/// - A monitor which checks for changes in the underlying system (e.g., changing the hostname) +/// and signals the main service accordingly. +/// - It depends on the issues service to keep the installation issues. +pub struct Starter { + model: Option>, + issues: Handler, + events: event::Sender, +} + +impl Starter { + /// Creates a new starter. + /// + /// * `events`: channel to emit the [localization-specific events](crate::Event). + /// * `issues`: handler to the issues service. + pub fn new(events: event::Sender, issues: Handler) -> Self { + Self { + model: None, + events, + issues, + } + } + + /// Uses the given model. + /// + /// By default, the hostname service relies on systemd. However, it might be useful + /// to replace it in some scenarios (e.g., when testing). + /// + /// * `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. + /// + /// The service uses a separate monitor to listen to system configuration + /// changes. + pub async fn start(self) -> Result, Error> { + let model = match self.model { + Some(model) => model, + None => Box::new(Model), + }; + + let config = model.read_system_info(); + + let service = Service { + config, + model, + issues: self.issues, + events: self.events, + }; + let handler = actor::spawn(service); + Self::start_monitor(handler.clone()).await; + Ok(handler) + } + + pub async fn start_monitor(handler: Handler) { + match Monitor::new(handler.clone()).await { + Ok(monitor) => monitor::spawn(monitor), + Err(error) => { + tracing::error!( + "Could not launch the hostname monitor, therefore changes from systemd will be ignored. \ + The original error was {error}" + ); + } + } + } +} + +/// Hostname service. +/// +/// It is responsible for handling the hostname part of the installation: +/// +/// * Reads the static and transient hostname +/// * Keeps track of the hostname settings of the underlying system (the installer). +/// * Persist the static hostname at the end of the installation. +pub struct Service { + config: SystemInfo, + model: Box, + 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 None; + } + + Some(Proposal { + hostname: self.config.hostname.clone(), + r#static: self.config.r#static.clone(), + }) + } + + /// Returns configuration issues. + /// + /// It returns issues if the hostname are too long + fn find_issues(&self) -> Vec { + // TODO: add length checks + vec![] + } +} + +impl Actor for Service { + type Error = Error; +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetSystem) -> Result { + Ok(self.config.clone()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle( + &mut self, + _message: message::GetConfig, + ) -> Result { + Ok(api::hostname::Config { + r#static: Some(self.config.r#static.clone()), + hostname: Some(self.config.hostname.clone()), + }) + } +} + +#[async_trait] +impl MessageHandler> for Service { + async fn handle( + &mut self, + message: message::SetConfig, + ) -> Result<(), Error> { + let current = self.config.clone(); + + if let Some(config) = &message.config { + if let Some(name) = &config.r#static { + self.config.r#static = name.clone(); + self.config.hostname = name.clone(); + self.model.set_static(name.clone())? + } + + if let Some(name) = &config.hostname { + // If static hostname is set the transient is basically the same + if self.config.r#static.is_empty() { + self.config.hostname = name.clone(); + self.model.set_hostname(name.clone())? + } + } + } else { + return Ok(()); + } + + if current == self.config { + return Ok(()); + } + + let issues = self.find_issues(); + self.issues + .cast(issue::message::Set::new(Scope::Hostname, issues))?; + self.events.send(Event::ProposalChanged { + scope: Scope::Hostname, + })?; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + Ok(self.get_proposal()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateHostname) -> Result<(), Error> { + self.config.hostname = message.name; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, message: message::UpdateStaticHostname) -> Result<(), Error> { + // If static hostname is set the transient is basically the same + self.config.r#static = message.name.clone(); + self.config.hostname = message.name; + Ok(()) + } +} + +#[async_trait] +impl MessageHandler for Service { + async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { + Ok(()) + } +} diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index e21f93f3c4..074aef7b15 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] agama-files = { path = "../agama-files" } +agama-hostname = { path = "../agama-hostname" } agama-l10n = { path = "../agama-l10n" } agama-network = { path = "../agama-network" } agama-software = { path = "../agama-software" } diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index d18166f066..df30c29051 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -26,6 +26,7 @@ pub mod message; pub mod hardware; pub use agama_files as files; +pub use agama_hostname as hostname; pub use agama_l10n as l10n; pub use agama_network as network; pub use agama_software as software; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index cdab3fb159..046dd14634 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.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::{files, hardware, l10n, message, network, software, storage}; +use crate::{files, hardware, hostname, l10n, message, network, software, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ @@ -49,6 +49,8 @@ pub enum Error { #[error(transparent)] Actor(#[from] actor::Error), #[error(transparent)] + Hostname(#[from] hostname::service::Error), + #[error(transparent)] L10n(#[from] l10n::service::Error), #[error(transparent)] Software(#[from] software::service::Error), @@ -82,6 +84,7 @@ pub struct Starter { questions: Handler, events: event::Sender, dbus: zbus::Connection, + hostname: Option>, l10n: Option>, network: Option, software: Option>, @@ -102,6 +105,7 @@ impl Starter { events, dbus, questions, + hostname: None, l10n: None, network: None, software: None, @@ -113,6 +117,10 @@ impl Starter { } } + pub fn with_hostname(mut self, hostname: Handler) -> Self { + self.hostname = Some(hostname); + self + } pub fn with_network(mut self, network: NetworkSystemClient) -> Self { self.network = Some(network); self @@ -165,6 +173,14 @@ impl Starter { None => progress::Service::starter(self.events.clone()).start(), }; + let hostname = match self.hostname { + Some(hostname) => hostname, + None => { + hostname::Service::starter(self.events.clone(), issues.clone()) + .start() + .await? + } + }; let l10n = match self.l10n { Some(l10n) => l10n, None => { @@ -225,6 +241,7 @@ impl Starter { questions: self.questions, progress, issues, + hostname, l10n, network, software, @@ -244,6 +261,7 @@ impl Starter { } pub struct Service { + hostname: Handler, l10n: Handler, software: Handler, network: NetworkSystemClient, @@ -304,6 +322,10 @@ impl Service { return Err(Error::MissingProduct); }; + self.hostname + .call(hostname::message::SetConfig::new(config.hostname.clone())) + .await?; + self.files .call(files::message::SetConfig::new(config.files.clone())) .await?; @@ -428,12 +450,14 @@ impl MessageHandler for Service { impl MessageHandler for Service { /// It returns the information of the underlying system. async fn handle(&mut self, _message: message::GetSystem) -> Result { + let hostname = self.hostname.call(hostname::message::GetSystem).await?; let l10n = self.l10n.call(l10n::message::GetSystem).await?; let manager = self.system.clone(); 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, manager, network, @@ -449,6 +473,7 @@ impl MessageHandler for Service { /// /// It includes user and default values. async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { + let hostname = self.hostname.call(hostname::message::GetConfig).await?; let l10n = self.l10n.call(l10n::message::GetConfig).await?; let software = self.software.call(software::message::GetConfig).await?; let questions = self.questions.call(question::message::GetConfig).await?; @@ -456,8 +481,9 @@ impl MessageHandler for Service { let storage = self.storage.call(storage::message::GetConfig).await?; Ok(Config { + hostname: Some(hostname), l10n: Some(l10n), - questions: questions, + questions, network: Some(network), software: Some(software), storage, @@ -503,12 +529,14 @@ impl MessageHandler for Service { impl MessageHandler for Service { /// It returns the current proposal, if any. async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { + let hostname = self.hostname.call(hostname::message::GetProposal).await?; let l10n = self.l10n.call(l10n::message::GetProposal).await?; 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?; Ok(Some(Proposal { + hostname, l10n, network, software, diff --git a/rust/agama-server/src/hostname/web.rs b/rust/agama-server/src/hostname/web.rs deleted file mode 100644 index 94b7e0c526..0000000000 --- a/rust/agama-server/src/hostname/web.rs +++ /dev/null @@ -1,93 +0,0 @@ -// 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 implements the web API for the hostname service. -//! -//! The module offers one public function: -//! -//! * `hostname_service` which returns the Axum service. -//! -//! stream is not needed, as we do not need to emit signals (for NOW). - -use agama_lib::{ - error::ServiceError, - hostname::{client::HostnameClient, model::HostnameSettings}, -}; -use axum::{extract::State, routing::put, Json, Router}; - -use crate::error; - -#[derive(Clone)] -struct HostnameState<'a> { - client: HostnameClient<'a>, -} - -/// Sets up and returns the axum service for the hostname module. -pub async fn hostname_service() -> Result { - let client = HostnameClient::new().await?; - let state = HostnameState { client }; - let router = Router::new() - .route("/config", put(set_config).get(get_config)) - .with_state(state); - Ok(router) -} - -/// Returns the hostname configuration. -/// -/// * `state` : service state. -#[utoipa::path( - get, - path = "/config", - context_path = "/api/hostname", - operation_id = "get_hostname_config", - responses( - (status = 200, description = "hostname configuration", body = HostnameSettings), - (status = 400, description = "The D-Bus service could not perform the action") - ) -)] -async fn get_config( - State(state): State>, -) -> Result, error::Error> { - // HostnameSettings is just a wrapper over serde_json::value::RawValue - let settings = state.client.get_config().await?; - Ok(Json(settings)) -} - -/// Sets the hostname configuration. -/// -/// * `state`: service state. -/// * `config`: hostname configuration. -#[utoipa::path( - put, - path = "/config", - context_path = "/api/hostname", - operation_id = "set_hostname_config", - responses( - (status = 200, description = "Set the hostname 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 2b7fb5a8ac..c721bc6e1c 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -22,7 +22,6 @@ pub mod bootloader; pub mod cert; pub mod dbus; pub mod error; -pub mod hostname; pub mod logs; pub mod profile; pub mod security; diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index d3761b2fb5..b56235221b 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -67,7 +67,6 @@ where .add_service("/security", security_service(dbus.clone()).await?) .add_service("/bootloader", bootloader_service(dbus.clone()).await?) .add_service("/users", users_service(dbus.clone()).await?) - .add_service("/hostname", hostname_service().await?) .add_service("/profile", profile_service().await?) .with_config(config) .build(); diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 919b7dfc20..4c09af8593 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -22,8 +22,6 @@ use utoipa::openapi::{Components, Info, InfoBuilder, OpenApi, OpenApiBuilder, Pa mod config; pub use config::ConfigApiDocBuilder; -mod hostname; -pub use hostname::HostnameApiDocBuilder; mod bootloader; pub use bootloader::BootloaderApiDocBuilder; mod profile; diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 349b2b4566..747ffda9e5 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -52,6 +52,7 @@ mod action; pub use action::Action; pub mod files; +pub mod hostname; pub mod l10n; pub mod manager; pub mod network; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index e663a5f205..fcdeb4df92 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -19,7 +19,7 @@ // find current contact information at www.suse.com. use crate::api::{ - files, l10n, network, question, + files, hostname, l10n, network, question, software::{self, ProductConfig}, storage, }; @@ -30,6 +30,8 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] #[merge(strategy = merge::option::recurse)] pub struct Config { + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "localization")] pub l10n: Option, diff --git a/rust/agama-server/src/web/docs/hostname.rs b/rust/agama-utils/src/api/hostname.rs similarity index 55% rename from rust/agama-server/src/web/docs/hostname.rs rename to rust/agama-utils/src/api/hostname.rs index b37a3e5745..36dba10903 100644 --- a/rust/agama-server/src/web/docs/hostname.rs +++ b/rust/agama-utils/src/api/hostname.rs @@ -18,26 +18,14 @@ // 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}; +//! This module contains all Agama public types that might be available over +//! the HTTP and WebSocket API. -use super::ApiDocBuilder; -pub struct HostnameApiDocBuilder; +mod config; +pub use config::Config; -impl ApiDocBuilder for HostnameApiDocBuilder { - fn title(&self) -> String { - "Hostname HTTP API".to_string() - } +mod system_info; +pub use system_info::SystemInfo; - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .build() - } -} +mod proposal; +pub use proposal::Proposal; diff --git a/rust/agama-utils/src/api/hostname/config.rs b/rust/agama-utils/src/api/hostname/config.rs new file mode 100644 index 0000000000..d91cfb0fe2 --- /dev/null +++ b/rust/agama-utils/src/api/hostname/config.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. + +use serde::{Deserialize, Serialize}; + +/// Hostname config. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + #[serde(skip_serializing_if = "Option::is_none")] + pub r#static: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(alias = "transient")] + pub hostname: Option, +} diff --git a/rust/agama-utils/src/api/hostname/proposal.rs b/rust/agama-utils/src/api/hostname/proposal.rs new file mode 100644 index 0000000000..3de26856bb --- /dev/null +++ b/rust/agama-utils/src/api/hostname/proposal.rs @@ -0,0 +1,28 @@ +// 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 serde::{Deserialize, Serialize}; + +/// Describes what Agama proposes for the target system. +#[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Proposal { + pub r#static: String, + pub hostname: String, +} diff --git a/rust/agama-utils/src/api/hostname/system_info.rs b/rust/agama-utils/src/api/hostname/system_info.rs new file mode 100644 index 0000000000..ced11358fc --- /dev/null +++ b/rust/agama-utils/src/api/hostname/system_info.rs @@ -0,0 +1,28 @@ +// 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 serde::{Deserialize, Serialize}; + +/// Describes the current system hostname information +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +pub struct SystemInfo { + pub r#static: String, + pub hostname: String, +} diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index 348eb6f8a3..2a6d53d9ff 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -18,13 +18,15 @@ // 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::{l10n, network, software}; +use crate::api::{hostname, l10n, network, software}; use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Proposal { + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, #[serde(skip_serializing_if = "Option::is_none")] pub l10n: Option, pub network: network::Proposal, diff --git a/rust/agama-utils/src/api/scope.rs b/rust/agama-utils/src/api/scope.rs index 663dd8b6e0..6cb6036323 100644 --- a/rust/agama-utils/src/api/scope.rs +++ b/rust/agama-utils/src/api/scope.rs @@ -40,6 +40,7 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub enum Scope { Manager, + Hostname, L10n, Product, Software, diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index 40bc21ecf1..1a617e9639 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.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::{l10n, manager, network, software}; +use crate::api::{hostname, l10n, manager, network, software}; use serde::Serialize; use serde_json::Value; @@ -27,6 +27,7 @@ use serde_json::Value; pub struct SystemInfo { #[serde(flatten)] pub manager: manager::SystemInfo, + pub hostname: hostname::SystemInfo, pub l10n: l10n::SystemInfo, pub software: software::SystemInfo, #[serde(default)] diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 20600ea40f..0f7e5f784c 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -5,8 +5,8 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ - ApiDocBuilder, ConfigApiDocBuilder, HostnameApiDocBuilder, MiscApiDocBuilder, - ProfileApiDocBuilder, UsersApiDocBuilder, + ApiDocBuilder, ConfigApiDocBuilder, MiscApiDocBuilder, ProfileApiDocBuilder, + UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -64,7 +64,6 @@ mod tasks { let out_dir = create_output_dir("openapi")?; write_openapi(ConfigApiDocBuilder {}, out_dir.join("config.json"))?; - write_openapi(HostnameApiDocBuilder {}, out_dir.join("hostname.json"))?; write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; write_openapi(ProfileApiDocBuilder {}, out_dir.join("profile.json"))?; write_openapi(UsersApiDocBuilder {}, out_dir.join("users.json"))?; From 6dda2a7b4440385c2d267556c8c3ee00209d18fb Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Wed, 17 Dec 2025 09:48:13 +0000 Subject: [PATCH 02/15] Added merge to hostname --- rust/agama-utils/src/api/hostname/config.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rust/agama-utils/src/api/hostname/config.rs b/rust/agama-utils/src/api/hostname/config.rs index d91cfb0fe2..0e67c95f66 100644 --- a/rust/agama-utils/src/api/hostname/config.rs +++ b/rust/agama-utils/src/api/hostname/config.rs @@ -18,15 +18,18 @@ // 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}; /// Hostname config. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] +#[derive(Clone, Debug, Default, Merge, Serialize, Deserialize, PartialEq, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct Config { #[serde(skip_serializing_if = "Option::is_none")] + #[merge(strategy = merge::option::overwrite_none)] pub r#static: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(alias = "transient")] + #[merge(strategy = merge::option::overwrite_none)] pub hostname: Option, } From ae69aa733c0ebac479e36a7418746a750a26ef3b Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Wed, 17 Dec 2025 09:48:46 +0000 Subject: [PATCH 03/15] Adapted hostname web page --- web/src/components/system/HostnamePage.tsx | 18 +++++------ web/src/hooks/model/proposal/hostname.ts | 37 ++++++++++++++++++++++ web/src/model/config.ts | 4 ++- web/src/model/config/hostname.ts | 28 ++++++++++++++++ web/src/model/proposal.ts | 4 ++- web/src/model/proposal/hostname.ts | 28 ++++++++++++++++ web/src/model/system.ts | 4 ++- web/src/model/system/hostname.ts | 28 ++++++++++++++++ 8 files changed, 139 insertions(+), 12 deletions(-) create mode 100644 web/src/hooks/model/proposal/hostname.ts create mode 100644 web/src/model/config/hostname.ts create mode 100644 web/src/model/proposal/hostname.ts create mode 100644 web/src/model/system/hostname.ts diff --git a/web/src/components/system/HostnamePage.tsx b/web/src/components/system/HostnamePage.tsx index 40c078e51a..e91c881ba5 100644 --- a/web/src/components/system/HostnamePage.tsx +++ b/web/src/components/system/HostnamePage.tsx @@ -32,17 +32,19 @@ import { TextInput, } from "@patternfly/react-core"; import { NestedContent, Page } from "~/components/core"; -import { useProduct, useRegistration } from "~/queries/software"; -import { useHostname, useHostnameMutation } from "~/queries/hostname"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; +import { useProposal } from "~/hooks/model/proposal"; +import { useProduct } from "~/hooks/model/config"; +import { patchConfig } from "~/api"; export default function HostnamePage() { - const registration = useRegistration(); - const { selectedProduct: product } = useProduct(); - const { transient: transientHostname, static: staticHostname } = useHostname(); - const { mutateAsync: updateHostname } = useHostnameMutation(); + const product = useProduct(); + const { hostname: proposal } = useProposal(); + // FIXME: It should be fixed once the registration is adapted to API v2 + const registration = { registered: true }; + const { hostname: transientHostname, static: staticHostname } = proposal; const hasTransientHostname = isEmpty(staticHostname); const [success, setSuccess] = useState(null); const [error, setError] = useState(null); @@ -64,7 +66,7 @@ export default function HostnamePage() { return; } - updateHostname({ static: settingHostname ? hostname : "" }) + patchConfig({ hostname: { static: settingHostname ? hostname : "" } }) .then(() => setSuccess(_("Hostname successfully updated"))) .catch(() => setRequestError(_("Hostname could not be updated"))); }; @@ -88,7 +90,6 @@ export default function HostnamePage() { )} )} - {hasTransientHostname && ( {_( @@ -96,7 +97,6 @@ export default function HostnamePage() { )} )} -
{success && } {requestError && } diff --git a/web/src/hooks/model/proposal/hostname.ts b/web/src/hooks/model/proposal/hostname.ts new file mode 100644 index 0000000000..b2a595ed61 --- /dev/null +++ b/web/src/hooks/model/proposal/hostname.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +import { useSuspenseQuery } from "@tanstack/react-query"; +import { proposalQuery } from "~/hooks/model/proposal"; +import type { Proposal, Hostname } from "~/model/proposal"; + +const selectProposal = (data: Proposal | null): Hostname.Proposal | null => data?.hostname; + +function useProposal(): Hostname.Proposal | null { + const { data } = useSuspenseQuery({ + ...proposalQuery, + select: selectProposal, + }); + return data; +} + +export { useProposal }; diff --git a/web/src/model/config.ts b/web/src/model/config.ts index 31aba6d308..0fdfa77bab 100644 --- a/web/src/model/config.ts +++ b/web/src/model/config.ts @@ -20,12 +20,14 @@ * find current contact information at www.suse.com. */ +import type * as Hostname from "~/model/config/hostname"; import type * as L10n from "~/model/config/l10n"; import type * as Network from "~/model/config/network"; import type * as Software from "~/model/config/software"; import type * as Storage from "~/model/config/storage"; type Config = { + hostname?: Hostname.Config; l10n?: L10n.Config; network?: Network.Config; product?: Product; @@ -37,4 +39,4 @@ type Product = { id?: string; }; -export type { Config, Product, L10n, Network, Storage }; +export type { Config, Hostname, Product, L10n, Network, Storage }; diff --git a/web/src/model/config/hostname.ts b/web/src/model/config/hostname.ts new file mode 100644 index 0000000000..b34067d314 --- /dev/null +++ b/web/src/model/config/hostname.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +type Config = { + static?: String; + hostname?: String; +}; + +export type { Config }; diff --git a/web/src/model/proposal.ts b/web/src/model/proposal.ts index e07802d613..efd24bca05 100644 --- a/web/src/model/proposal.ts +++ b/web/src/model/proposal.ts @@ -20,16 +20,18 @@ * find current contact information at www.suse.com. */ +import type * as Hostname from "~/model/proposal/hostname"; import type * as L10n from "~/model/proposal/l10n"; import type * as Network from "~/model/proposal/network"; import type * as Software from "~/model/proposal/software"; import type * as Storage from "~/model/proposal/storage"; type Proposal = { + hostname?: Hostname.Proposal; l10n?: L10n.Proposal; network: Network.Proposal; software?: Software.Proposal; storage?: Storage.Proposal; }; -export type { Proposal, L10n, Network, Software, Storage }; +export type { Hostname, Proposal, L10n, Network, Software, Storage }; diff --git a/web/src/model/proposal/hostname.ts b/web/src/model/proposal/hostname.ts new file mode 100644 index 0000000000..06b305c545 --- /dev/null +++ b/web/src/model/proposal/hostname.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +type Proposal = { + static: String; + hostname: String; +}; + +export type { Proposal }; diff --git a/web/src/model/system.ts b/web/src/model/system.ts index c6dbef1339..4c43c185dd 100644 --- a/web/src/model/system.ts +++ b/web/src/model/system.ts @@ -20,12 +20,14 @@ * find current contact information at www.suse.com. */ +import type * as Hostname from "~/model/system/hostname"; import type * as L10n from "~/model/system/l10n"; import type * as Network from "~/model/system/network"; import type * as Software from "~/model/system/software"; import type * as Storage from "~/model/system/storage"; type System = { + hostname: Hostname.System; l10n?: L10n.System; network: Network.System; products?: Product[]; @@ -53,4 +55,4 @@ type Product = { }; }; -export type { System, Product, L10n, Network, Software, Storage }; +export type { System, Product, L10n, Hostname, Network, Software, Storage }; diff --git a/web/src/model/system/hostname.ts b/web/src/model/system/hostname.ts new file mode 100644 index 0000000000..80f7fa89d1 --- /dev/null +++ b/web/src/model/system/hostname.ts @@ -0,0 +1,28 @@ +/* + * 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. + */ + +type System = { + static: String; + hostname: String; +}; + +export type { System }; From a6efa63a7848b31564c30a44da2c2b448e1fdd5f Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Thu, 18 Dec 2025 07:28:48 +0000 Subject: [PATCH 04/15] Comment monitor --- rust/agama-hostname/src/monitor.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/agama-hostname/src/monitor.rs b/rust/agama-hostname/src/monitor.rs index b52f2d7543..eb58990a35 100644 --- a/rust/agama-hostname/src/monitor.rs +++ b/rust/agama-hostname/src/monitor.rs @@ -37,6 +37,10 @@ pub struct Monitor { stream: PropertiesChangedStream, } +// Monitors the DBUS hostname service notifying the static or transient system hostname change +// when them occurs +// +/// * `handler`: service handler to be monitorized. impl Monitor { pub async fn new(handler: Handler) -> Result { let dbus = zbus::Connection::system().await?; From 147ba10fb39bd1843a017c1c2e8cd5a9f54c605b Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Thu, 18 Dec 2025 09:42:06 +0000 Subject: [PATCH 05/15] Removed hostname queries and old api functions --- .../product/ProductRegistrationPage.tsx | 5 +- web/src/model/hostname.ts | 40 -------------- web/src/queries/hostname.ts | 54 ------------------- 3 files changed, 3 insertions(+), 96 deletions(-) delete mode 100644 web/src/model/hostname.ts delete mode 100644 web/src/queries/hostname.ts diff --git a/web/src/components/product/ProductRegistrationPage.tsx b/web/src/components/product/ProductRegistrationPage.tsx index cf04fcdbcc..ed2d9f7b0e 100644 --- a/web/src/components/product/ProductRegistrationPage.tsx +++ b/web/src/components/product/ProductRegistrationPage.tsx @@ -54,11 +54,11 @@ import RegistrationCodeInput from "./RegistrationCodeInput"; import { RegistrationParams } from "~/types/software"; import { HOSTNAME } from "~/routes/paths"; import { useProduct, useRegistration, useRegisterMutation, useAddons } from "~/queries/software"; -import { useHostname } from "~/queries/hostname"; import { isEmpty } from "radashi"; import { mask } from "~/utils"; import { sprintf } from "sprintf-js"; import { _, N_ } from "~/i18n"; +import { useProposal } from "~/hooks/model/proposal"; const FORM_ID = "productRegistration"; const SERVER_LABEL = N_("Registration server"); @@ -388,7 +388,8 @@ const RegistrationFormSection = () => { }; const HostnameAlert = () => { - const { transient: transientHostname, static: staticHostname } = useHostname(); + const { hostname: hostnameProposal } = useProposal(); + const { hostname: transientHostname, static: staticHostname } = hostnameProposal; const hostname = isEmpty(staticHostname) ? transientHostname : staticHostname; // TRANSLATORS: %s will be replaced with the hostname value diff --git a/web/src/model/hostname.ts b/web/src/model/hostname.ts deleted file mode 100644 index d119ebaf0b..0000000000 --- a/web/src/model/hostname.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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. - */ - -// @todo Move to the new API. - -import { get, put } from "~/http"; -import { Hostname } from "~/types/hostname"; - -/** - * Returns the hostname configuration - */ -const fetchHostname = (): Promise => get("/api/hostname/config"); - -/** - * Updates the hostname configuration - * - * @param hostname - Object containing hostname updates - */ -const updateHostname = (user: Partial) => put("/api/hostname/config", user); - -export { fetchHostname, updateHostname }; diff --git a/web/src/queries/hostname.ts b/web/src/queries/hostname.ts deleted file mode 100644 index e99413b7f5..0000000000 --- a/web/src/queries/hostname.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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. - */ - -import { useMutation, useQueryClient, useSuspenseQuery } from "@tanstack/react-query"; -import { fetchHostname, updateHostname } from "~/model/hostname"; - -/** - * Returns a query for retrieving the hostname configuration - */ -const hostnameQuery = () => ({ - queryKey: ["system", "hostname"], - queryFn: fetchHostname, -}); - -/** - * Hook that returns the hostname configuration - */ -const useHostname = () => { - const { data: hostname } = useSuspenseQuery(hostnameQuery()); - return hostname; -}; - -/* - * Hook that returns a mutation to change the hostname - */ -const useHostnameMutation = () => { - const queryClient = useQueryClient(); - const query = { - mutationFn: updateHostname, - onSuccess: () => queryClient.invalidateQueries({ queryKey: ["system", "hostname"] }), - }; - return useMutation(query); -}; - -export { useHostname, useHostnameMutation }; From de61771f256549459c4a63020653920a04420d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Alejandro=20Anderssen=20Gonz=C3=A1lez?= Date: Thu, 18 Dec 2025 09:48:03 +0000 Subject: [PATCH 06/15] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Imobach González Sosa --- rust/agama-hostname/src/model.rs | 20 +++++++++++++------- rust/agama-hostname/src/service.rs | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/rust/agama-hostname/src/model.rs b/rust/agama-hostname/src/model.rs index 00e77ad960..ef324b9c35 100644 --- a/rust/agama-hostname/src/model.rs +++ b/rust/agama-hostname/src/model.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -29,7 +29,7 @@ use std::{fs, path::PathBuf, process::Command}; /// tests. pub trait ModelAdapter: Send + 'static { /// Reads the system info. - fn read_system_info(&self) -> SystemInfo { + fn system_info(&self) -> SystemInfo { SystemInfo { r#static: self.static_hostname().unwrap_or_default(), hostname: self.hostname().unwrap_or_default(), @@ -43,7 +43,7 @@ pub trait ModelAdapter: Send + 'static { fn static_hostname(&self) -> Result; /// Change the system static hostname. - fn set_static(&mut self, name: String) -> Result<(), service::Error>; + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error>; /// Change the system hostname fn set_hostname(&mut self, name: String) -> Result<(), service::Error>; @@ -51,6 +51,9 @@ 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>; + + // Target directory to copy the static hostname at the end of the installation + fn static_target_dir(&self) -> &str; } /// [ModelAdapter] implementation for systemd-based systems. @@ -75,7 +78,7 @@ impl ModelAdapter for Model { Ok(output.unwrap_or_default()) } - fn set_static(&mut self, name: String) -> Result<(), service::Error> { + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { Command::new("hostnamectl") .args(["set-hostname", "--static", name.as_str()]) .output()?; @@ -90,13 +93,16 @@ impl ModelAdapter for Model { Ok(()) } - // Copy the static hostname to the target system + fn static_target_dir(&self) -> &str { + "/mnt" + } + + /// Copy the static hostname to the target system fn install(&self) -> Result<(), service::Error> { - const ROOT: &str = "/mnt"; const HOSTNAME_PATH: &str = "/etc/hostname"; let from = PathBuf::from(HOSTNAME_PATH); if fs::exists(from.clone())? { - let to = PathBuf::from(ROOT).join(HOSTNAME_PATH); + let to = PathBuf::from(self.static_target_dir()).join(HOSTNAME_PATH); fs::copy(from, to)?; } Ok(()) diff --git a/rust/agama-hostname/src/service.rs b/rust/agama-hostname/src/service.rs index dfc0d9de58..ef078c837a 100644 --- a/rust/agama-hostname/src/service.rs +++ b/rust/agama-hostname/src/service.rs @@ -103,7 +103,7 @@ impl Starter { None => Box::new(Model), }; - let config = model.read_system_info(); + let config = model.system_info(); let service = Service { config, @@ -204,7 +204,7 @@ impl MessageHandler> for Service { if let Some(name) = &config.r#static { self.config.r#static = name.clone(); self.config.hostname = name.clone(); - self.model.set_static(name.clone())? + self.model.set_static_hostname(name.clone())? } if let Some(name) = &config.hostname { From 72e9fc2c64386ada1529a0bfaf178a3212169811 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Thu, 18 Dec 2025 23:15:50 +0000 Subject: [PATCH 07/15] Fixing hostname and network tests --- web/src/App.test.tsx | 4 +- .../network/BindingSettingsForm.test.tsx | 12 +-- .../network/InstallationOnlySwitch.test.tsx | 4 +- .../components/network/NetworkPage.test.tsx | 7 +- .../NoPersistentConnectionsAlert.test.tsx | 4 +- .../network/WifiConnectionForm.test.tsx | 20 ++--- .../network/WifiNetworksList.test.tsx | 8 +- .../network/WiredConnectionDetails.test.tsx | 4 +- .../network/WiredConnectionPage.test.tsx | 2 +- .../network/WiredConnectionsList.test.tsx | 8 +- .../components/system/HostnamePage.test.tsx | 75 ++++++++++++------- web/src/components/system/HostnamePage.tsx | 2 +- web/src/model/config/hostname.ts | 4 +- web/src/model/proposal/hostname.ts | 4 +- web/src/model/system.ts | 4 +- web/src/model/system/hostname.ts | 4 +- 16 files changed, 92 insertions(+), 74 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e0e7095705..891cf63297 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -54,8 +54,8 @@ const mockProgresses: jest.Mock = jest.fn(); const mockState: jest.Mock = jest.fn(); const mockSelectedProduct: jest.Mock = jest.fn(); -jest.mock("~/hooks/api", () => ({ - ...jest.requireActual("~/hooks/api"), +jest.mock("~/hooks/model/system", () => ({ + ...jest.requireActual("~/hooks/model/system"), useSystem: (): ReturnType => ({ products: [tumbleweed, microos], network, diff --git a/web/src/components/network/BindingSettingsForm.test.tsx b/web/src/components/network/BindingSettingsForm.test.tsx index 001e920832..aeeee84f9e 100644 --- a/web/src/components/network/BindingSettingsForm.test.tsx +++ b/web/src/components/network/BindingSettingsForm.test.tsx @@ -59,18 +59,18 @@ jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -jest.mock("~/hooks/api/system/network", () => ({ - ...jest.requireActual("~/hooks/api/system/network"), +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), useDevices: () => [mockDevice], })); -jest.mock("~/hooks/api/proposal/network", () => ({ - ...jest.requireActual("~/hooks/api/proposal/network"), +jest.mock("~/hooks/model/proposal/network", () => ({ + ...jest.requireActual("~/hooks/model/proposal/network"), useConnection: () => mockConnection, })); -jest.mock("~/hooks/api/config/network", () => ({ - ...jest.requireActual("~/hooks/api/config/network"), +jest.mock("~/hooks/model/config/network", () => ({ + ...jest.requireActual("~/hooks/model/config/network"), useConnectionMutation: () => ({ mutateAsync: mockMutation }), })); diff --git a/web/src/components/network/InstallationOnlySwitch.test.tsx b/web/src/components/network/InstallationOnlySwitch.test.tsx index df31f48c88..65a507a3c0 100644 --- a/web/src/components/network/InstallationOnlySwitch.test.tsx +++ b/web/src/components/network/InstallationOnlySwitch.test.tsx @@ -40,8 +40,8 @@ const mockConnection = (options: Partial = {}) => ...options, }); -jest.mock("~/hooks/api/config/network", () => ({ - ...jest.requireActual("~/hooks/api/config/network"), +jest.mock("~/hooks/model/config/network", () => ({ + ...jest.requireActual("~/hooks/model/config/network"), useConnectionMutation: () => ({ mutateAsync: mockConnectionMutation, }), diff --git a/web/src/components/network/NetworkPage.test.tsx b/web/src/components/network/NetworkPage.test.tsx index ed40248c00..2ab93f9011 100644 --- a/web/src/components/network/NetworkPage.test.tsx +++ b/web/src/components/network/NetworkPage.test.tsx @@ -44,14 +44,13 @@ const mockSystem = { connections: [], state: { connectivity: true, - wiredEnabled: true, + copyNetwork: true, + networkingEnabled: true, wirelessEnabled: false, - persistNetwork: true, - copyEnabled: false, }, }; -jest.mock("~/hooks/api/system/network", () => ({ +jest.mock("~/hooks/model/system/network", () => ({ useNetworkChanges: jest.fn(), useSystem: () => mockSystem, })); diff --git a/web/src/components/network/NoPersistentConnectionsAlert.test.tsx b/web/src/components/network/NoPersistentConnectionsAlert.test.tsx index fe4d2efa32..e2ee654019 100644 --- a/web/src/components/network/NoPersistentConnectionsAlert.test.tsx +++ b/web/src/components/network/NoPersistentConnectionsAlert.test.tsx @@ -28,8 +28,8 @@ import NoPersistentConnectionsAlert from "./NoPersistentConnectionsAlert"; let mockConnections: Connection[]; -jest.mock("~/hooks/api/proposal/network", () => ({ - ...jest.requireActual("~/hooks/api/proposal/network"), +jest.mock("~/hooks/model/proposal/network", () => ({ + ...jest.requireActual("~/hooks/model/proposal/network"), useConnections: () => mockConnections, })); diff --git a/web/src/components/network/WifiConnectionForm.test.tsx b/web/src/components/network/WifiConnectionForm.test.tsx index ddb1702d4f..5442d7ea9f 100644 --- a/web/src/components/network/WifiConnectionForm.test.tsx +++ b/web/src/components/network/WifiConnectionForm.test.tsx @@ -28,8 +28,8 @@ import { Connection, SecurityProtocols, WifiNetworkStatus, Wireless } from "~/ty const mockUpdateConnection = jest.fn(); -jest.mock("~/hooks/api/config/network", () => ({ - ...jest.requireActual("~/hooks/api/config/network"), +jest.mock("~/hooks/model/config/network", () => ({ + ...jest.requireActual("~/hooks/model/config/network"), useConnectionMutation: () => ({ mutateAsync: mockUpdateConnection, }), @@ -40,11 +40,17 @@ jest.mock("~/api", () => ({ configureL10nAction: () => jest.fn(), })); -jest.mock("~/hooks/api/system", () => ({ - ...jest.requireActual("~/hooks/api/system"), +jest.mock("~/hooks/model/system", () => ({ + ...jest.requireActual("~/hooks/model/system"), useSystem: () => jest.fn(), })); +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), + useSystem: () => mockSystem, + useConnections: () => mockSystem.connections, +})); + const mockConnection = new Connection("Visible Network", { wireless: new Wireless({ ssid: "Visible Network" }), }); @@ -60,12 +66,6 @@ const mockSystem = { }, }; -jest.mock("~/hooks/api/system/network", () => ({ - ...jest.requireActual("~/hooks/api/system/network"), - useSystem: () => mockSystem, - useConnections: () => mockSystem.connections, -})); - const networkMock = { ssid: "Visible Network", hidden: false, diff --git a/web/src/components/network/WifiNetworksList.test.tsx b/web/src/components/network/WifiNetworksList.test.tsx index 58e5d8aaa2..e362e8b694 100644 --- a/web/src/components/network/WifiNetworksList.test.tsx +++ b/web/src/components/network/WifiNetworksList.test.tsx @@ -52,13 +52,13 @@ const wlan0: Device = { let mockWifiNetworks: WifiNetwork[]; let mockWifiConnections: Connection[]; -jest.mock("~/hooks/api/proposal/network", () => ({ - ...jest.requireActual("~/hooks/api/proposal/network"), +jest.mock("~/hooks/model/proposal/network", () => ({ + ...jest.requireActual("~/hooks/model/proposal/network"), useConnections: () => mockWifiConnections, })); -jest.mock("~/hooks/api/system/network", () => ({ - ...jest.requireActual("~/hooks/api/system/network"), +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), useNetworkChanges: jest.fn(), useWifiNetworks: () => mockWifiNetworks, useConnections: () => mockWifiConnections, diff --git a/web/src/components/network/WiredConnectionDetails.test.tsx b/web/src/components/network/WiredConnectionDetails.test.tsx index f52e0bdc3a..be64eab6ba 100644 --- a/web/src/components/network/WiredConnectionDetails.test.tsx +++ b/web/src/components/network/WiredConnectionDetails.test.tsx @@ -78,8 +78,8 @@ const mockConnection: Connection = new Connection("Network #1", { let mockNetworkDevices = [mockDevice]; const networkDevices = () => mockNetworkDevices; -jest.mock("~/hooks/api/system/network", () => ({ - ...jest.requireActual("~/hooks/api/system/network"), +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), useDevices: () => networkDevices(), })); diff --git a/web/src/components/network/WiredConnectionPage.test.tsx b/web/src/components/network/WiredConnectionPage.test.tsx index 9240576959..63c6e4a834 100644 --- a/web/src/components/network/WiredConnectionPage.test.tsx +++ b/web/src/components/network/WiredConnectionPage.test.tsx @@ -43,7 +43,7 @@ jest.mock("~/components/network/NoPersistentConnectionsAlert", () => () => (
NoPersistentConnectionsAlert Mock
)); -jest.mock("~/hooks/api/proposal/network", () => ({ +jest.mock("~/hooks/model/proposal/network", () => ({ useNetworkChanges: jest.fn(), useConnections: () => [mockConnection], })); diff --git a/web/src/components/network/WiredConnectionsList.test.tsx b/web/src/components/network/WiredConnectionsList.test.tsx index 93761d88e8..0fa8fa1944 100644 --- a/web/src/components/network/WiredConnectionsList.test.tsx +++ b/web/src/components/network/WiredConnectionsList.test.tsx @@ -50,13 +50,13 @@ const mockDevice: Device = { let mockConnections: Connection[]; -jest.mock("~/hooks/api/proposal/network", () => ({ - ...jest.requireActual("~/hooks/api/proposal/network"), +jest.mock("~/hooks/model/proposal/network", () => ({ + ...jest.requireActual("~/hooks/model/proposal/network"), useConnections: () => mockConnections, })); -jest.mock("~/hooks/api/system/network", () => ({ - ...jest.requireActual("~/hooks/api/system/network"), +jest.mock("~/hooks/model/system/network", () => ({ + ...jest.requireActual("~/hooks/model/system/network"), useDevices: () => [mockDevice], })); diff --git a/web/src/components/system/HostnamePage.test.tsx b/web/src/components/system/HostnamePage.test.tsx index 1debb771b1..61fb561096 100644 --- a/web/src/components/system/HostnamePage.test.tsx +++ b/web/src/components/system/HostnamePage.test.tsx @@ -23,9 +23,13 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { useProduct, useRegistration } from "~/queries/software"; import { Product, RegistrationInfo } from "~/types/software"; import HostnamePage from "./HostnamePage"; +import { patchConfig } from "~/api"; + +let mockStaticHostname: string; +let registrationInfoMock: RegistrationInfo; +let mockPatchConfig = jest.fn(); const tw: Product = { id: "Tumbleweed", @@ -39,31 +43,42 @@ const sle: Product = { registration: true, }; -let selectedProduct: Product; -let registrationInfoMock: RegistrationInfo; -let mockStaticHostname: string; - -const mockHostnameMutation = jest.fn().mockResolvedValue(true); +let selectedProduct = tw; jest.mock("~/components/product/ProductRegistrationAlert", () => () => (
ProductRegistrationAlert Mock
)); -jest.mock("~/queries/software", () => ({ - ...jest.requireActual("~/queries/software"), - useRegistration: (): ReturnType => registrationInfoMock, - useProduct: (): ReturnType => { - return { - products: [tw, sle], - selectedProduct, - }; - }, +jest.mock("~/api", () => ({ + ...jest.requireActual("~/api"), + patchConfig: (config) => mockPatchConfig(config), })); -jest.mock("~/queries/hostname", () => ({ - ...jest.requireActual("~/queries/hostname"), - useHostname: () => ({ transient: "agama-node", static: mockStaticHostname }), - useHostnameMutation: () => ({ mutateAsync: mockHostnameMutation }), +jest.mock("~/hooks/model/config", () => ({ + ...jest.requireActual("~/hooks/model/config"), + useProduct: () => selectedProduct, + useConfig: () => ({ + product: selectedProduct.id, + }), +})); + +jest.mock("~/hooks/model/proposal", () => ({ + ...jest.requireActual("~/hooks/model/proposal"), + useProposal: () => ({ + network: { + connections: [], + state: { + connectivity: true, + copyNetwork: true, + networkingEnabled: false, + wirelessEnabled: false, + }, + }, + hostname: { + hostname: "agama-node", + static: mockStaticHostname, + }, + }), })); describe("HostnamePage", () => { @@ -74,6 +89,7 @@ describe("HostnamePage", () => { describe("when static hostname is set", () => { beforeEach(() => { mockStaticHostname = "agama-server"; + mockPatchConfig.mockResolvedValue(true); }); it("does not render a custom alert with current value and mode", () => { @@ -84,6 +100,7 @@ describe("HostnamePage", () => { it("allows unsetting the static hostname", async () => { const { user } = installerRender(); + const setHostnameCheckbox = screen.getByRole("checkbox", { name: "Use static hostname" }); const hostnameInput = screen.getByRole("textbox", { name: "Static hostname" }); const acceptButton = screen.getByRole("button", { name: "Accept" }); @@ -96,8 +113,8 @@ describe("HostnamePage", () => { await user.click(acceptButton); - expect(mockHostnameMutation).toHaveBeenCalledWith({ - static: "", + expect(mockPatchConfig).toHaveBeenCalledWith({ + hostname: { static: "" }, }); screen.getByText("Success alert:"); screen.getByText("Hostname successfully updated"); @@ -107,6 +124,7 @@ describe("HostnamePage", () => { describe("when static hostname is not set", () => { beforeEach(() => { mockStaticHostname = ""; + mockPatchConfig.mockResolvedValue(true); }); it("renders a custom alert with current value and mode", () => { @@ -131,8 +149,8 @@ describe("HostnamePage", () => { await user.type(hostnameInput, "testing-server"); await user.click(acceptButton); - expect(mockHostnameMutation).toHaveBeenCalledWith({ - static: "testing-server", + expect(mockPatchConfig).toHaveBeenCalledWith({ + hostname: { static: "testing-server" }, }); screen.getByText("Success alert:"); screen.getByText("Hostname successfully updated"); @@ -146,20 +164,21 @@ describe("HostnamePage", () => { await user.click(setHostnameCheckbox); await user.click(acceptButton); - expect(mockHostnameMutation).not.toHaveBeenCalled(); + expect(mockPatchConfig).not.toHaveBeenCalled(); screen.getByText("Warning alert:"); screen.getByText("Enter a hostname."); }); it("renders an error if the update request fails", async () => { - mockHostnameMutation.mockRejectedValue("Not valid"); + mockPatchConfig.mockRejectedValue("Fail"); + const { user } = installerRender(); const acceptButton = screen.getByRole("button", { name: "Accept" }); await user.click(acceptButton); - expect(mockHostnameMutation).toHaveBeenCalledWith({ - static: "", + expect(mockPatchConfig).toHaveBeenCalledWith({ + hostname: { static: "" }, }); screen.getByText("Warning alert:"); @@ -181,7 +200,7 @@ describe("HostnamePage", () => { registrationInfoMock = { registered: false, key: "", email: "", url: "" }; }); - it("does not render an alert about registration", () => { + xit("does not render an alert about registration", () => { installerRender(); expect(screen.queryByText("Info alert:")).toBeNull(); expect(screen.queryByText("Product is already registered")).toBeNull(); diff --git a/web/src/components/system/HostnamePage.tsx b/web/src/components/system/HostnamePage.tsx index e91c881ba5..5960f756be 100644 --- a/web/src/components/system/HostnamePage.tsx +++ b/web/src/components/system/HostnamePage.tsx @@ -43,7 +43,7 @@ export default function HostnamePage() { const product = useProduct(); const { hostname: proposal } = useProposal(); // FIXME: It should be fixed once the registration is adapted to API v2 - const registration = { registered: true }; + const registration = { registered: product.registration }; const { hostname: transientHostname, static: staticHostname } = proposal; const hasTransientHostname = isEmpty(staticHostname); const [success, setSuccess] = useState(null); diff --git a/web/src/model/config/hostname.ts b/web/src/model/config/hostname.ts index b34067d314..a2f4aa07af 100644 --- a/web/src/model/config/hostname.ts +++ b/web/src/model/config/hostname.ts @@ -21,8 +21,8 @@ */ type Config = { - static?: String; - hostname?: String; + static?: string; + hostname?: string; }; export type { Config }; diff --git a/web/src/model/proposal/hostname.ts b/web/src/model/proposal/hostname.ts index 06b305c545..a80d4c11f9 100644 --- a/web/src/model/proposal/hostname.ts +++ b/web/src/model/proposal/hostname.ts @@ -21,8 +21,8 @@ */ type Proposal = { - static: String; - hostname: String; + static: string; + hostname: string; }; export type { Proposal }; diff --git a/web/src/model/system.ts b/web/src/model/system.ts index 4c43c185dd..07e55d377c 100644 --- a/web/src/model/system.ts +++ b/web/src/model/system.ts @@ -27,9 +27,9 @@ import type * as Software from "~/model/system/software"; import type * as Storage from "~/model/system/storage"; type System = { - hostname: Hostname.System; + hostname?: Hostname.System; l10n?: L10n.System; - network: Network.System; + network?: Network.System; products?: Product[]; software?: Software.System; storage?: Storage.System; diff --git a/web/src/model/system/hostname.ts b/web/src/model/system/hostname.ts index 80f7fa89d1..82d28fffb6 100644 --- a/web/src/model/system/hostname.ts +++ b/web/src/model/system/hostname.ts @@ -21,8 +21,8 @@ */ type System = { - static: String; - hostname: String; + static: string; + hostname: string; }; export type { System }; From 2eeeda015c9a670a28d457b0d230b2d0c562c19f Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Mon, 22 Dec 2025 11:59:36 +0000 Subject: [PATCH 08/15] Adding tests to hostname service --- rust/Cargo.lock | 3 + rust/agama-hostname/Cargo.toml | 3 + rust/agama-hostname/src/lib.rs | 44 +++++++++++++ rust/agama-hostname/src/model.rs | 70 ++++++++++++++++++++ rust/agama-hostname/src/test_utils.rs | 92 +++++++++++++++++++++++++++ rust/agama-manager/src/test_utils.rs | 2 + 6 files changed, 214 insertions(+) create mode 100644 rust/agama-hostname/src/test_utils.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index aeb4a752a5..5c7ec48609 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -70,9 +70,12 @@ dependencies = [ "agama-utils", "anyhow", "async-trait", + "tempfile", + "test-context", "thiserror 2.0.17", "tokio", "tokio-stream", + "tokio-test", "tracing", "zbus", ] diff --git a/rust/agama-hostname/Cargo.toml b/rust/agama-hostname/Cargo.toml index cf28ada8a5..82ec3c1412 100644 --- a/rust/agama-hostname/Cargo.toml +++ b/rust/agama-hostname/Cargo.toml @@ -8,8 +8,11 @@ edition.workspace = true agama-utils = { version = "0.1.0", path = "../agama-utils" } anyhow = "1.0.100" async-trait = "0.1.89" +tempfile = "3.23.0" +test-context = "0.4.1" thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.17" +tokio-test = "0.4.4" tracing = "0.1.43" zbus = "5.12.0" diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs index 09679f4627..4400a32278 100644 --- a/rust/agama-hostname/src/lib.rs +++ b/rust/agama-hostname/src/lib.rs @@ -43,3 +43,47 @@ pub mod message; mod model; pub use model::{Model, ModelAdapter}; mod monitor; +pub mod test_utils; + +#[cfg(test)] +mod tests { + use crate::{ + message, + service::Service, + test_utils::{start_service, TestModel}, + }; + + use agama_utils::{actor::Handler, api::event::Event, issue}; + use test_context::{test_context, AsyncTestContext}; + use tokio::sync::broadcast; + + struct Context { + events_rx: broadcast::Receiver, + handler: Handler, + issues: Handler, + } + + impl AsyncTestContext for Context { + async fn setup() -> Context { + let (events_tx, events_rx) = broadcast::channel::(16); + let issues = issue::Service::starter(events_tx.clone()).start(); + + let handler = start_service(events_tx, issues.clone()).await; + + Self { + events_rx, + handler, + issues, + } + } + } + + #[test_context(Context)] + #[tokio::test] + async fn test_get_and_set_config(ctx: &mut Context) -> Result<(), Box> { + let config = ctx.handler.call(message::GetConfig).await.unwrap(); + assert_eq!(config.r#static, Some("test-hostname".to_string())); + + Ok(()) + } +} diff --git a/rust/agama-hostname/src/model.rs b/rust/agama-hostname/src/model.rs index ef324b9c35..3a67b9997b 100644 --- a/rust/agama-hostname/src/model.rs +++ b/rust/agama-hostname/src/model.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. +//! In this file, we implement a new test case for the `install` method. use crate::service; use agama_utils::api::hostname::SystemInfo; use std::{fs, path::PathBuf, process::Command}; @@ -103,8 +104,77 @@ impl ModelAdapter for Model { let from = PathBuf::from(HOSTNAME_PATH); if fs::exists(from.clone())? { let to = PathBuf::from(self.static_target_dir()).join(HOSTNAME_PATH); + fs::create_dir_all(to.parent().unwrap())?; fs::copy(from, to)?; } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + struct TestModel { + source_dir: PathBuf, + target_dir: PathBuf, + } + + impl ModelAdapter for TestModel { + fn hostname(&self) -> Result { + Ok("test-hostname".to_string()) + } + + fn static_hostname(&self) -> Result { + let path = self.source_dir.join("etc/hostname"); + fs::read_to_string(path).map_err(service::Error::from) + } + + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { + let path = self.source_dir.join("etc/hostname"); + fs::write(path, name).map_err(service::Error::from) + } + + fn set_hostname(&mut self, _name: String) -> Result<(), service::Error> { + Ok(()) + } + + fn install(&self) -> Result<(), service::Error> { + let from = self.source_dir.join("etc/hostname"); + if fs::exists(&from)? { + let to = self.target_dir.join("etc/hostname"); + fs::create_dir_all(to.parent().unwrap())?; + fs::copy(from, to)?; + } + Ok(()) + } + + fn static_target_dir(&self) -> &str { + self.target_dir.to_str().unwrap() + } + } + + #[test] + fn test_install() -> Result<(), service::Error> { + let temp_source = tempdir()?; + let temp_target = tempdir()?; + let hostname_path = temp_source.path().join("etc"); + fs::create_dir_all(&hostname_path)?; + fs::write(hostname_path.join("hostname"), "test-hostname")?; + + let model = TestModel { + source_dir: temp_source.path().to_path_buf(), + target_dir: temp_target.path().to_path_buf(), + }; + + model.install()?; + + let installed_hostname_path = temp_target.path().join("etc/hostname"); + assert!(fs::exists(&installed_hostname_path)?); + let content = fs::read_to_string(installed_hostname_path)?; + assert_eq!(content, "test-hostname"); + + Ok(()) + } +} diff --git a/rust/agama-hostname/src/test_utils.rs b/rust/agama-hostname/src/test_utils.rs new file mode 100644 index 0000000000..3863d5bee4 --- /dev/null +++ b/rust/agama-hostname/src/test_utils.rs @@ -0,0 +1,92 @@ +// 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::Handler, api::event, issue}; +use async_trait::async_trait; +use std::{fs, path::PathBuf, process::Command}; +use tempfile::tempdir; + +use crate::{ + service::{self}, + ModelAdapter, Service, +}; + +pub struct TestModel { + source_dir: PathBuf, + target_dir: PathBuf, +} + +#[async_trait] +impl ModelAdapter for TestModel { + fn hostname(&self) -> Result { + Ok("test-hostname".to_string()) + } + + fn static_hostname(&self) -> Result { + let path = self.source_dir.join("etc/hostname"); + fs::read_to_string(path).map_err(service::Error::from) + } + + fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { + let path = self.source_dir.join("etc/hostname"); + fs::write(path, name).map_err(service::Error::from) + } + + fn set_hostname(&mut self, _name: String) -> Result<(), service::Error> { + Ok(()) + } + + fn install(&self) -> Result<(), service::Error> { + let from = self.source_dir.join("etc/hostname"); + if fs::exists(&from)? { + let to = self.target_dir.join("etc/hostname"); + fs::create_dir_all(to.parent().unwrap())?; + fs::copy(from, to)?; + } + Ok(()) + } + + fn static_target_dir(&self) -> &str { + self.target_dir.to_str().unwrap() + } +} + +/// Starts a testing hostname service. +pub async fn start_service( + events: event::Sender, + issues: Handler, +) -> Handler { + let temp_source = tempdir().unwrap(); + let temp_target = tempdir().unwrap(); + let hostname_path = temp_source.path().join("etc"); + fs::create_dir_all(&hostname_path).unwrap(); + fs::write(hostname_path.join("hostname"), "test-hostname").unwrap(); + + let model = TestModel { + source_dir: temp_source.path().to_path_buf(), + target_dir: temp_target.path().to_path_buf(), + }; + + Service::starter(events, issues) + .with_model(model) + .start() + .await + .expect("Could not spawn a testing hostname service") +} diff --git a/rust/agama-manager/src/test_utils.rs b/rust/agama-manager/src/test_utils.rs index ea8b2ffbbd..d6d353e427 100644 --- a/rust/agama-manager/src/test_utils.rs +++ b/rust/agama-manager/src/test_utils.rs @@ -22,6 +22,7 @@ use std::path::PathBuf; +use agama_hostname::test_utils::start_service as start_hostname_service; use agama_l10n::test_utils::start_service as start_l10n_service; use agama_network::test_utils::start_service as start_network_service; use agama_software::test_utils::start_service as start_software_service; @@ -38,6 +39,7 @@ pub async fn start_service(events: event::Sender, dbus: zbus::Connection) -> Han let progress = progress::Service::starter(events.clone()).start(); Service::starter(questions.clone(), events.clone(), dbus.clone()) + .with_hostname(start_hostname_service(events.clone(), issues.clone()).await) .with_l10n(start_l10n_service(events.clone(), issues.clone()).await) .with_storage( start_storage_service(events.clone(), issues.clone(), progress.clone(), dbus).await, From 7a56451dd71cb38ae3d533c40a807e476927b74c Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Mon, 22 Dec 2025 12:56:45 +0000 Subject: [PATCH 09/15] Edit hostname config --- rust/agama-hostname/src/lib.rs | 24 ++++++++++++++++++++++-- rust/agama-hostname/src/test_utils.rs | 4 +++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs index 4400a32278..3405e92837 100644 --- a/rust/agama-hostname/src/lib.rs +++ b/rust/agama-hostname/src/lib.rs @@ -53,7 +53,11 @@ mod tests { test_utils::{start_service, TestModel}, }; - use agama_utils::{actor::Handler, api::event::Event, issue}; + use agama_utils::{ + actor::Handler, + api::{self, event::Event}, + issue, + }; use test_context::{test_context, AsyncTestContext}; use tokio::sync::broadcast; @@ -81,8 +85,24 @@ mod tests { #[test_context(Context)] #[tokio::test] async fn test_get_and_set_config(ctx: &mut Context) -> Result<(), Box> { - let config = ctx.handler.call(message::GetConfig).await.unwrap(); + let mut config = ctx.handler.call(message::GetConfig).await.unwrap(); assert_eq!(config.r#static, Some("test-hostname".to_string())); + config.r#static = Some("".to_string()); + config.hostname = Some("test".to_string()); + + ctx.handler + .call(message::SetConfig::with(config.clone())) + .await?; + dbg!(" Updated hostname "); + + let updated = ctx.handler.call(message::GetConfig).await?; + assert_eq!( + &updated, + &api::hostname::Config { + r#static: Some("".to_string()), + hostname: Some("test".to_string()) + } + ); Ok(()) } diff --git a/rust/agama-hostname/src/test_utils.rs b/rust/agama-hostname/src/test_utils.rs index 3863d5bee4..4e3bf91e58 100644 --- a/rust/agama-hostname/src/test_utils.rs +++ b/rust/agama-hostname/src/test_utils.rs @@ -45,7 +45,9 @@ impl ModelAdapter for TestModel { } fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { - let path = self.source_dir.join("etc/hostname"); + let dir = self.source_dir.join("etc"); + let path = dir.join("hostname"); + fs::create_dir_all(&dir).map_err(service::Error::from)?; fs::write(path, name).map_err(service::Error::from) } From 48279d1b17e062e7036a20eb2eb57b7c1ffef767 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 23 Dec 2025 10:09:02 +0000 Subject: [PATCH 10/15] Some small fixes. --- rust/agama-hostname/Cargo.toml | 5 ++++- rust/agama-hostname/src/lib.rs | 7 +------ rust/agama-hostname/src/model.rs | 17 ++++++++--------- rust/agama-hostname/src/service.rs | 2 +- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/rust/agama-hostname/Cargo.toml b/rust/agama-hostname/Cargo.toml index 82ec3c1412..187eefbf54 100644 --- a/rust/agama-hostname/Cargo.toml +++ b/rust/agama-hostname/Cargo.toml @@ -9,10 +9,13 @@ agama-utils = { version = "0.1.0", path = "../agama-utils" } anyhow = "1.0.100" async-trait = "0.1.89" tempfile = "3.23.0" -test-context = "0.4.1" thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync"] } tokio-stream = "0.1.17" tokio-test = "0.4.4" tracing = "0.1.43" zbus = "5.12.0" + +[dev-dependencies] +tempfile = "3.23.0" +test-context = "0.4.1" diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs index 3405e92837..0389b48755 100644 --- a/rust/agama-hostname/src/lib.rs +++ b/rust/agama-hostname/src/lib.rs @@ -47,11 +47,7 @@ pub mod test_utils; #[cfg(test)] mod tests { - use crate::{ - message, - service::Service, - test_utils::{start_service, TestModel}, - }; + use crate::{message, service::Service, test_utils::start_service}; use agama_utils::{ actor::Handler, @@ -93,7 +89,6 @@ mod tests { ctx.handler .call(message::SetConfig::with(config.clone())) .await?; - dbg!(" Updated hostname "); let updated = ctx.handler.call(message::GetConfig).await?; assert_eq!( diff --git a/rust/agama-hostname/src/model.rs b/rust/agama-hostname/src/model.rs index 3a67b9997b..2f62ef38e1 100644 --- a/rust/agama-hostname/src/model.rs +++ b/rust/agama-hostname/src/model.rs @@ -18,7 +18,6 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! In this file, we implement a new test case for the `install` method. use crate::service; use agama_utils::api::hostname::SystemInfo; use std::{fs, path::PathBuf, process::Command}; @@ -102,7 +101,7 @@ impl ModelAdapter for Model { fn install(&self) -> Result<(), service::Error> { const HOSTNAME_PATH: &str = "/etc/hostname"; let from = PathBuf::from(HOSTNAME_PATH); - if fs::exists(from.clone())? { + if from.exists() { let to = PathBuf::from(self.static_target_dir()).join(HOSTNAME_PATH); fs::create_dir_all(to.parent().unwrap())?; fs::copy(from, to)?; @@ -110,15 +109,15 @@ impl ModelAdapter for Model { Ok(()) } } - #[cfg(test)] -mod tests { +pub mod tests { use super::*; - use tempfile::tempdir; + use tempfile::{tempdir, TempDir}; - struct TestModel { - source_dir: PathBuf, - target_dir: PathBuf, + #[derive(Clone)] + pub struct TestModel { + pub source_dir: PathBuf, + pub target_dir: PathBuf, } impl ModelAdapter for TestModel { @@ -142,7 +141,7 @@ mod tests { fn install(&self) -> Result<(), service::Error> { let from = self.source_dir.join("etc/hostname"); - if fs::exists(&from)? { + if from.exists() { let to = self.target_dir.join("etc/hostname"); fs::create_dir_all(to.parent().unwrap())?; fs::copy(from, to)?; diff --git a/rust/agama-hostname/src/service.rs b/rust/agama-hostname/src/service.rs index ef078c837a..410f1488b0 100644 --- a/rust/agama-hostname/src/service.rs +++ b/rust/agama-hostname/src/service.rs @@ -260,6 +260,6 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, _message: message::Install) -> Result<(), Error> { - Ok(()) + self.model.install() } } From eb2bbb4db3c8236600b2ffb1a7af27c203556174 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Wed, 24 Dec 2025 08:59:10 +0000 Subject: [PATCH 11/15] Merge leftovers --- rust/agama-server/src/web.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index b56235221b..363ea101a5 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -25,9 +25,8 @@ //! * Serve the code for the web user interface (not implemented yet). use crate::{ - bootloader::web::bootloader_service, error::Error, hostname::web::hostname_service, - profile::web::profile_service, security::security_service, server::server_service, - users::web::users_service, + bootloader::web::bootloader_service, profile::web::profile_service, security::security_service, + server::server_service, users::web::users_service, }; use agama_utils::api::event; use axum::Router; @@ -40,12 +39,10 @@ mod service; mod state; mod ws; -use agama_lib::connection; use agama_lib::error::ServiceError; pub use config::ServiceConfig; pub use service::MainServiceBuilder; use std::path::Path; -use tokio_stream::{StreamExt, StreamMap}; /// Returns a service that implements the web-based Agama API. /// From aab6eba161be1833db6313d782c8da53a572324c Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Wed, 24 Dec 2025 08:59:41 +0000 Subject: [PATCH 12/15] System static hostname is optional --- rust/agama-hostname/src/lib.rs | 18 ++++++++++- rust/agama-hostname/src/model.rs | 31 +++++++++++-------- rust/agama-hostname/src/service.rs | 27 +++++++++++----- rust/agama-hostname/src/test_utils.rs | 2 +- rust/agama-utils/src/api/hostname/proposal.rs | 2 +- .../src/api/hostname/system_info.rs | 2 +- 6 files changed, 57 insertions(+), 25 deletions(-) diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs index 0389b48755..3f6a3dc0ad 100644 --- a/rust/agama-hostname/src/lib.rs +++ b/rust/agama-hostname/src/lib.rs @@ -51,7 +51,7 @@ mod tests { use agama_utils::{ actor::Handler, - api::{self, event::Event}, + api::{self, event::Event, scope::Scope}, issue, }; use test_context::{test_context, AsyncTestContext}; @@ -99,6 +99,22 @@ mod tests { } ); + let proposal = ctx.handler.call(message::GetProposal).await?; + assert!(proposal.is_some()); + + let event = ctx + .events_rx + .recv() + .await + .expect("Did not receive the event"); + + assert!(matches!( + event, + Event::ProposalChanged { + scope: Scope::Hostname + } + )); + Ok(()) } } diff --git a/rust/agama-hostname/src/model.rs b/rust/agama-hostname/src/model.rs index 2f62ef38e1..919c506d4e 100644 --- a/rust/agama-hostname/src/model.rs +++ b/rust/agama-hostname/src/model.rs @@ -29,11 +29,13 @@ use std::{fs, path::PathBuf, process::Command}; /// tests. pub trait ModelAdapter: Send + 'static { /// Reads the system info. - fn system_info(&self) -> SystemInfo { - SystemInfo { - r#static: self.static_hostname().unwrap_or_default(), - hostname: self.hostname().unwrap_or_default(), - } + fn system_info(&self) -> Result { + let name = self.static_hostname()?; + + Ok(SystemInfo { + r#static: (!name.is_empty()).then(|| name), + hostname: self.hostname()?, + }) } /// Current system hostname. @@ -61,21 +63,24 @@ pub struct Model; impl ModelAdapter for Model { fn static_hostname(&self) -> Result { - let output = Command::new("hostnamectl") - .args(["hostname", "--static"]) - .output()?; - let output = String::from_utf8_lossy(&output.stdout).trim().parse(); + let mut cmd = Command::new("hostnamectl"); + cmd.args(["hostname", "--static"]); + tracing::info!("{:?}", &cmd); + let output = cmd.output()?; + tracing::info!("{:?}", &output); + + let output = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(output.unwrap_or_default()) + Ok(output) } fn hostname(&self) -> Result { let output = Command::new("hostnamectl") .args(["hostname", "--transient"]) .output()?; - let output = String::from_utf8_lossy(&output.stdout).trim().parse(); + let output = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(output.unwrap_or_default()) + Ok(output) } fn set_static_hostname(&mut self, name: String) -> Result<(), service::Error> { @@ -112,7 +117,7 @@ impl ModelAdapter for Model { #[cfg(test)] pub mod tests { use super::*; - use tempfile::{tempdir, TempDir}; + use tempfile::tempdir; #[derive(Clone)] pub struct TestModel { diff --git a/rust/agama-hostname/src/service.rs b/rust/agama-hostname/src/service.rs index 410f1488b0..927642a645 100644 --- a/rust/agama-hostname/src/service.rs +++ b/rust/agama-hostname/src/service.rs @@ -103,7 +103,7 @@ impl Starter { None => Box::new(Model), }; - let config = model.system_info(); + let config = model.system_info()?; let service = Service { config, @@ -186,7 +186,7 @@ impl MessageHandler for Service { _message: message::GetConfig, ) -> Result { Ok(api::hostname::Config { - r#static: Some(self.config.r#static.clone()), + r#static: self.config.r#static.clone(), hostname: Some(self.config.hostname.clone()), }) } @@ -201,15 +201,16 @@ impl MessageHandler> for Service { let current = self.config.clone(); if let Some(config) = &message.config { + self.config.r#static = config.r#static.clone(); + self.model + .set_static_hostname(config.r#static.clone().unwrap_or_default())?; if let Some(name) = &config.r#static { - self.config.r#static = name.clone(); self.config.hostname = name.clone(); - self.model.set_static_hostname(name.clone())? } if let Some(name) = &config.hostname { // If static hostname is set the transient is basically the same - if self.config.r#static.is_empty() { + if self.config.r#static.clone().unwrap_or_default().is_empty() { self.config.hostname = name.clone(); self.model.set_hostname(name.clone())? } @@ -242,7 +243,13 @@ impl MessageHandler for Service { #[async_trait] impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateHostname) -> Result<(), Error> { - self.config.hostname = message.name; + let current_name = self.config.hostname.clone(); + self.config.hostname = message.name.clone(); + if current_name != message.name { + self.events.send(Event::ProposalChanged { + scope: Scope::Hostname, + })?; + } Ok(()) } } @@ -251,8 +258,12 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, message: message::UpdateStaticHostname) -> Result<(), Error> { // If static hostname is set the transient is basically the same - self.config.r#static = message.name.clone(); - self.config.hostname = message.name; + if !message.name.is_empty() { + self.config.r#static = Some(message.name.clone()); + self.config.hostname = message.name; + } else { + self.config.r#static = None; + } Ok(()) } } diff --git a/rust/agama-hostname/src/test_utils.rs b/rust/agama-hostname/src/test_utils.rs index 4e3bf91e58..22b5a4502e 100644 --- a/rust/agama-hostname/src/test_utils.rs +++ b/rust/agama-hostname/src/test_utils.rs @@ -20,7 +20,7 @@ use agama_utils::{actor::Handler, api::event, issue}; use async_trait::async_trait; -use std::{fs, path::PathBuf, process::Command}; +use std::{fs, path::PathBuf}; use tempfile::tempdir; use crate::{ diff --git a/rust/agama-utils/src/api/hostname/proposal.rs b/rust/agama-utils/src/api/hostname/proposal.rs index 3de26856bb..00bc6c04d9 100644 --- a/rust/agama-utils/src/api/hostname/proposal.rs +++ b/rust/agama-utils/src/api/hostname/proposal.rs @@ -23,6 +23,6 @@ use serde::{Deserialize, Serialize}; /// Describes what Agama proposes for the target system. #[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct Proposal { - pub r#static: String, + pub r#static: Option, pub hostname: String, } diff --git a/rust/agama-utils/src/api/hostname/system_info.rs b/rust/agama-utils/src/api/hostname/system_info.rs index ced11358fc..cdf99feba4 100644 --- a/rust/agama-utils/src/api/hostname/system_info.rs +++ b/rust/agama-utils/src/api/hostname/system_info.rs @@ -23,6 +23,6 @@ use serde::{Deserialize, Serialize}; /// Describes the current system hostname information #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub struct SystemInfo { - pub r#static: String, + pub r#static: Option, pub hostname: String, } From 542ab77ee26d153eedc5457521508adc9df5e8a3 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Wed, 24 Dec 2025 12:23:18 +0000 Subject: [PATCH 13/15] Changes based on code review --- rust/agama-hostname/src/lib.rs | 12 ++++-------- rust/agama-hostname/src/service.rs | 2 +- rust/agama-utils/src/api/hostname/proposal.rs | 2 ++ rust/agama-utils/src/api/hostname/system_info.rs | 2 ++ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs index 3f6a3dc0ad..ee80f87d3e 100644 --- a/rust/agama-hostname/src/lib.rs +++ b/rust/agama-hostname/src/lib.rs @@ -18,19 +18,15 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -//! This crate implements the support for localization handling in Agama. -//! It takes care of setting the locale, keymap and timezone for Agama itself -//! and the target system. +//! This crate implements the support for hostname handling in Agama. +//! It takes care of setting the hostname for Agama itself and copying it +//! to the target system in case of an static one. //! -//! From a technical point of view, it includes: -//! -//! * The [UserConfig] struct that defines the settings the user can -//! alter for the target system. //! * The [Proposal] struct that describes how the system will look like after //! the installation. //! * The [SystemInfo] which includes information about the system //! where Agama is running. -//! * An [specific event type](Event) for localization-related events. +//! * An [specific event type](Event) for hostname-related events. //! //! The service can be started by calling the [start_service] function, which //! returns a [agama_utils::actors::ActorHandler] to interact with the system. diff --git a/rust/agama-hostname/src/service.rs b/rust/agama-hostname/src/service.rs index 927642a645..f9c337d408 100644 --- a/rust/agama-hostname/src/service.rs +++ b/rust/agama-hostname/src/service.rs @@ -72,7 +72,7 @@ pub struct Starter { impl Starter { /// Creates a new starter. /// - /// * `events`: channel to emit the [localization-specific events](crate::Event). + /// * `events`: channel to emit the [hostname-specific events](crate::Event). /// * `issues`: handler to the issues service. pub fn new(events: event::Sender, issues: Handler) -> Self { Self { diff --git a/rust/agama-utils/src/api/hostname/proposal.rs b/rust/agama-utils/src/api/hostname/proposal.rs index 00bc6c04d9..00c75c50c6 100644 --- a/rust/agama-utils/src/api/hostname/proposal.rs +++ b/rust/agama-utils/src/api/hostname/proposal.rs @@ -23,6 +23,8 @@ use serde::{Deserialize, Serialize}; /// Describes what Agama proposes for the target system. #[derive(Clone, Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct Proposal { + #[serde(skip_serializing_if = "Option::is_none")] pub r#static: Option, + #[serde(alias = "transient")] pub hostname: String, } diff --git a/rust/agama-utils/src/api/hostname/system_info.rs b/rust/agama-utils/src/api/hostname/system_info.rs index cdf99feba4..c5b7102c56 100644 --- a/rust/agama-utils/src/api/hostname/system_info.rs +++ b/rust/agama-utils/src/api/hostname/system_info.rs @@ -23,6 +23,8 @@ use serde::{Deserialize, Serialize}; /// Describes the current system hostname information #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] pub struct SystemInfo { + #[serde(skip_serializing_if = "Option::is_none")] pub r#static: Option, + #[serde(alias = "transient")] pub hostname: String, } From 18092373a7bf9ebfa76ecc53f6a08c3ab96973c7 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Wed, 24 Dec 2025 12:36:23 +0000 Subject: [PATCH 14/15] More changes based on code review --- rust/Cargo.toml | 3 ++- rust/agama-hostname/src/lib.rs | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7fccc9c5b1..c440a2347e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,7 +2,8 @@ members = [ "agama-autoinstall", "agama-cli", - "agama-files", "agama-hostname", + "agama-files", + "agama-hostname", "agama-l10n", "agama-lib", "agama-locale-data", diff --git a/rust/agama-hostname/src/lib.rs b/rust/agama-hostname/src/lib.rs index ee80f87d3e..a4a09b8c4f 100644 --- a/rust/agama-hostname/src/lib.rs +++ b/rust/agama-hostname/src/lib.rs @@ -82,9 +82,7 @@ mod tests { config.r#static = Some("".to_string()); config.hostname = Some("test".to_string()); - ctx.handler - .call(message::SetConfig::with(config.clone())) - .await?; + ctx.handler.call(message::SetConfig::with(config)).await?; let updated = ctx.handler.call(message::GetConfig).await?; assert_eq!( From 8f9796e15f3450a818fc076785e0ffca9dce6819 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Wed, 24 Dec 2025 12:41:50 +0000 Subject: [PATCH 15/15] Fixed eslint complains --- web/src/components/system/HostnamePage.test.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/web/src/components/system/HostnamePage.test.tsx b/web/src/components/system/HostnamePage.test.tsx index 61fb561096..ac13afbbf6 100644 --- a/web/src/components/system/HostnamePage.test.tsx +++ b/web/src/components/system/HostnamePage.test.tsx @@ -23,13 +23,11 @@ import React from "react"; import { screen } from "@testing-library/react"; import { installerRender } from "~/test-utils"; -import { Product, RegistrationInfo } from "~/types/software"; +import { Product } from "~/types/software"; import HostnamePage from "./HostnamePage"; -import { patchConfig } from "~/api"; let mockStaticHostname: string; -let registrationInfoMock: RegistrationInfo; -let mockPatchConfig = jest.fn(); +const mockPatchConfig = jest.fn(); const tw: Product = { id: "Tumbleweed", @@ -197,7 +195,6 @@ describe("HostnamePage", () => { describe("when the selected product is registrable and registration code is not set", () => { beforeEach(() => { selectedProduct = sle; - registrationInfoMock = { registered: false, key: "", email: "", url: "" }; }); xit("does not render an alert about registration", () => { @@ -210,12 +207,6 @@ describe("HostnamePage", () => { describe("when the selected product is registrable and registration code is set", () => { beforeEach(() => { selectedProduct = sle; - registrationInfoMock = { - registered: true, - key: "INTERNAL-USE-ONLY-1234-5678", - email: "example@company.test", - url: "", - }; }); it("renders an alert to let user know that changes will not have effect in the registration", () => {