Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package agama for openSUSE:Factory checked in at 2026-02-07 15:33:27 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/agama (Old) and /work/SRC/openSUSE:Factory/.agama.new.1670 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "agama" Sat Feb 7 15:33:27 2026 rev:36 rq:1331727 version:0 Changes: -------- --- /work/SRC/openSUSE:Factory/agama/agama.changes 2026-02-03 21:25:57.842680796 +0100 +++ /work/SRC/openSUSE:Factory/.agama.new.1670/agama.changes 2026-02-07 15:33:54.474573139 +0100 @@ -1,0 +2,29 @@ +Fri Feb 6 13:59:11 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> + +- Fix parsing of the answers file (bsc#1257400). + +------------------------------------------------------------------- +Fri Feb 6 07:38:38 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> + +- Allow loading a profile with no product (bsc#1257067 and bsc#1257082). +- Make the bootloader proposal after the storage one is ready (bsc#1257530). +- Run pre-scripts only once to prevent potential infinite loops. + +------------------------------------------------------------------- +Thu Feb 5 06:46:46 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> + +- Sort the list of languages, keymaps and timezones (bsc#1257497). + +------------------------------------------------------------------- +Wed Feb 4 11:37:18 UTC 2026 - Josef Reidinger <[email protected]> + +- Fix usage of local repositories on full medium + (bsc#1257475) + +------------------------------------------------------------------- +Tue Feb 3 07:23:18 UTC 2026 - Knut Anderssen <[email protected]> + +- Correct the /etc/resolv.conf link in the chroot to + /run/NetworkManager/resolv.conf (bsc#257468). + +------------------------------------------------------------------- @@ -31 +60 @@ - they are None (related to bsc#1257400). + they are None (related to bsc#1257230). ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ agama.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/Cargo.lock new/agama/Cargo.lock --- old/agama/Cargo.lock 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/Cargo.lock 2026-02-06 17:21:45.000000000 +0100 @@ -61,6 +61,7 @@ "inquire", "regex", "reqwest", + "serde", "serde_json", "tempfile", "thiserror 2.0.18", @@ -76,6 +77,7 @@ "agama-utils", "async-trait", "serde_json", + "strum", "tempfile", "test-context", "thiserror 2.0.18", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-cli/Cargo.toml new/agama/agama-cli/Cargo.toml --- old/agama/agama-cli/Cargo.toml 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-cli/Cargo.toml 2026-02-06 17:21:45.000000000 +0100 @@ -28,6 +28,7 @@ regex = "1.11.1" home = "0.5.11" fluent-uri = "0.3.2" +serde = { version = "1.0.228", features = ["derive"] } [[bin]] name = "agama" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-cli/src/questions.rs new/agama/agama-cli/src/questions.rs --- old/agama/agama-cli/src/questions.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-cli/src/questions.rs 2026-02-06 17:21:45.000000000 +0100 @@ -24,6 +24,7 @@ use agama_utils::api::question::{AnswerRule, Policy, QuestionSpec}; use anyhow::anyhow; use clap::{Args, Subcommand, ValueEnum}; +use serde::Deserialize; // TODO: use for answers also JSON to be consistent #[derive(Subcommand, Debug)] @@ -72,11 +73,17 @@ Ok(()) } +#[derive(Deserialize)] +struct AnswersWrapper { + #[serde(default)] + answers: Vec<AnswerRule>, +} + async fn set_answers(client: HTTPClient, path: &str) -> anyhow::Result<()> { let file = File::open(&path)?; let reader = BufReader::new(file); - let rules: Vec<AnswerRule> = serde_json::from_reader(reader)?; - client.set_answers(rules).await?; + let wrapper: AnswersWrapper = serde_json::from_reader(reader)?; + client.set_answers(wrapper.answers).await?; Ok(()) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-files/Cargo.toml new/agama/agama-files/Cargo.toml --- old/agama/agama-files/Cargo.toml 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-files/Cargo.toml 2026-02-06 17:21:45.000000000 +0100 @@ -8,6 +8,7 @@ agama-software = { version = "0.1.0", path = "../agama-software" } agama-utils = { path = "../agama-utils" } async-trait = "0.1.89" +strum = "0.27.2" tempfile = "3.23.0" thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["sync"] } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-files/src/lib.rs new/agama/agama-files/src/lib.rs --- old/agama/agama-files/src/lib.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-files/src/lib.rs 2026-02-06 17:21:45.000000000 +0100 @@ -78,7 +78,7 @@ ) .await; let handler = Service::starter(progress, questions, software) - .with_scripts_workdir(tmp_dir.path()) + .with_workdir(tmp_dir.path()) .with_install_dir(tmp_dir.path()) .start() .await @@ -94,6 +94,13 @@ #[test_context(Context)] #[tokio::test] async fn test_add_and_run_scripts(ctx: &mut Context) -> Result<(), Error> { + let ran = ctx + .handler + .call(message::RunScripts::new(ScriptsGroup::Pre)) + .await + .unwrap(); + assert_eq!(ran, false); + let test_file_1 = ctx.tmp_dir.path().join("file-1.txt"); let test_file_2 = ctx.tmp_dir.path().join("file-2.txt"); @@ -118,10 +125,12 @@ .await .unwrap(); - ctx.handler + let ran = ctx + .handler .call(message::RunScripts::new(ScriptsGroup::Pre)) .await .unwrap(); + assert_eq!(ran, true); // Wait until the scripts are executed. while let Ok(event) = ctx.events_rx.recv().await { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-files/src/message.rs new/agama/agama-files/src/message.rs --- old/agama/agama-files/src/message.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-files/src/message.rs 2026-02-06 17:21:45.000000000 +0100 @@ -44,6 +44,7 @@ } } +/// Run scripts of the given group. #[derive(Clone)] pub struct RunScripts { pub group: ScriptsGroup, @@ -55,8 +56,9 @@ } } +/// It returns true if any script ran; false otherwise. impl Message for RunScripts { - type Reply = (); + type Reply = bool; } #[derive(Clone)] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-files/src/runner.rs new/agama/agama-files/src/runner.rs --- old/agama/agama-files/src/runner.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-files/src/runner.rs 2026-02-06 17:21:45.000000000 +0100 @@ -52,11 +52,12 @@ /// Implements the logic to run a script. /// -/// It takes care of running the script, reporting errors (and asking whether to retry) and write +/// It takes care of running the script, reporting errors (and asking whether to retry) and writing /// the logs. pub struct ScriptsRunner { progress: Handler<progress::Service>, questions: Handler<question::Service>, + root_dir: PathBuf, install_dir: PathBuf, workdir: PathBuf, } @@ -64,12 +65,14 @@ impl ScriptsRunner { /// Creates a new runner. /// + /// * `root_dir`: root directory of the system. /// * `install_dir`: directory where the system is being installed. It is relevant for /// chrooted scripts. /// * `workdir`: scripts work directory. /// * `progress`: handler to report the progress. /// * `questions`: handler to interact with the user. pub fn new<P: AsRef<Path>>( + root_dir: P, install_dir: P, workdir: P, progress: Handler<progress::Service>, @@ -78,6 +81,7 @@ Self { progress, questions, + root_dir: root_dir.as_ref().to_path_buf(), install_dir: install_dir.as_ref().to_path_buf(), workdir: workdir.as_ref().to_path_buf(), } @@ -90,7 +94,8 @@ /// /// * `scripts`: scripts to run. pub async fn run(&self, scripts: &[&Script]) -> Result<(), Error> { - self.start_progress(scripts); + let scripts: Vec<_> = self.find_scripts_to_run(&scripts); + self.start_progress(&scripts); let mut resolv_linked = false; if scripts.iter().any(|s| s.chroot()) { @@ -118,12 +123,9 @@ /// /// If the script fails, it asks the user whether it should try again. async fn run_script(&self, script: &Script) -> Result<(), Error> { - loop { - let path = self - .workdir - .join(script.group().to_string()) - .join(script.name()); + let path = self.workdir.join(script.relative_script_path()); + loop { let Err(error) = self.run_command(&path, script.chroot()).await else { return Ok(()); }; @@ -211,6 +213,23 @@ _ = self.progress.cast(progress_action); } + /// Returns the scripts to run from the given collection + /// + /// It exclues any script that already ran. + fn find_scripts_to_run<'a>(&self, scripts: &[&'a Script]) -> Vec<&'a Script> { + scripts + .into_iter() + .filter(|s| { + let stdout_file = self + .workdir + .join(s.relative_script_path()) + .with_extension("stdout"); + !std::fs::exists(stdout_file).unwrap_or(false) + }) + .cloned() + .collect() + } + /// Reads the last n bytes of the file and returns them as a string. fn read_n_last_bytes(path: &Path, n_bytes: u64) -> io::Result<String> { let mut file = File::open(path)?; @@ -228,7 +247,7 @@ /// /// It returns false if the resolv.conf was already linked and no action was required. fn link_resolv(&self) -> Result<bool, std::io::Error> { - let original = self.install_dir.join(NM_RESOLV_CONF_PATH); + let original = self.root_dir.join(NM_RESOLV_CONF_PATH); let link = self.resolv_link_path(); if fs::exists(&link)? || !fs::exists(&original)? { @@ -291,7 +310,7 @@ let (events_tx, events_rx) = broadcast::channel::<Event>(16); let install_dir = tmp_dir.path().join("mnt"); - let workdir = tmp_dir.path().join("scripts"); + let workdir = tmp_dir.path().to_path_buf(); let questions = question::start(events_tx.clone()).await.unwrap(); let progress = progress::Service::starter(events_tx.clone()).start(); @@ -310,13 +329,18 @@ impl Context { pub fn runner(&self) -> ScriptsRunner { ScriptsRunner::new( - self.install_dir.clone(), self.workdir.clone(), + self.install_dir.clone(), + self.scripts_dir(), self.progress.clone(), self.questions.clone(), ) } + pub fn scripts_dir(&self) -> PathBuf { + self.workdir.join("run/agama/scripts") + } + pub fn setup_script(&self, content: &str, chroot: bool) -> Script { let base = BaseScript { name: "test.sh".to_string(), @@ -329,14 +353,14 @@ chroot: Some(chroot), }); script - .write(&self.workdir) + .write(&self.scripts_dir()) .expect("Could not write the script"); script } // Set up a fake chroot. pub fn setup_chroot(&self) -> std::io::Result<()> { - let nm_dir = self.install_dir.join("run/NetworkManager"); + let nm_dir = self.workdir.join("run/NetworkManager"); fs::create_dir_all(&nm_dir)?; fs::create_dir_all(self.install_dir.join("etc"))?; @@ -348,7 +372,7 @@ // Return the content of a script result file. pub fn result_content(&self, script_type: &str, name: &str) -> String { - let path = &self.workdir.join(script_type).join(name); + let path = &self.scripts_dir().join(script_type).join(name); let body: Vec<u8> = std::fs::read(path).unwrap(); String::from_utf8(body).unwrap() } @@ -448,7 +472,7 @@ }); // Check the generated files - let path = &ctx.workdir.join("post").join("test.stderr"); + let path = &ctx.scripts_dir().join("post").join("test.stderr"); let body: Vec<u8> = std::fs::read(path).unwrap(); let body = String::from_utf8(body).unwrap(); assert!(body.contains("agama-unknown")); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-files/src/service.rs new/agama/agama-files/src/service.rs --- old/agama/agama-files/src/service.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-files/src/service.rs 2026-02-06 17:21:45.000000000 +0100 @@ -27,12 +27,13 @@ use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::files::{ - scripts::{self, ScriptsRepository}, + scripts::{self, ScriptsGroup, ScriptsRepository}, user_file, ScriptsConfig, UserFile, }, progress, question, }; use async_trait::async_trait; +use strum::IntoEnumIterator; use tokio::sync::Mutex; use crate::{message, ScriptsRunner}; @@ -49,14 +50,15 @@ Actor(#[from] actor::Error), } -const DEFAULT_SCRIPTS_DIR: &str = "/run/agama/scripts"; +const DEFAULT_SCRIPTS_DIR: &str = "run/agama/scripts"; +const DEFAULT_WORK_DIR: &str = "/"; const DEFAULT_INSTALL_DIR: &str = "/mnt"; /// Builds and spawns the files service. /// /// This structs allows to build a files service. pub struct Starter { - scripts_workdir: PathBuf, + workdir: PathBuf, install_dir: PathBuf, software: Handler<software::Service>, progress: Handler<progress::Service>, @@ -76,14 +78,14 @@ software, progress, questions, - scripts_workdir: PathBuf::from(DEFAULT_SCRIPTS_DIR), + workdir: PathBuf::from(DEFAULT_WORK_DIR), install_dir: PathBuf::from(DEFAULT_INSTALL_DIR), } } /// Starts the service and returns the handler to communicate with it. pub async fn start(self) -> Result<Handler<Service>, Error> { - let scripts = ScriptsRepository::new(self.scripts_workdir); + let scripts = ScriptsRepository::new(self.workdir.join(DEFAULT_SCRIPTS_DIR)); let service = Service { progress: self.progress, questions: self.questions, @@ -91,13 +93,14 @@ scripts: Arc::new(Mutex::new(scripts)), files: vec![], install_dir: self.install_dir, + root_dir: self.workdir, }; let handler = actor::spawn(service); Ok(handler) } - pub fn with_scripts_workdir<P: AsRef<Path>>(mut self, workdir: P) -> Self { - self.scripts_workdir = PathBuf::from(workdir.as_ref()); + pub fn with_workdir<P: AsRef<Path>>(mut self, workdir: P) -> Self { + self.workdir = PathBuf::from(workdir.as_ref()); self } @@ -114,6 +117,7 @@ scripts: Arc<Mutex<ScriptsRepository>>, files: Vec<UserFile>, install_dir: PathBuf, + root_dir: PathBuf, } impl Service { @@ -125,9 +129,15 @@ Starter::new(progress, questions, software) } + /// Clear the scripts. + /// + /// Keep the pre-scripts because they are expected to run as soon as they are imported. pub async fn clear_scripts(&mut self) -> Result<(), Error> { let mut repo = self.scripts.lock().await; - repo.clear()?; + let groups: Vec<_> = ScriptsGroup::iter() + .filter(|g| g != &ScriptsGroup::Pre) + .collect(); + repo.clear(groups.as_slice())?; Ok(()) } @@ -195,20 +205,26 @@ #[async_trait] impl MessageHandler<message::RunScripts> for Service { - async fn handle(&mut self, message: message::RunScripts) -> Result<(), Error> { - let scripts = self.scripts.clone(); - let install_dir = self.install_dir.clone(); - let progress = self.progress.clone(); - let questions = self.questions.clone(); - - tokio::task::spawn(async move { - let scripts = scripts.lock().await; - let workdir = scripts.workdir.clone(); - let to_run = scripts.by_group(message.group).clone(); - let runner = ScriptsRunner::new(install_dir, workdir, progress, questions); - runner.run(&to_run).await.unwrap(); - }); - Ok(()) + async fn handle(&mut self, message: message::RunScripts) -> Result<bool, Error> { + let scripts = self.scripts.lock().await; + let workdir = scripts.workdir.clone(); + let to_run = scripts.by_group(message.group).clone(); + + if to_run.is_empty() { + return Ok(false); + } else { + let runner = ScriptsRunner::new( + &self.root_dir, + &self.install_dir, + &workdir, + self.progress.clone(), + self.questions.clone(), + ); + if let Err(error) = runner.run(&to_run).await { + tracing::error!("Error running scripts: {error}"); + } + Ok(true) + } } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-l10n/src/model/keyboard.rs new/agama/agama-l10n/src/model/keyboard.rs --- old/agama/agama-l10n/src/model/keyboard.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-l10n/src/model/keyboard.rs 2026-02-06 17:21:45.000000000 +0100 @@ -40,14 +40,14 @@ } pub fn with_entries(data: &[Keymap]) -> Self { - Self { - keymaps: data.to_vec(), - } + let mut database = Self::new(); + database.set_entries(data.to_vec()); + database } /// Reads the list of keymaps. pub fn read(&mut self) -> anyhow::Result<()> { - self.keymaps = get_keymaps()?; + self.set_entries(get_keymaps()?); Ok(()) } @@ -59,6 +59,33 @@ pub fn entries(&self) -> &Vec<Keymap> { &self.keymaps } + + // Set the locales entries. + fn set_entries(&mut self, keymaps: Vec<Keymap>) { + self.keymaps = keymaps; + self.keymaps.sort(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sorting_keymaps() { + let entries = vec![ + Keymap::new("es".parse().unwrap(), "Spanish"), + Keymap::new("us".parse().unwrap(), "English (US)"), + Keymap::new("de".parse().unwrap(), "German"), + ]; + + let db = KeymapsDatabase::with_entries(&entries); + let keymaps = db.entries(); + + assert_eq!(keymaps[0].description.to_string(), "English (US)"); + assert_eq!(keymaps[1].description.to_string(), "German"); + assert_eq!(keymaps[2].description.to_string(), "Spanish"); + } } /// Returns the list of keymaps to offer. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-l10n/src/model/locale.rs new/agama/agama-l10n/src/model/locale.rs --- old/agama/agama-l10n/src/model/locale.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-l10n/src/model/locale.rs 2026-02-06 17:21:45.000000000 +0100 @@ -41,10 +41,10 @@ } pub fn with_entries(data: &[LocaleEntry]) -> Self { - Self { - known_locales: data.iter().map(|l| l.id.clone()).collect(), - locales: data.to_vec(), - } + let mut database = Self::new(); + database.set_entries(data.to_vec()); + database.known_locales = database.locales.iter().map(|l| l.id.clone()).collect(); + database } /// Loads the list of locales. @@ -55,7 +55,7 @@ /// * `ui_language`: language to translate the descriptions (e.g., "en"). pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { self.known_locales = Self::get_locales_list()?; - self.locales = self.get_locales(ui_language)?; + self.set_entries(self.get_locales(ui_language)?); Ok(()) } @@ -125,6 +125,12 @@ Ok(result) } + // Set the locales entries. + fn set_entries(&mut self, locales: Vec<LocaleEntry>) { + self.locales = locales; + self.locales.sort(); + } + fn get_locales_list() -> anyhow::Result<Vec<LocaleId>> { const LOCALES_LIST_PATH: &str = "/etc/agama.d/locales"; @@ -198,4 +204,45 @@ assert!(db.exists(&en_us)); assert!(!db.exists(&unknown)); } + + #[test] + fn test_sorting_locales() { + use agama_utils::api::l10n::LocaleEntry; + let entries = vec![ + LocaleEntry { + id: "es_ES".parse().unwrap(), + language: "Spanish".to_string(), + territory: "Spain".to_string(), + consolefont: None, + }, + LocaleEntry { + id: "en_GB".parse().unwrap(), + language: "English".to_string(), + territory: "United Kingdom".to_string(), + consolefont: None, + }, + LocaleEntry { + id: "en_US".parse().unwrap(), + language: "English".to_string(), + territory: "United States".to_string(), + consolefont: None, + }, + LocaleEntry { + id: "de_DE".parse().unwrap(), + language: "German".to_string(), + territory: "Germany".to_string(), + consolefont: None, + }, + ]; + + let db = LocalesDatabase::with_entries(&entries); + let locales = db.entries(); + + assert_eq!(locales[0].language, "English"); + assert_eq!(locales[0].territory, "United Kingdom"); + assert_eq!(locales[1].language, "English"); + assert_eq!(locales[1].territory, "United States"); + assert_eq!(locales[2].language, "German"); + assert_eq!(locales[3].language, "Spanish"); + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-l10n/src/model/timezone.rs new/agama/agama-l10n/src/model/timezone.rs --- old/agama/agama-l10n/src/model/timezone.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-l10n/src/model/timezone.rs 2026-02-06 17:21:45.000000000 +0100 @@ -35,16 +35,16 @@ } pub fn with_entries(data: &[TimezoneEntry]) -> Self { - Self { - timezones: data.to_vec(), - } + let mut database = Self::new(); + database.set_entries(data.to_vec()); + database } /// Initializes the list of known timezones. /// /// * `ui_language`: language to translate the descriptions (e.g., "en"). pub fn read(&mut self, ui_language: &str) -> anyhow::Result<()> { - self.timezones = self.get_timezones(ui_language)?; + self.set_entries(self.get_timezones(ui_language)?); Ok(()) } @@ -91,6 +91,12 @@ Ok(ret) } + + // Set the locales entries. + fn set_entries(&mut self, timezones: Vec<TimezoneEntry>) { + self.timezones = timezones; + self.timezones.sort(); + } } fn translate_parts(timezone: &str, ui_language: &str, tz_parts: &TimezoneIdParts) -> Vec<String> { @@ -124,6 +130,7 @@ #[cfg(test)] mod tests { use super::TimezonesDatabase; + use agama_utils::api::l10n::TimezoneEntry; #[test] fn test_read_timezones() { @@ -175,4 +182,32 @@ assert!(db.exists(&canary)); assert!(!db.exists(&unknown)); } + + #[test] + fn test_sorting_timezones() { + let entries = vec![ + TimezoneEntry { + id: "Europe/Madrid".parse().unwrap(), + parts: vec!["Europe".to_string(), "Madrid".to_string()], + country: Some("Spain".to_string()), + }, + TimezoneEntry { + id: "Atlantic/Canary".parse().unwrap(), + parts: vec!["Atlantic".to_string(), "Canary".to_string()], + country: Some("Spain".to_string()), + }, + TimezoneEntry { + id: "Europe/Berlin".parse().unwrap(), + parts: vec!["Europe".to_string(), "Berlin".to_string()], + country: Some("Germany".to_string()), + }, + ]; + + let db = TimezonesDatabase::with_entries(&entries); + let timezones = db.entries(); + + assert_eq!(timezones[0].id.as_str(), "Atlantic/Canary"); + assert_eq!(timezones[1].id.as_str(), "Europe/Berlin"); + assert_eq!(timezones[2].id.as_str(), "Europe/Madrid"); + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-locale-data/src/locale.rs new/agama/agama-locale-data/src/locale.rs --- old/agama/agama-locale-data/src/locale.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-locale-data/src/locale.rs 2026-02-06 17:21:45.000000000 +0100 @@ -26,7 +26,7 @@ use std::{fmt::Display, str::FromStr}; use thiserror::Error; -#[derive(Debug, Clone, Serialize, PartialEq, utoipa::ToSchema)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord, utoipa::ToSchema)] pub struct TimezoneId(String); impl Default for TimezoneId { @@ -60,7 +60,9 @@ } } -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, utoipa::ToSchema, +)] pub struct LocaleId { // ISO-639 pub language: String, @@ -134,7 +136,9 @@ /// let id_with_dashes: KeymapId = "es-ast".parse().unwrap(); /// assert_eq!(id, id_with_dashes); /// ``` -#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[derive( + Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, utoipa::ToSchema, +)] pub struct KeymapId { /// Keyboard layout (e.g., "es" in "es(ast)") pub layout: String, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-manager/src/lib.rs new/agama/agama-manager/src/lib.rs --- old/agama/agama-manager/src/lib.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-manager/src/lib.rs 2026-02-06 17:21:45.000000000 +0100 @@ -137,25 +137,6 @@ #[test_context(Context)] #[tokio::test] - async fn test_update_config_without_product(ctx: &mut Context) { - let input_config = Config { - l10n: Some(l10n::Config { - locale: Some("es_ES.UTF-8".to_string()), - keymap: Some("es".to_string()), - timezone: Some("Atlantic/Canary".to_string()), - }), - ..Default::default() - }; - - let error = ctx - .handler - .call(message::SetConfig::new(input_config.clone())) - .await; - assert!(matches!(error, Err(crate::service::Error::MissingProduct))); - } - - #[test_context(Context)] - #[tokio::test] async fn test_patch_config(ctx: &mut Context) -> Result<(), Error> { select_product(&ctx.handler).await?; @@ -184,28 +165,4 @@ Ok(()) } - - #[test_context(Context)] - #[tokio::test] - async fn test_patch_config_without_product(ctx: &mut Context) -> Result<(), Error> { - let input_config = Config { - l10n: Some(l10n::Config { - keymap: Some("es".to_string()), - ..Default::default() - }), - ..Default::default() - }; - - let result = ctx - .handler - .call(message::UpdateConfig::new(input_config.clone())) - .await; - assert!(matches!(result, Err(crate::service::Error::MissingProduct))); - - let extended_config = ctx.handler.call(message::GetExtendedConfig).await?; - let l10n_config = extended_config.l10n.unwrap(); - assert_eq!(l10n_config.keymap, Some("us".to_string())); - - Ok(()) - } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-manager/src/service.rs new/agama/agama-manager/src/service.rs --- old/agama/agama-manager/src/service.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-manager/src/service.rs 2026-02-06 17:21:45.000000000 +0100 @@ -45,8 +45,6 @@ #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("Missing product")] - MissingProduct, #[error(transparent)] Event(#[from] broadcast::error::SendError<Event>), #[error(transparent)] @@ -434,73 +432,27 @@ } async fn set_config(&mut self, config: Config) -> Result<(), Error> { + tracing::debug!("SetConfig: {config:?}"); self.set_product(&config)?; + self.config = config; - let Some(product) = &self.product else { - return Err(Error::MissingProduct); + let action = SetConfigAction { + bootloader: self.bootloader.clone(), + files: self.files.clone(), + hostname: self.hostname.clone(), + iscsi: self.iscsi.clone(), + l10n: self.l10n.clone(), + network: self.network.clone(), + proxy: self.proxy.clone(), + questions: self.questions.clone(), + security: self.security.clone(), + software: self.software.clone(), + storage: self.storage.clone(), + users: self.users.clone(), }; - self.security - .call(security::message::SetConfig::new(config.security.clone())) - .await?; - - self.hostname - .call(hostname::message::SetConfig::new(config.hostname.clone())) - .await?; - - self.proxy - .call(proxy::message::SetConfig::new(config.proxy.clone())) - .await?; + action.run(self.product.clone(), &self.config).await; - self.files - .call(files::message::SetConfig::new(config.files.clone())) - .await?; - - self.files - .call(files::message::RunScripts::new(ScriptsGroup::Pre)) - .await?; - - self.questions - .call(question::message::SetConfig::new(config.questions.clone())) - .await?; - - self.software - .call(software::message::SetConfig::new( - Arc::clone(product), - config.software.clone(), - )) - .await?; - - self.l10n - .call(l10n::message::SetConfig::new(config.l10n.clone())) - .await?; - - self.users - .call(users::message::SetConfig::new(config.users.clone())) - .await?; - - self.iscsi - .call(iscsi::message::SetConfig::new(config.iscsi.clone())) - .await?; - - self.storage.cast(storage::message::SetConfig::new( - Arc::clone(product), - config.storage.clone(), - ))?; - - // call bootloader always after storage to ensure that bootloader reflect new storage settings - self.bootloader - .call(bootloader::message::SetConfig::new( - config.bootloader.clone(), - )) - .await?; - - if let Some(network) = config.network.clone() { - self.network.update_config(network).await?; - self.network.apply().await?; - } - - self.config = config; Ok(()) } @@ -1037,3 +989,113 @@ } } } + +/// Implements the set config logic. +/// +/// This action runs on a separate Tokio task to prevent the manager from blocking. +struct SetConfigAction { + bootloader: Handler<bootloader::Service>, + files: Handler<files::Service>, + hostname: Handler<hostname::Service>, + iscsi: Handler<iscsi::Service>, + l10n: Handler<l10n::Service>, + network: NetworkSystemClient, + proxy: Handler<proxy::Service>, + questions: Handler<question::Service>, + security: Handler<security::Service>, + software: Handler<software::Service>, + storage: Handler<storage::Service>, + users: Handler<users::Service>, +} + +impl SetConfigAction { + pub async fn run(self, product: Option<Arc<RwLock<ProductSpec>>>, config: &Config) { + let config = config.clone(); + tokio::spawn(async move { + tracing::info!("Updating the configuration"); + match self.set_config(product, config).await { + Ok(_) => tracing::info!("Configuration updated successfully"), + Err(error) => tracing::error!("Failed to update the configuration: {error}"), + } + }); + } + + async fn set_config( + self, + product: Option<Arc<RwLock<ProductSpec>>>, + config: Config, + ) -> Result<(), Error> { + self.security + .call(security::message::SetConfig::new(config.security.clone())) + .await?; + + self.hostname + .call(hostname::message::SetConfig::new(config.hostname.clone())) + .await?; + + self.proxy + .call(proxy::message::SetConfig::new(config.proxy.clone())) + .await?; + + self.files + .call(files::message::SetConfig::new(config.files.clone())) + .await?; + + self.files + .call(files::message::RunScripts::new(ScriptsGroup::Pre)) + .await?; + + self.questions + .call(question::message::SetConfig::new(config.questions.clone())) + .await?; + + self.l10n + .call(l10n::message::SetConfig::new(config.l10n.clone())) + .await?; + + self.users + .call(users::message::SetConfig::new(config.users.clone())) + .await?; + + self.iscsi + .call(iscsi::message::SetConfig::new(config.iscsi.clone())) + .await?; + + if let Some(network) = config.network.clone() { + self.network.update_config(network).await?; + self.network.apply().await?; + } + + match &product { + Some(product) => { + self.software + .call(software::message::SetConfig::new( + Arc::clone(product), + config.software.clone(), + )) + .await?; + + self.storage + .call(storage::message::SetConfig::new( + Arc::clone(product), + config.storage.clone(), + )) + .await?; + + // call bootloader always after storage to ensure that bootloader reflect new storage settings + self.bootloader + .call(bootloader::message::SetConfig::new( + config.bootloader.clone(), + )) + .await?; + } + + None => { + // TODO: reset software and storage proposals. + tracing::info!("No product is selected."); + } + } + + Ok(()) + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-server/tests/server_service.rs new/agama/agama-server/tests/server_service.rs --- old/agama/agama-server/tests/server_service.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-server/tests/server_service.rs 2026-02-06 17:21:45.000000000 +0100 @@ -152,30 +152,6 @@ #[test_context(Context)] #[test] -async fn test_put_config_without_product(ctx: &mut Context) -> Result<(), Box<dyn Error>> { - let json = r#" - { - "l10n": { - "locale": "es_ES.UTF-8", "keymap": "es", "timezone": "Atlantic/Canary" - } - } - "#; - - let request = Request::builder() - .uri("/config") - .header("Content-Type", "application/json") - .method(Method::PUT) - .body(json.to_string()) - .unwrap(); - - let response = ctx.client.send_request(request).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - Ok(()) -} - -#[test_context(Context)] -#[test] async fn test_put_config_without_mode(ctx: &mut Context) -> Result<(), Box<dyn Error>> { let json = r#" { @@ -274,28 +250,6 @@ Ok(()) } - -#[test_context(Context)] -#[test] -async fn test_patch_config_without_selected_product( - ctx: &mut Context, -) -> Result<(), Box<dyn Error>> { - let json = r#"{ "update": { "l10n": { "keymap": "en" } } }"#; - let request = Request::builder() - .uri("/config") - .header("Content-Type", "application/json") - .method(Method::PATCH) - .body(json.to_string()) - .unwrap(); - - let response = ctx.client.send_request(request).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - let body = body_to_string(response.into_body()).await; - assert_eq!(body, r#"{"error":"Missing product"}"#); - - Ok(()) -} #[test_context(Context)] #[test] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-software/src/model.rs new/agama/agama-software/src/model.rs --- old/agama/agama-software/src/model.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-software/src/model.rs 2026-02-06 17:21:45.000000000 +0100 @@ -149,10 +149,21 @@ .iter() .map(|r| r.url.as_str()) .collect(); + let mut missing_predefined_repos = self.predefined_repositories.clone(); + for repo in system_info.repositories.iter_mut() { repo.predefined = predefined_urls.contains(&repo.url.as_str()); + if repo.predefined { + missing_predefined_repos.retain(|r| r.url != repo.url); + } } + // Add all predefined repositories that are missing in libzypp. + // As a result, they will be added to libzypp when writing the new + // software state. + system_info.repositories.extend(missing_predefined_repos); + tracing::info!("System info: {:?}", system_info); + Ok(system_info) } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-software/src/service.rs new/agama/agama-software/src/service.rs --- old/agama/agama-software/src/service.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-software/src/service.rs 2026-02-06 17:21:45.000000000 +0100 @@ -459,6 +459,8 @@ repos.push(dud) } + tracing::info!("Using mandatory repositories: {:?}", repos); + repos } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-utils/src/api/files/scripts.rs new/agama/agama-utils/src/api/files/scripts.rs --- old/agama/agama-utils/src/api/files/scripts.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-utils/src/api/files/scripts.rs 2026-02-06 17:21:45.000000000 +0100 @@ -132,6 +132,14 @@ self.base().write(&path) } + /// Returns the relative script path. + /// + /// The full script path depends on the workdir. This method returns + /// the relative path (e.g., "pre/my-script.sh"). + pub fn relative_script_path(&self) -> PathBuf { + PathBuf::from(self.group().to_string()).join(&self.base().name) + } + /// Script's group. /// /// It determines whether the script runs. @@ -289,8 +297,10 @@ } /// Removes all the scripts from the repository. - pub fn clear(&mut self) -> Result<(), Error> { - for group in ScriptsGroup::iter() { + /// + /// * `groups`: groups of scripts to clear. + pub fn clear(&mut self, groups: &[ScriptsGroup]) -> Result<(), Error> { + for group in ScriptsGroup::iter().filter(|g| groups.contains(&g)) { let path = self.workdir.join(group.to_string()); if path.exists() { std::fs::remove_dir_all(path)?; @@ -319,14 +329,18 @@ #[cfg(test)] mod test { + use std::path::PathBuf; + use tempfile::TempDir; - use tokio::test; - use crate::api::files::{BaseScript, FileSource, PreScript, Script}; + use crate::api::files::{ + scripts::ScriptsGroup, BaseScript, FileSource, InitScript, PostPartitioningScript, + PostScript, PreScript, Script, + }; use super::ScriptsRepository; - #[test] + #[tokio::test] async fn test_add_script() { let tmp_dir = TempDir::with_prefix("scripts-").expect("a temporary directory"); let mut repo = ScriptsRepository::new(&tmp_dir); @@ -347,7 +361,7 @@ assert!(script_path.exists()); } - #[test] + #[tokio::test] async fn test_clear_scripts() { let tmp_dir = TempDir::with_prefix("scripts-").expect("a temporary directory"); let mut repo = ScriptsRepository::new(&tmp_dir); @@ -365,10 +379,34 @@ let script_path = tmp_dir.path().join("pre").join("test"); assert!(script_path.exists()); - _ = repo.clear(); + _ = repo.clear(&[ScriptsGroup::Pre]); assert!(!script_path.exists()); // the directory for AutoYaST scripts is not removed assert!(autoyast_path.exists()) } + + #[test] + fn test_relative_script_path() { + let base = BaseScript { + name: "test".to_string(), + source: FileSource::Text { + content: "".to_string(), + }, + }; + let script = Script::Pre(PreScript { base: base.clone() }); + assert_eq!(script.relative_script_path(), PathBuf::from("pre/test")); + let script = Script::PostPartitioning(PostPartitioningScript { base: base.clone() }); + assert_eq!( + script.relative_script_path(), + PathBuf::from("postPartitioning/test") + ); + let script = Script::Post(PostScript { + base: base.clone(), + chroot: Some(false), + }); + assert_eq!(script.relative_script_path(), PathBuf::from("post/test")); + let script = Script::Init(InitScript { base: base.clone() }); + assert_eq!(script.relative_script_path(), PathBuf::from("init/test")); + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/agama-utils/src/api/l10n/system_info.rs new/agama/agama-utils/src/api/l10n/system_info.rs --- old/agama/agama-utils/src/api/l10n/system_info.rs 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/agama-utils/src/api/l10n/system_info.rs 2026-02-06 17:21:45.000000000 +0100 @@ -47,7 +47,7 @@ /// Represents a locale, including the localized language and territory. #[serde_as] -#[derive(Debug, Serialize, Clone, utoipa::ToSchema)] +#[derive(Debug, Serialize, Clone, utoipa::ToSchema, PartialEq, Eq)] pub struct LocaleEntry { /// The locale code (e.g., "es_ES.UTF-8"). #[serde_as(as = "DisplayFromStr")] @@ -60,8 +60,24 @@ pub consolefont: Option<String>, } +impl Ord for LocaleEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.language + .cmp(&other.language) + .then_with(|| self.territory.cmp(&other.territory)) + .then_with(|| self.id.cmp(&other.id)) + .then_with(|| self.consolefont.cmp(&other.consolefont)) + } +} + +impl PartialOrd for LocaleEntry { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + /// Represents a timezone, including each part as localized. -#[derive(Clone, Debug, Serialize, utoipa::ToSchema)] +#[derive(Clone, Debug, Serialize, utoipa::ToSchema, PartialEq, Eq)] pub struct TimezoneEntry { /// Timezone identifier (e.g. "Atlantic/Canary"). pub id: TimezoneId, @@ -71,13 +87,42 @@ pub country: Option<String>, } +impl Ord for TimezoneEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.parts + .cmp(&other.parts) + .then_with(|| self.country.cmp(&other.country)) + .then_with(|| self.id.cmp(&other.id)) + } +} + +impl PartialOrd for TimezoneEntry { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + // Minimal representation of a keymap -#[derive(Clone, Debug, utoipa::ToSchema)] +#[derive(Clone, Debug, utoipa::ToSchema, PartialEq, Eq)] pub struct Keymap { /// Keymap identifier (e.g., "us") pub id: KeymapId, /// Keymap description - description: String, + pub description: String, +} + +impl Ord for Keymap { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.localized_description() + .cmp(&other.localized_description()) + .then_with(|| self.id.cmp(&other.id)) + } +} + +impl PartialOrd for Keymap { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } } impl Keymap { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/package/agama.changes new/agama/package/agama.changes --- old/agama/package/agama.changes 2026-02-02 14:01:52.000000000 +0100 +++ new/agama/package/agama.changes 2026-02-06 17:21:45.000000000 +0100 @@ -1,4 +1,33 @@ ------------------------------------------------------------------- +Fri Feb 6 13:59:11 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> + +- Fix parsing of the answers file (bsc#1257400). + +------------------------------------------------------------------- +Fri Feb 6 07:38:38 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> + +- Allow loading a profile with no product (bsc#1257067 and bsc#1257082). +- Make the bootloader proposal after the storage one is ready (bsc#1257530). +- Run pre-scripts only once to prevent potential infinite loops. + +------------------------------------------------------------------- +Thu Feb 5 06:46:46 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> + +- Sort the list of languages, keymaps and timezones (bsc#1257497). + +------------------------------------------------------------------- +Wed Feb 4 11:37:18 UTC 2026 - Josef Reidinger <[email protected]> + +- Fix usage of local repositories on full medium + (bsc#1257475) + +------------------------------------------------------------------- +Tue Feb 3 07:23:18 UTC 2026 - Knut Anderssen <[email protected]> + +- Correct the /etc/resolv.conf link in the chroot to + /run/NetworkManager/resolv.conf (bsc#257468). + +------------------------------------------------------------------- Mon Feb 2 11:56:24 UTC 2026 - Ladislav Slezák <[email protected]> - Fixed opening the SSH firewall port when the firewall is not @@ -28,7 +57,7 @@ Thu Jan 29 22:30:05 UTC 2026 - Imobach Gonzalez Sosa <[email protected]> - Do not export neither network/connections nor network/state when - they are None (related to bsc#1257400). + they are None (related to bsc#1257230). ------------------------------------------------------------------- Thu Jan 29 21:10:18 UTC 2026 - Josef Reidinger <[email protected]> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/agama/share/system.dasd.schema.json new/agama/share/system.dasd.schema.json --- old/agama/share/system.dasd.schema.json 1970-01-01 01:00:00.000000000 +0100 +++ new/agama/share/system.dasd.schema.json 2026-02-06 17:21:45.000000000 +0100 @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://github.com/openSUSE/agama/blob/master/rust/share/system.storage.schema.json", + "title": "System", + "description": "API description of the DASD system", + "type": "object", + "additionalProperties": false, + "required": ["devices"], + "properties": { + "devices": { + "description": "DASD devices", + "type": "array", + "items": { "$ref": "#/$defs/device" } + } + }, + "$defs": { + "device": { + "type": "object", + "additionalProperties": false, + "required": [ + "channel", + "deviceName", + "type", + "diag", + "accessType", + "partitionInfo", + "status", + "active", + "formatted" + ], + "properties": { + "channel": { "type": "string" }, + "deviceName": { "type": "string" }, + "type": { "type": "string" }, + "diag": { "type": "boolean" }, + "accessType": { "type": "string" }, + "partitionInfo": { "type": "string" }, + "status": { "type": "string" }, + "active": { "type": "boolean" }, + "formatted": { "type": "boolean" } + } + } + } +} ++++++ agama.obsinfo ++++++ --- /var/tmp/diff_new_pack.fjXUH2/_old 2026-02-07 15:33:56.358651414 +0100 +++ /var/tmp/diff_new_pack.fjXUH2/_new 2026-02-07 15:33:56.374652079 +0100 @@ -1,5 +1,5 @@ name: agama -version: 19.pre+1359.77cf8dc51 -mtime: 1770037312 -commit: 77cf8dc51086f7e25c669ae83399e40e5c1e27e5 +version: 19.pre+1415.80da57854 +mtime: 1770394905 +commit: 80da578541862b51b08a290145b25597faed02f3 ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/agama/vendor.tar.zst /work/SRC/openSUSE:Factory/.agama.new.1670/vendor.tar.zst differ: char 7, line 1
