diff --git a/rust/agama-autoinstall/src/main.rs b/rust/agama-autoinstall/src/main.rs index 348301873b..def378c937 100644 --- a/rust/agama-autoinstall/src/main.rs +++ b/rust/agama-autoinstall/src/main.rs @@ -22,7 +22,10 @@ use std::{process::exit, time::Duration}; use agama_autoinstall::{ConfigAutoLoader, ScriptsRunner}; use agama_lib::{auth::AuthToken, http::BaseHTTPClient, manager::ManagerHTTPClient}; -use agama_utils::{api::status::Stage, kernel_cmdline::KernelCmdline}; +use agama_utils::{ + api::{status::Stage, FinishMethod}, + kernel_cmdline::KernelCmdline, +}; use anyhow::anyhow; use tokio::time::sleep; @@ -93,7 +96,8 @@ async fn main() -> anyhow::Result<()> { } } - manager_client.finish(None).await?; + let method = FinishMethod::from_kernel_cmdline().unwrap_or(FinishMethod::Reboot); + manager_client.finish(method).await?; Ok(()) } diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index 9f1272f1b9..d4987d8cbc 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -147,6 +147,8 @@ async fn finish( ) -> anyhow::Result<()> { wait_until_idle(monitor.clone()).await?; + let method = method + .unwrap_or_else(|| FinishMethod::from_kernel_cmdline().unwrap_or(FinishMethod::Reboot)); manager.finish(method).await?; Ok(()) } diff --git a/rust/agama-lib/src/manager/http_client.rs b/rust/agama-lib/src/manager/http_client.rs index a14d1426b2..5111854e2a 100644 --- a/rust/agama-lib/src/manager/http_client.rs +++ b/rust/agama-lib/src/manager/http_client.rs @@ -67,7 +67,7 @@ impl ManagerHTTPClient { /// Finishes the installation. /// /// * `method`: halt, reboot, stop or poweroff the system. - pub async fn finish(&self, method: Option) -> Result<(), ManagerHTTPClientError> { + pub async fn finish(&self, method: FinishMethod) -> Result<(), ManagerHTTPClientError> { let action = api::Action::Finish(method); self.client.post_void("/v2/action", &action).await?; Ok(()) diff --git a/rust/agama-manager/src/actions.rs b/rust/agama-manager/src/actions.rs new file mode 100644 index 0000000000..86c55b232c --- /dev/null +++ b/rust/agama-manager/src/actions.rs @@ -0,0 +1,385 @@ +// Copyright (c) [2025-2026] 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 std::{process::Command, sync::Arc}; + +use agama_network::NetworkSystemClient; +use agama_utils::{ + actor::Handler, + api::{files::scripts::ScriptsGroup, status::Stage, Config, FinishMethod, Scope}, + issue, + products::ProductSpec, + progress, question, +}; +use gettextrs::gettext; +use tokio::sync::RwLock; + +use crate::{ + bootloader, checks, files, hostname, iscsi, l10n, proxy, s390, security, service, software, + storage, users, +}; + +/// Implements the installation process. +/// +/// This action runs on a separate Tokio task to prevent the manager from blocking. +pub struct InstallAction { + pub hostname: Handler, + pub issues: Handler, + pub l10n: Handler, + pub network: NetworkSystemClient, + pub proxy: Handler, + pub software: Handler, + pub storage: Handler, + pub files: Handler, + pub progress: Handler, + pub users: Handler, +} + +impl InstallAction { + /// Runs the installation process on a separate Tokio task. + pub async fn run(mut self) -> Result<(), service::Error> { + checks::check_stage(&self.progress, Stage::Configuring).await?; + checks::check_issues(&self.issues).await?; + checks::check_progress(&self.progress).await?; + + tracing::info!("Installation started"); + if let Err(error) = self.install().await { + tracing::error!("Installation failed: {error}"); + self.set_stage(Stage::Failed).await; + return Err(error); + } + + tracing::info!("Installation finished"); + Ok(()) + } + + async fn install(&mut self) -> Result<(), service::Error> { + // NOTE: consider a NextState message? + self.progress + .call(progress::message::SetStage::new(Stage::Installing)) + .await?; + + // + // Preparation + // + self.progress + .call(progress::message::StartWithSteps::new( + Scope::Manager, + vec![ + gettext("Prepare the system"), + gettext("Install software"), + gettext("Configure the system"), + ], + )) + .await?; + + self.storage.call(storage::message::Install).await?; + self.files + .call(files::message::RunScripts::new( + ScriptsGroup::PostPartitioning, + )) + .await?; + + // + // Installation + // + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.software.call(software::message::Install).await?; + + // + // Configuration + // + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.l10n.call(l10n::message::Install).await?; + self.software.call(software::message::Finish).await?; + self.files.call(files::message::WriteFiles).await?; + self.network.install().await?; + self.proxy.call(proxy::message::Finish).await?; + self.hostname.call(hostname::message::Install).await?; + self.users.call(users::message::Install).await?; + self.storage.call(storage::message::Finish).await?; + + // call files before storage finish as it unmounts /mnt/run which is important for chrooted scripts + self.files + .call(files::message::RunScripts::new(ScriptsGroup::Post)) + .await?; + + self.storage.call(storage::message::Umount).await?; + + // + // Finish progress and changes + // + self.progress + .call(progress::message::Finish::new(Scope::Manager)) + .await?; + + self.progress + .call(progress::message::SetStage::new(Stage::Finished)) + .await?; + Ok(()) + } + + async fn set_stage(&self, stage: Stage) { + if let Err(error) = self + .progress + .call(progress::message::SetStage::new(stage)) + .await + { + tracing::error!("It was not possible to set the stage to {}: {error}", stage); + } + } +} + +/// Implements the set config logic. +/// +/// This action runs on a separate Tokio task to prevent the manager from blocking. +pub struct SetConfigAction { + pub bootloader: Handler, + pub files: Handler, + pub hostname: Handler, + pub iscsi: Handler, + pub l10n: Handler, + pub network: NetworkSystemClient, + pub proxy: Handler, + pub progress: Handler, + pub questions: Handler, + pub security: Handler, + pub software: Handler, + pub storage: Handler, + pub users: Handler, + pub s390: Option>, +} + +impl SetConfigAction { + pub async fn run( + self, + product: Option>>, + config: Config, + ) -> Result<(), service::Error> { + checks::check_stage(&self.progress, Stage::Configuring).await?; + + // + // Preparation + // + let mut steps = vec![ + gettext("Storing security settings"), + gettext("Setting up the hostname"), + gettext("Setting up the network proxy"), + gettext("Importing user files"), + gettext("Running user pre-installation scripts"), + gettext("Storing questions settings"), + gettext("Storing localization settings"), + gettext("Storing users settings"), + gettext("Configuring iSCSI devices"), + ]; + + if self.s390.is_some() { + steps.push(gettext("Configuring DASD devices")); + } + + if config.network.is_some() { + steps.push(gettext("Setting up the network")); + } + + if product.is_some() { + steps.extend_from_slice(&[ + gettext("Preparing the software proposal"), + gettext("Preparing the storage proposal"), + gettext("Storing bootloader settings"), + ]) + } + + self.progress + .call(progress::message::StartWithSteps::new( + Scope::Manager, + steps, + )) + .await?; + + // + // Set the configuration for each service + // + self.security + .call(security::message::SetConfig::new(config.security.clone())) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.hostname + .call(hostname::message::SetConfig::new(config.hostname.clone())) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.proxy + .call(proxy::message::SetConfig::new(config.proxy.clone())) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.files + .call(files::message::SetConfig::new(config.files.clone())) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.files + .call(files::message::RunScripts::new(ScriptsGroup::Pre)) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.questions + .call(question::message::SetConfig::new(config.questions.clone())) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.l10n + .call(l10n::message::SetConfig::new(config.l10n.clone())) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.users + .call(users::message::SetConfig::new(config.users.clone())) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.iscsi + .call(iscsi::message::SetConfig::new(config.iscsi.clone())) + .await?; + + if let Some(s390) = &self.s390 { + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + s390.call(s390::message::SetConfig::new(config.s390.clone())) + .await?; + } + + if let Some(network) = config.network.clone() { + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.network.update_config(network).await?; + self.network.apply().await?; + } + + match &product { + Some(product) => { + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + self.software + .call(software::message::SetConfig::new( + Arc::clone(product), + config.software.clone(), + )) + .await?; + + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + let future = self + .storage + .call(storage::message::SetConfig::new( + Arc::clone(product), + config.storage.clone(), + )) + .await?; + let _ = future.await; + + // call bootloader always after storage to ensure that bootloader reflect new storage settings + self.progress + .call(progress::message::Next::new(Scope::Manager)) + .await?; + 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(()) + } +} + +/// Implements the finish action. +/// +/// If no FinishMethod is given, it defaults to "Stop" (which basically menans to do nothing). +pub struct FinishAction { + method: FinishMethod, +} + +impl FinishAction { + pub fn new(method: FinishMethod) -> Self { + Self { method } + } + + pub fn run(self) { + tracing::info!("Finishing the installation process ({})", self.method); + + let option = match self.method { + FinishMethod::Halt => Some("-H"), + FinishMethod::Reboot => Some("-r"), + FinishMethod::Poweroff => Some("-P"), + FinishMethod::Stop => None, + }; + let mut command = Command::new("shutdown"); + + if let Some(switch) = option { + command.arg(switch); + } else { + return; + } + + command.arg("now"); + match command.output() { + Ok(output) => { + if !output.status.success() { + tracing::error!("Failed to shutdown the system: {output:?}") + } + } + Err(error) => { + tracing::error!("Failed to run the shutdown command: {error}"); + } + } + } +} diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 149e5f1eb3..43dc4e76fd 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.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. +pub(crate) mod actions; pub mod service; pub use service::Service; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index a153b9d821..9e57f921c6 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -19,8 +19,8 @@ // find current contact information at www.suse.com. use crate::{ - bootloader, checks, files, hardware, hostname, iscsi, l10n, message, network, proxy, s390, - security, software, storage, tasks, users, + actions::FinishAction, bootloader, checks, files, hardware, hostname, iscsi, l10n, message, + network, proxy, s390, security, software, storage, tasks, users, }; use agama_users::PasswordCheckResult; use agama_utils::{ @@ -42,7 +42,7 @@ use async_trait::async_trait; use merge::Merge; use network::NetworkSystemClient; use serde_json::Value; -use std::{collections::HashMap, process::Command, str::FromStr, sync::Arc}; +use std::{collections::HashMap, str::FromStr, sync::Arc}; use tokio::sync::{broadcast, RwLock}; #[derive(Debug, thiserror::Error)] @@ -890,52 +890,3 @@ impl MessageHandler for Service { Ok(self.users.call(message).await?) } } - -/// Implements the finish action. -struct FinishAction { - method: Option, -} - -impl FinishAction { - pub fn new(method: Option) -> Self { - Self { method } - } - - pub fn run(self) { - let method = self.method.unwrap_or_else(|| { - let inst_finish_method = KernelCmdline::parse() - .ok() - .and_then(|a| a.get_last("inst.finish")) - .and_then(|m| FinishMethod::from_str(&m).ok()); - inst_finish_method.unwrap_or_default() - }); - - tracing::info!("Finishing the installation process ({})", method); - - let option = match method { - FinishMethod::Halt => Some("-H"), - FinishMethod::Reboot => Some("-r"), - FinishMethod::Poweroff => Some("-P"), - FinishMethod::Stop => None, - }; - let mut command = Command::new("shutdown"); - - if let Some(switch) = option { - command.arg(switch); - } else { - return; - } - - command.arg("now"); - match command.output() { - Ok(output) => { - if !output.status.success() { - tracing::error!("Failed to shutdown the system: {output:?}") - } - } - Err(error) => { - tracing::error!("Failed to run the shutdown command: {error}"); - } - } - } -} diff --git a/rust/agama-manager/src/tasks/runner.rs b/rust/agama-manager/src/tasks/runner.rs index 1b3b08c08e..ecaa8b705f 100644 --- a/rust/agama-manager/src/tasks/runner.rs +++ b/rust/agama-manager/src/tasks/runner.rs @@ -18,23 +18,23 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use std::sync::Arc; +use std::str::FromStr; use crate::{ - bootloader, checks, files, hostname, iscsi, l10n, proxy, s390, security, service, software, - storage, tasks::message, users, + actions::{FinishAction, InstallAction, SetConfigAction}, + bootloader, files, hostname, iscsi, l10n, proxy, s390, security, service, software, storage, + tasks::message, + users, }; use agama_network::NetworkSystemClient; use agama_utils::{ actor::{Actor, Handler, MessageHandler}, - api::{files::scripts::ScriptsGroup, status::Stage, Config, Scope}, + api::{FinishMethod, Scope}, issue, - products::ProductSpec, + kernel_cmdline::KernelCmdline, progress, question, }; use async_trait::async_trait; -use gettextrs::gettext; -use tokio::sync::RwLock; /// Runs long tasks in background. /// @@ -95,6 +95,13 @@ impl MessageHandler for TasksRunner { .call(progress::message::Finish::new(Scope::Manager)) .await; tracing::info!("Installation finished"); + + // + // Finish the installer (using the default option). + // + let method = FinishMethod::from_kernel_cmdline().unwrap_or(FinishMethod::Stop); + let finish = FinishAction::new(method); + finish.run(); Ok(()) } } @@ -134,308 +141,3 @@ impl MessageHandler for TasksRunner { Ok(()) } } - -/// Implements the installation process. -/// -/// This action runs on a separate Tokio task to prevent the manager from blocking. -struct InstallAction { - hostname: Handler, - issues: Handler, - l10n: Handler, - network: NetworkSystemClient, - proxy: Handler, - software: Handler, - storage: Handler, - files: Handler, - progress: Handler, - users: Handler, -} - -impl InstallAction { - /// Runs the installation process on a separate Tokio task. - pub async fn run(mut self) -> Result<(), service::Error> { - checks::check_stage(&self.progress, Stage::Configuring).await?; - checks::check_issues(&self.issues).await?; - checks::check_progress(&self.progress).await?; - - tracing::info!("Installation started"); - if let Err(error) = self.install().await { - tracing::error!("Installation failed: {error}"); - self.set_stage(Stage::Failed).await; - return Err(error); - } - - tracing::info!("Installation finished"); - Ok(()) - } - - async fn install(&mut self) -> Result<(), service::Error> { - // NOTE: consider a NextState message? - self.progress - .call(progress::message::SetStage::new(Stage::Installing)) - .await?; - - // - // Preparation - // - self.progress - .call(progress::message::StartWithSteps::new( - Scope::Manager, - vec![ - gettext("Prepare the system"), - gettext("Install software"), - gettext("Configure the system"), - ], - )) - .await?; - - self.storage.call(storage::message::Install).await?; - self.files - .call(files::message::RunScripts::new( - ScriptsGroup::PostPartitioning, - )) - .await?; - - // - // Installation - // - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.software.call(software::message::Install).await?; - - // - // Configuration - // - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.l10n.call(l10n::message::Install).await?; - self.software.call(software::message::Finish).await?; - self.files.call(files::message::WriteFiles).await?; - self.network.install().await?; - self.proxy.call(proxy::message::Finish).await?; - self.hostname.call(hostname::message::Install).await?; - self.users.call(users::message::Install).await?; - self.storage.call(storage::message::Finish).await?; - - // call files before storage finish as it unmount /mnt/run which is important for chrooted scripts - self.files - .call(files::message::RunScripts::new(ScriptsGroup::Post)) - .await?; - - self.storage.call(storage::message::Umount).await?; - - // - // Finish progress and changes - // - self.progress - .call(progress::message::Finish::new(Scope::Manager)) - .await?; - - self.progress - .call(progress::message::SetStage::new(Stage::Finished)) - .await?; - Ok(()) - } - - async fn set_stage(&self, stage: Stage) { - if let Err(error) = self - .progress - .call(progress::message::SetStage::new(stage)) - .await - { - tracing::error!("It was not possible to set the stage to {}: {error}", stage); - } - } -} - -/// Implements the set config logic. -/// -/// This action runs on a separate Tokio task to prevent the manager from blocking. -struct SetConfigAction { - bootloader: Handler, - files: Handler, - hostname: Handler, - iscsi: Handler, - l10n: Handler, - network: NetworkSystemClient, - proxy: Handler, - progress: Handler, - questions: Handler, - security: Handler, - software: Handler, - storage: Handler, - users: Handler, - s390: Option>, -} - -impl SetConfigAction { - pub async fn run( - self, - product: Option>>, - config: Config, - ) -> Result<(), service::Error> { - checks::check_stage(&self.progress, Stage::Configuring).await?; - - // - // Preparation - // - let mut steps = vec![ - gettext("Storing security settings"), - gettext("Setting up the hostname"), - gettext("Setting up the network proxy"), - gettext("Importing user files"), - gettext("Running user pre-installation scripts"), - gettext("Storing questions settings"), - gettext("Storing localization settings"), - gettext("Storing users settings"), - gettext("Configuring iSCSI devices"), - ]; - - if self.s390.is_some() { - steps.push(gettext("Configuring DASD devices")); - } - - if config.network.is_some() { - steps.push(gettext("Setting up the network")); - } - - if product.is_some() { - steps.extend_from_slice(&[ - gettext("Preparing the software proposal"), - gettext("Preparing the storage proposal"), - gettext("Storing bootloader settings"), - ]) - } - - self.progress - .call(progress::message::StartWithSteps::new( - Scope::Manager, - steps, - )) - .await?; - - // - // Set the configuration for each service - // - self.security - .call(security::message::SetConfig::new(config.security.clone())) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.hostname - .call(hostname::message::SetConfig::new(config.hostname.clone())) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.proxy - .call(proxy::message::SetConfig::new(config.proxy.clone())) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.files - .call(files::message::SetConfig::new(config.files.clone())) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.files - .call(files::message::RunScripts::new(ScriptsGroup::Pre)) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.questions - .call(question::message::SetConfig::new(config.questions.clone())) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.l10n - .call(l10n::message::SetConfig::new(config.l10n.clone())) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.users - .call(users::message::SetConfig::new(config.users.clone())) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.iscsi - .call(iscsi::message::SetConfig::new(config.iscsi.clone())) - .await?; - - if let Some(s390) = &self.s390 { - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - s390.call(s390::message::SetConfig::new(config.s390.clone())) - .await?; - } - - if let Some(network) = config.network.clone() { - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.network.update_config(network).await?; - self.network.apply().await?; - } - - match &product { - Some(product) => { - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - self.software - .call(software::message::SetConfig::new( - Arc::clone(product), - config.software.clone(), - )) - .await?; - - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - let future = self - .storage - .call(storage::message::SetConfig::new( - Arc::clone(product), - config.storage.clone(), - )) - .await?; - let _ = future.await; - - // call bootloader always after storage to ensure that bootloader reflect new storage settings - self.progress - .call(progress::message::Next::new(Scope::Manager)) - .await?; - 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 --git a/rust/agama-utils/src/api/action.rs b/rust/agama-utils/src/api/action.rs index ca29f8cf46..53c4dbe1ab 100644 --- a/rust/agama-utils/src/api/action.rs +++ b/rust/agama-utils/src/api/action.rs @@ -18,7 +18,12 @@ // 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::{iscsi, l10n}; +use std::str::FromStr; + +use crate::{ + api::{iscsi, l10n}, + kernel_cmdline::KernelCmdline, +}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] @@ -38,12 +43,11 @@ pub enum Action { #[serde(rename = "install")] Install, #[serde(rename = "finish")] - Finish(Option), + Finish(FinishMethod), } /// Finish method #[derive( - Default, Serialize, Deserialize, Debug, @@ -61,10 +65,19 @@ pub enum FinishMethod { /// Halt the system Halt, /// Reboots the system - #[default] Reboot, /// Do nothing at the end of the installation Stop, /// Poweroff the system Poweroff, } + +impl FinishMethod { + /// Returns the finish method given in the kernel's command-line (if any). + pub fn from_kernel_cmdline() -> Option { + KernelCmdline::parse() + .ok() + .and_then(|a| a.get_last("inst.finish")) + .and_then(|m| FinishMethod::from_str(&m).ok()) + } +} diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 679e14a9af..3e0f33f38c 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Thu Mar 12 10:03:39 UTC 2026 - Imobach Gonzalez Sosa + +- Do not force to push the "Reboot" button if inst.finish is given + (jsc#PED-15031). + ------------------------------------------------------------------- Tue Mar 10 12:16:53 UTC 2026 - Imobach Gonzalez Sosa