diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5c7ec48609..9b323fd071 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -280,6 +280,7 @@ dependencies = [ "serde_with", "serde_yaml", "strum", + "suseconnect-agama", "tempfile", "thiserror 2.0.17", "tokio", @@ -343,6 +344,7 @@ dependencies = [ "tokio-stream", "tokio-test", "tracing", + "url", "utoipa", "uuid", "zbus", diff --git a/rust/agama-software/Cargo.toml b/rust/agama-software/Cargo.toml index 25b7ebb4a5..3b5f40996c 100644 --- a/rust/agama-software/Cargo.toml +++ b/rust/agama-software/Cargo.toml @@ -21,6 +21,7 @@ tokio-stream = "0.1.16" tracing = "0.1.41" url = "2.5.7" utoipa = { version = "5.2.0", features = ["axum_extras", "uuid"] } +suseconnect-agama = { path = "../suseconnect-agama" } zypp-agama = { path = "../zypp-agama" } [dev-dependencies] diff --git a/rust/agama-software/src/lib.rs b/rust/agama-software/src/lib.rs index cc9046445f..f9e798e00c 100644 --- a/rust/agama-software/src/lib.rs +++ b/rust/agama-software/src/lib.rs @@ -39,7 +39,7 @@ pub mod service; pub use service::Service; mod model; -pub use model::{state, Model, ModelAdapter, Resolvable, ResolvableType}; +pub use model::{state, Model, ModelAdapter, Registration, Resolvable, ResolvableType}; mod callbacks; pub mod message; diff --git a/rust/agama-software/src/model.rs b/rust/agama-software/src/model.rs index a665b0302d..6f4e5992cd 100644 --- a/rust/agama-software/src/model.rs +++ b/rust/agama-software/src/model.rs @@ -39,6 +39,7 @@ pub mod software_selection; pub mod state; pub use packages::{Resolvable, ResolvableType}; +pub use registration::{Registration, RegistrationBuilder}; /// Abstract the software-related configuration from the underlying system. /// diff --git a/rust/agama-software/src/model/registration.rs b/rust/agama-software/src/model/registration.rs index acac8ff6ba..dad563f4ad 100644 --- a/rust/agama-software/src/model/registration.rs +++ b/rust/agama-software/src/model/registration.rs @@ -18,47 +18,250 @@ // 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}; - -/// Software service configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RegistrationParams { - /// Registration key. - pub key: String, - /// Registration email. - pub email: String, +//! This module implements support for registering a system. +//! +//! It interacts with SUSEConnect-ng (using the [suseconnect_agama] crate) to register +//! the system and its add-ons and with libzypp (through [zypp_agama]) to add the +//! corresponding services to `libzypp`. + +use agama_utils::{ + api::software::{AddonInfo, RegistrationInfo}, + arch::Arch, +}; +use camino::Utf8PathBuf; +use suseconnect_agama::{self, ConnectParams, Credentials}; +use url::Url; + +#[derive(thiserror::Error, Debug)] +pub enum RegistrationError { + #[error(transparent)] + Registration(#[from] suseconnect_agama::Error), + #[error("Failed to add the service {0}: {1}")] + AddService(String, #[source] zypp_agama::ZyppError), } -/// Addon registration -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AddonParams { - // Addon identifier - pub id: String, - // Addon version, if not specified the version is found from the available addons - pub version: Option, - // Optional registration code, not required for free extensions - pub registration_code: Option, +type RegistrationResult = Result; + +/// Represents a registered system. +/// +/// It is used to activate products and add the corresponding services. +/// It is created from a [RegistrationBuilder]. +#[derive(Debug)] +pub struct Registration { + root_dir: Utf8PathBuf, + product: String, + version: String, + // The connection parameters are kept because they are needed by the + // `to_registration_info` function. + connect_params: ConnectParams, + creds: Credentials, + services: Vec, } -/// Information about registration configuration (product, patterns, etc.). -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct RegistrationInfo { - /// Registration status. True if base system is already registered. - pub registered: bool, - /// Registration key. Empty value mean key not used or not registered. - pub key: String, - /// Registration email. Empty value mean email not used or not registered. - pub email: String, - /// Registration URL. Empty value mean that de default value is used. - pub url: String, +impl Registration { + pub fn builder(root_dir: Utf8PathBuf, product: &str, version: &str) -> RegistrationBuilder { + RegistrationBuilder::new(root_dir, product, version) + } + + // This activate_product should receive the code + pub fn activate_product( + &mut self, + zypp: &zypp_agama::Zypp, + name: &str, + version: &str, + code: Option<&str>, + ) -> RegistrationResult<()> { + let product = Self::product_specification(name, version); + let mut params = self.connect_params.clone(); + params.token = code.map(ToString::to_string); + + tracing::debug!("Registering product {product:?}"); + let service = suseconnect_agama::activate_product( + product, + params, + self.connect_params + .email + .as_ref() + .map(|e| e.as_str()) + .unwrap_or(""), + )?; + + if let Some(file) = Self::credentials_from_url(&service.url) { + let path = self + .root_dir + .join(format!("etc/zypp/credentials.d/{}", file)); + tracing::debug!( + "Creating the credentials file for {} at {}", + &service.name, + &path + ); + suseconnect_agama::create_credentials_file( + &self.creds.login, + &self.creds.password, + path.as_str(), + )?; + } + + // Add the libzypp service + zypp.add_service(&service.name, &service.url) + .map_err(|e| RegistrationError::AddService(service.name.clone(), e))?; + self.services.push(service); + Ok(()) + } + + /// Returns the registration information. + /// + /// It includes not only the basic data (like the registration code or the e-mail), + /// but the list of extensions. + pub fn to_registration_info(&self) -> RegistrationInfo { + let addons: Vec = match self.base_product() { + Ok(product) => product + .extensions + .into_iter() + .map(|e| AddonInfo { + id: e.identifier, + version: e.version, + label: e.friendly_name, + available: e.available, + free: e.free, + recommended: e.recommended, + description: e.description, + release: e.release_stage, + }) + .collect(), + Err(error) => { + tracing::error!("Failed to get the product from the registration server: {error}"); + vec![] + } + }; + + RegistrationInfo { + code: self.connect_params.token.clone(), + email: self.connect_params.email.clone(), + url: self.connect_params.url.clone(), + addons, + } + } + + fn base_product(&self) -> RegistrationResult { + let product = suseconnect_agama::show_product( + self.base_product_specification(), + self.connect_params.clone(), + )?; + Ok(product) + } + + fn base_product_specification(&self) -> suseconnect_agama::ProductSpecification { + Self::product_specification(&self.product, &self.version) + } + + fn product_specification(id: &str, version: &str) -> suseconnect_agama::ProductSpecification { + // We do not expect this to happen. + let arch = Arch::current().expect("Failed to determine the architecture"); + suseconnect_agama::ProductSpecification { + identifier: id.to_string(), + arch: arch.to_string(), + version: version.to_string(), + } + } + + fn credentials_from_url(url: &str) -> Option { + let url = Url::parse(url) + .inspect_err(|e| tracing::warn!("Could not parse the service URL: {e}")) + .ok()?; + url.query_pairs() + .find(|(k, _v)| k == "credentials") + .map(|(_k, v)| v.to_string()) + } } -#[derive(Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct RegistrationError { - /// ID of error. See dbus API for possible values - pub id: u32, - /// human readable error string intended to be displayed to user - pub message: String, +/// A builder for a [Registration] object. +/// +/// It is used to configure the build a registration object. It allows to configure +/// the registration parameters like the product and version, the registration code, +/// the e-mail, etc. +/// [Registration] object. +#[derive(Debug)] +pub struct RegistrationBuilder { + root_dir: Utf8PathBuf, + product: String, + version: String, + code: Option, + email: Option, +} + +impl RegistrationBuilder { + /// Creates a new builder. + /// + /// It receives the mandatory arguments for registering a system. + /// + /// * `root_dir`: root directory where libzypp configuration lives. + /// * `product`: product name (e.g., "SLES"). + /// * `version`: product version (e.g., "16.1"). + pub fn new(root_dir: Utf8PathBuf, product: &str, version: &str) -> Self { + RegistrationBuilder { + root_dir, + product: product.to_string(), + version: version.to_string(), + code: None, + email: None, + } + } + + /// Sets the registration code to use. + /// + /// * `code`: registration code. + pub fn with_code(mut self, code: &str) -> Self { + self.code = Some(code.to_string()); + self + } + + /// Sets the e-mail associated to the registration. + pub fn with_email(mut self, email: &str) -> Self { + self.email = Some(email.to_string()); + self + } + + /// Registers the system and return a [Registration] object. + /// + /// It announces the system, gets the credentials and registers the base product. + /// + /// * `zypp`: zypp instance. + pub fn register(self, zypp: &zypp_agama::Zypp) -> RegistrationResult { + let params = suseconnect_agama::ConnectParams { + token: self.code.clone(), + email: self.email.clone(), + language: "en-us".to_string().into(), + // unwrap: it is guaranteed to be a correct URL. + url: Some(Url::parse(suseconnect_agama::DEFAULT_SCC_URL).unwrap()), + ..Default::default() + }; + // https://github.com/agama-project/agama/blob/master/service/lib/agama/registration.rb#L294 + let version = self.version.split(".").next().unwrap_or("1"); + let target_distro = format!("{}-{}-{}", &self.product, version, std::env::consts::ARCH); + tracing::debug!("Announcing system {target_distro}"); + let creds = suseconnect_agama::announce_system(params.clone(), &target_distro)?; + + tracing::debug!( + "Creating the base credentials file at {}", + suseconnect_agama::GLOBAL_CREDENTIALS_FILE + ); + suseconnect_agama::create_credentials_file( + &creds.login, + &creds.password, + suseconnect_agama::GLOBAL_CREDENTIALS_FILE, + )?; + + let mut registration = Registration { + root_dir: self.root_dir, + connect_params: params, + product: self.product.clone(), + version: self.version.clone(), + creds, + services: vec![], + }; + + registration.activate_product(zypp, &self.product, &self.version, None)?; + Ok(registration) + } } diff --git a/rust/agama-software/src/model/state.rs b/rust/agama-software/src/model/state.rs index e0b1672053..2a38ee66d2 100644 --- a/rust/agama-software/src/model/state.rs +++ b/rust/agama-software/src/model/state.rs @@ -25,7 +25,9 @@ use std::collections::HashMap; use agama_utils::{ - api::software::{Config, PatternsConfig, RepositoryConfig, SystemInfo}, + api::software::{ + Config, PatternsConfig, ProductConfig, RepositoryConfig, SoftwareConfig, SystemInfo, + }, products::{ProductSpec, UserPattern}, }; @@ -44,6 +46,7 @@ pub struct SoftwareState { pub repositories: Vec, pub resolvables: ResolvablesState, pub options: SoftwareOptions, + pub registration: Option, } impl SoftwareState { @@ -54,6 +57,7 @@ impl SoftwareState { repositories: Default::default(), resolvables: Default::default(), options: Default::default(), + registration: None, } } } @@ -146,16 +150,41 @@ impl<'a> SoftwareStateBuilder<'a> { /// Adds the elements from the user configuration. fn add_user_config(&self, state: &mut SoftwareState, config: &Config) { - let Some(software) = &config.software else { + if let Some(product) = &config.product { + self.add_user_product_config(state, product); + } + + if let Some(software) = &config.software { + self.add_user_software_config(state, software); + } + } + + /// Adds the elements from the user product configuration. + fn add_user_product_config(&self, state: &mut SoftwareState, config: &ProductConfig) { + let Some(code) = &config.registration_code else { return; }; - if let Some(repositories) = &software.extra_repositories { + let product = self.product.id.clone(); + let version = self.product.version.clone().unwrap_or("1".to_string()); + + state.registration = Some(RegistrationState { + product, + version, + code: code.to_string(), + email: config.registration_email.clone(), + url: config.registration_url.clone(), + }); + } + + /// Adds the elements from the user software configuration. + fn add_user_software_config(&self, state: &mut SoftwareState, config: &SoftwareConfig) { + if let Some(repositories) = &config.extra_repositories { let extra = repositories.iter().map(Repository::from); state.repositories.extend(extra); } - if let Some(patterns) = &software.patterns { + if let Some(patterns) = &config.patterns { match patterns { PatternsConfig::PatternsList(list) => { state.resolvables.reset(); @@ -194,7 +223,7 @@ impl<'a> SoftwareStateBuilder<'a> { } } - if let Some(only_required) = software.only_required { + if let Some(only_required) = config.only_required { state.options.only_required = only_required; } } @@ -259,6 +288,7 @@ impl<'a> SoftwareStateBuilder<'a> { product: software.base_product.clone(), repositories, resolvables, + registration: None, options: Default::default(), } } @@ -420,14 +450,24 @@ pub struct SoftwareOptions { only_required: bool, } +#[derive(Clone, Debug)] +pub struct RegistrationState { + pub product: String, + pub version: String, + // FIXME: the code should be optional. + pub code: String, + pub email: Option, + pub url: Option, +} + #[cfg(test)] mod tests { use std::path::PathBuf; use agama_utils::{ api::software::{ - Config, PatternsConfig, PatternsMap, Repository, RepositoryConfig, SoftwareConfig, - SystemInfo, + Config, PatternsConfig, PatternsMap, ProductConfig, Repository, RepositoryConfig, + SoftwareConfig, SystemInfo, }, products::ProductSpec, }; @@ -557,6 +597,28 @@ mod tests { ); } + #[test] + fn test_add_registration() { + let product = build_product_spec(); + let mut config = build_user_config(None); + config.product = ProductConfig { + id: Some("SLES".to_string()), + registration_code: Some("123456".to_string()), + registration_url: Some("https://scc.suse.com".to_string()), + registration_email: Some("jane.doe@example.net".to_string()), + addons: None, + } + .into(); + let state = SoftwareStateBuilder::for_product(&product) + .with_config(&config) + .build(); + + let registration = state.registration.unwrap(); + assert_eq!(registration.code, "123456".to_string()); + assert_eq!(registration.url, Some("https://scc.suse.com".to_string())); + assert_eq!(registration.email, Some("jane.doe@example.net".to_string())); + } + #[test] fn test_remove_patterns() { let product = build_product_spec(); diff --git a/rust/agama-software/src/zypp_server.rs b/rust/agama-software/src/zypp_server.rs index 03b1e6d03d..dd214a82ed 100644 --- a/rust/agama-software/src/zypp_server.rs +++ b/rust/agama-software/src/zypp_server.rs @@ -40,8 +40,8 @@ use zypp_agama::{errors::ZyppResult, ZyppError}; use crate::{ callbacks, model::state::{self, SoftwareState}, - state::ResolvableSelection, - ResolvableType, + state::{RegistrationState, ResolvableSelection}, + Registration, ResolvableType, }; const GPG_KEYS: &str = "/usr/lib/rpm/gnupg/keys/gpg-*"; @@ -103,6 +103,7 @@ pub enum SoftwareAction { /// Software service server. pub struct ZyppServer { receiver: mpsc::UnboundedReceiver, + registration: Option, root_dir: Utf8PathBuf, } @@ -118,6 +119,7 @@ impl ZyppServer { let server = Self { receiver, root_dir: root_dir.as_ref().to_path_buf(), + registration: None, }; // drop the returned JoinHandle: the thread will be detached @@ -246,7 +248,7 @@ impl ZyppServer { } fn write( - &self, + &mut self, state: SoftwareState, progress: Handler, security: &mut callbacks::Security, @@ -254,16 +256,29 @@ impl ZyppServer { zypp: &zypp_agama::Zypp, ) -> Result<(), ZyppDispatchError> { let mut issues: Vec = vec![]; + let mut steps = vec![ + gettext("Updating the list of repositories"), + gettext("Refreshing metadata from the repositories"), + gettext("Calculating the software proposal"), + ]; + if state.registration.is_some() { + steps.insert(0, gettext("Registering the system")); + } _ = progress.cast(progress::message::StartWithSteps::new( Scope::Software, - vec![ - gettext("Updating the list of repositories"), - gettext("Refreshing metadata from the repositories"), - gettext("Calculating the software proposal"), - ], + steps, )); + + // TODO: add information about the current registration state let old_state = self.read(zypp)?; + + // how to check whether the system is registered + if let Some(registration) = &state.registration { + self.register_system(registration, &zypp, &mut issues); + } + + progress.cast(progress::message::Next::new(Scope::Software))?; let old_aliases: Vec<_> = old_state .repositories .iter() @@ -496,11 +511,12 @@ impl ZyppServer { ) -> Result<(), ZyppDispatchError> { let patterns = self.patterns(&product, zypp)?; let repositories = self.repositories(zypp)?; + let registration = self.registration.as_ref().map(|r| r.to_registration_info()); let system_info = SystemInfo { patterns, repositories, - addons: vec![], + registration, }; tx.send(Ok(system_info)) @@ -656,4 +672,28 @@ impl ZyppServer { }) .map_err(|e| e.into()) } + + fn register_system( + &mut self, + state: &RegistrationState, + zypp: &zypp_agama::Zypp, + issues: &mut Vec, + ) { + let mut registration = + Registration::builder(self.root_dir.clone(), &state.product, &state.version) + .with_code(&state.code); + if let Some(email) = &state.email { + registration = registration.with_email(email); + } + + match registration.register(&zypp) { + Ok(registration) => self.registration = Some(registration), + Err(error) => { + issues.push( + Issue::new("software.register_system", "Failed to register the system") + .with_details(&error.to_string()), + ); + } + } + } } diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 93af9b11a5..01c1345fa1 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -15,7 +15,7 @@ strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.16" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "process", "sync"] } tokio-stream = "0.1.17" -utoipa = "5.3.1" +utoipa = { version = "5.3.1", features = ["url"] } zbus = "5.7.1" zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } @@ -29,6 +29,7 @@ fs-err = "3.2.0" fluent-uri = { version = "0.4.1", features = ["serde"] } tempfile = "3.23.0" merge = "0.2.0" +url = { version = "2.5.7", features = ["serde"] } [dev-dependencies] test-context = "0.4.1" diff --git a/rust/agama-utils/src/api/software/system_info.rs b/rust/agama-utils/src/api/software/system_info.rs index a2e7398274..232b329e1f 100644 --- a/rust/agama-utils/src/api/software/system_info.rs +++ b/rust/agama-utils/src/api/software/system_info.rs @@ -20,7 +20,7 @@ use serde::Serialize; -/// Localization-related information of the system where the installer +/// Software-related information of the system where the installer /// is running. #[derive(Clone, Debug, Default, Serialize, utoipa::ToSchema)] pub struct SystemInfo { @@ -28,8 +28,8 @@ pub struct SystemInfo { pub patterns: Vec, /// List of known repositories. pub repositories: Vec, - /// List of available addons to register - pub addons: Vec, + /// Registration information + pub registration: Option, } /// Repository specification. @@ -66,10 +66,22 @@ pub struct Pattern { pub preselected: bool, } +#[derive(Clone, Default, Debug, Serialize, utoipa::ToSchema)] +pub struct RegistrationInfo { + /// Registration code. + pub code: Option, + /// Registration e-mail. + pub email: Option, + /// URL of the registration server. + pub url: Option, + /// Available add-ons. + pub addons: Vec, +} + /// Addon registration #[derive(Clone, Debug, Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] -pub struct AddonProperties { +pub struct AddonInfo { /// Addon identifier pub id: String, /// Version of the addon @@ -84,8 +96,6 @@ pub struct AddonProperties { pub recommended: bool, /// Short description of the addon (translated) pub description: String, - /// Type of the addon, like "extension" or "module" - pub r#type: String, /// Release status of the addon, e.g. "beta" pub release: String, } diff --git a/rust/agama-utils/src/arch.rs b/rust/agama-utils/src/arch.rs new file mode 100644 index 0000000000..b955c3a12a --- /dev/null +++ b/rust/agama-utils/src/arch.rs @@ -0,0 +1,97 @@ +// 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. + +//! Implement support for detecting and converting architeture identifiers. + +use std::process::Command; + +#[derive(Clone, Copy, Debug, PartialEq, strum::Display, strum::EnumString)] +#[strum(serialize_all = "lowercase")] +pub enum Arch { + AARCH64, + PPC64LE, + S390X, + X86_64, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Unknown architecture: {0}")] + Unknown(String), + #[error("Could not detect the architecture")] + Detect(#[from] std::io::Error), +} + +impl Arch { + /// Returns the current architecture. + pub fn current() -> Result { + let output = Command::new("uname").arg("-m").output()?; + let arch_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + arch_str + .as_str() + .try_into() + .map_err(|_| Error::Unknown(arch_str)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_arch_from_string() { + assert_eq!("aarch64".try_into(), Ok(Arch::AARCH64)); + assert_eq!("ppc64le".try_into(), Ok(Arch::PPC64LE)); + assert_eq!("s390x".try_into(), Ok(Arch::S390X)); + assert_eq!("x86_64".try_into(), Ok(Arch::X86_64)); + } + + #[test] + fn test_arch_to_string() { + assert_eq!(Arch::AARCH64.to_string(), "aarch64".to_string()); + assert_eq!(Arch::PPC64LE.to_string(), "ppc64le".to_string()); + assert_eq!(Arch::S390X.to_string(), "s390x".to_string()); + assert_eq!(Arch::X86_64.to_string(), "x86_64".to_string()); + } + + #[cfg(target_arch = "aarch64")] + #[test] + fn test_current_arch_aarch64() { + assert_eq!(Arch::current().unwrap(), Arch::AARCH64); + } + + #[cfg(target_arch = "powerpc64")] + #[test] + fn test_current_arch_powerpc64() { + assert_eq!(Arch::current().unwrap(), Arch::PPC64LE); + } + + #[cfg(target_arch = "s390x")] + #[test] + fn test_current_arch_s390x() { + assert_eq!(Arch::current().unwrap(), Arch::S390X); + } + + #[cfg(target_arch = "x86_64")] + #[test] + fn test_current_arch_x86_64() { + assert_eq!(Arch::current().unwrap(), Arch::X86_64); + } +} diff --git a/rust/agama-utils/src/lib.rs b/rust/agama-utils/src/lib.rs index d90e670052..1bf3c28cfb 100644 --- a/rust/agama-utils/src/lib.rs +++ b/rust/agama-utils/src/lib.rs @@ -23,6 +23,7 @@ pub mod actor; pub mod api; +pub mod arch; pub mod command; pub mod dbus; pub mod issue;