Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions products.d/agama-products.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Wed Jan 28 13:01:02 UTC 2026 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

- Fix the optional_patterns attribute in the product definitions
(jsc#PED-14307).

-------------------------------------------------------------------
Wed Jan 28 11:01:02 UTC 2026 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

Expand Down
2 changes: 1 addition & 1 deletion products.d/kalpa.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ software:
- microos_hardware
- microos_kde_desktop
- microos_selinux
optional_patterns: null
optional_patterns: []
user_patterns:
- container_runtime
mandatory_packages:
Expand Down
2 changes: 1 addition & 1 deletion products.d/leap_160.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ software:
archs: ppc
mandatory_patterns:
- enhanced_base # only pattern that is shared among all roles on Leap
optional_patterns: null # no optional pattern shared
optional_patterns: [] # no optional pattern shared
user_patterns:
- gnome
- kde
Expand Down
2 changes: 1 addition & 1 deletion products.d/leap_micro_62.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ software:
- hardware
- selinux

optional_patterns: null
optional_patterns: []

user_patterns:
- cloud
Expand Down
2 changes: 1 addition & 1 deletion products.d/microos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ software:
- microos_defaults
- microos_hardware
- microos_selinux
optional_patterns: null
optional_patterns: []
user_patterns:
- container_runtime
- microos_ra_agent
Expand Down
2 changes: 1 addition & 1 deletion products.d/sles_sap_161.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ software:
- enhanced_base
- bootloader
- sles_sap_base_sap_server
optional_patterns: null # no optional pattern shared
optional_patterns: [] # no optional pattern shared
user_patterns:
# First all patterns from file sles_160.yaml
- cockpit
Expand Down
2 changes: 1 addition & 1 deletion products.d/slowroll.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ software:

mandatory_patterns:
- enhanced_base
optional_patterns: null
optional_patterns: []
user_patterns:
- basic-desktop
- gnome
Expand Down
2 changes: 1 addition & 1 deletion products.d/tumbleweed.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ software:
archs: ppc
mandatory_patterns:
- enhanced_base # only pattern that is shared among all roles on TW
optional_patterns: null # no optional pattern shared
optional_patterns: [] # no optional pattern shared
user_patterns:
- basic_desktop
- xfce
Expand Down
44 changes: 33 additions & 11 deletions rust/agama-manager/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ use gettextrs::gettext;
use merge::Merge;
use network::NetworkSystemClient;
use serde_json::Value;
use std::{process::Command, sync::Arc};
use std::{collections::HashMap, process::Command, sync::Arc};
use tokio::sync::{broadcast, RwLock};

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -85,8 +85,12 @@ pub enum Error {
Hardware(#[from] hardware::Error),
#[error("Cannot dispatch this action in {current} stage (expected {expected}).")]
UnexpectedStage { current: Stage, expected: Stage },
#[error("Cannot start the installation. The config contains some issues.")]
InstallationBlocked,
#[error("Failed to perform the action because the system is busy: {scopes:?}.")]
Busy { scopes: Vec<Scope> },
#[error(
"It is not possible to install the system because there are some pending issues: {issues:?}."
)]
PendingIssues { issues: HashMap<Scope, Vec<Issue>> },
#[error(transparent)]
Users(#[from] users::service::Error),
}
Expand Down Expand Up @@ -515,6 +519,26 @@ impl Service {
Ok(())
}

async fn check_issues(&self) -> Result<(), Error> {
let issues = self.issues.call(issue::message::Get).await?;
if !issues.is_empty() {
return Err(Error::PendingIssues {
issues: issues.clone(),
});
}
Ok(())
}

async fn check_progress(&self) -> Result<(), Error> {
let progress = self.progress.call(progress::message::GetProgress).await?;
if !progress.is_empty() {
return Err(Error::Busy {
scopes: progress.iter().map(|p| p.scope).collect(),
});
}
Ok(())
}

/// Determines whether the software service is available.
///
/// Consider the service as available if there is no pending progress.
Expand Down Expand Up @@ -695,13 +719,6 @@ impl MessageHandler<message::GetLicense> for Service {
impl MessageHandler<message::RunAction> for Service {
/// It runs the given action.
async fn handle(&mut self, message: message::RunAction) -> Result<(), Error> {
let issues = self.issues.call(issue::message::Get).await?;
let progress = self.progress.call(progress::message::GetProgress).await?;

if !issues.is_empty() || !progress.is_empty() {
return Err(Error::InstallationBlocked);
}

match message.action {
Action::ConfigureL10n(config) => {
self.check_stage(Stage::Configuring).await?;
Expand All @@ -717,6 +734,8 @@ impl MessageHandler<message::RunAction> for Service {
}
Action::Install => {
self.check_stage(Stage::Configuring).await?;
self.check_issues().await?;
self.check_progress().await?;
let action = InstallAction {
hostname: self.hostname.clone(),
l10n: self.l10n.clone(),
Expand All @@ -730,7 +749,7 @@ impl MessageHandler<message::RunAction> for Service {
action.run();
}
Action::Finish(method) => {
// TODO: check the stage
self.check_stage(Stage::Finished).await?;
let action = FinishAction::new(method);
action.run();
}
Expand Down Expand Up @@ -803,6 +822,7 @@ impl InstallAction {
/// Runs the installation process on a separate Tokio task.
pub fn run(mut self) {
tokio::spawn(async move {
tracing::info!("Installation started");
if let Err(error) = self.install().await {
tracing::error!("Installation failed: {error}");
if let Err(error) = self
Expand All @@ -815,6 +835,8 @@ impl InstallAction {
Stage::Failed
);
}
} else {
tracing::info!("Installation finished");
}
});
}
Expand Down
20 changes: 13 additions & 7 deletions rust/agama-server/src/server/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

use crate::server::config_schema;
use agama_lib::{error::ServiceError, logs};
use agama_manager::service::Error::InstallationBlocked;
use agama_manager::service::Error as ManagerError;
use agama_manager::{self as manager, message};
use agama_software::Resolvable;
use agama_utils::{
Expand Down Expand Up @@ -67,11 +67,17 @@ impl IntoResponse for Error {
let body = json!({
"error": self.to_string()
});
let status = if matches!(self, Error::Manager(InstallationBlocked)) {
StatusCode::UNPROCESSABLE_ENTITY
} else {
StatusCode::BAD_REQUEST
};

let mut status = StatusCode::BAD_REQUEST;

if let Error::Manager(error) = &self {
if matches!(error, ManagerError::PendingIssues { issues: _ })
|| matches!(error, ManagerError::Busy { scopes })
{
status = StatusCode::UNPROCESSABLE_ENTITY;
}
}

(status, Json(body)).into_response()
}
}
Expand Down Expand Up @@ -403,7 +409,7 @@ async fn get_license(
path = "/action",
context_path = "/api/v2",
responses(
(status = 200, description = "Action successfully run."),
(status = 200, description = "Action successfully ran."),
(status = 400, description = "Not possible to run the action.", body = Object),
(status = 422, description = "Action blocked by backend state", body = Object)
),
Expand Down
9 changes: 9 additions & 0 deletions rust/package/agama.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
-------------------------------------------------------------------
Wed Jan 28 12:43:05 UTC 2026 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

- Fix the handling of HTTP actions (gh#agama-project/agama#3088).
- Add more details when a request fails because there are pending issues
or the installer is busy.
- Include in the logs markers for the start and the end of the
installation.

-------------------------------------------------------------------
Wed Jan 28 10:59:29 UTC 2026 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

Expand Down
9 changes: 9 additions & 0 deletions web/package/agama-web-ui.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
-------------------------------------------------------------------
Wed Jan 28 12:43:13 UTC 2026 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

- Force the user to select a mode when it is needed (related to
jsc#PED-14307).
- Do not loose the mode when registering a product (related to
jsc#PED-14307).
- Reset the configuration when selecting a new product.

-------------------------------------------------------------------
Wed Jan 28 11:00:28 UTC 2026 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>

Expand Down
6 changes: 5 additions & 1 deletion web/src/components/product/ProductRegistrationPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ jest.mock("~/hooks/model/proposal", () => ({

describe("ProductRegistrationPage", () => {
beforeEach(() => {
mockConfig = { product: { id: "sle", registrationCode: "" } };
mockConfig = { product: { id: "sle", mode: "standard", registrationCode: "" } };
mockIssues = [];
mockProductConfig(mockConfig.product);
// @ts-ignore
Expand Down Expand Up @@ -144,6 +144,7 @@ describe("ProductRegistrationPage", () => {
...mockConfig,
product: {
id: "sle",
mode: "standard",
registrationCode: "INTERNAL-USE-ONLY-1234-5678",
registrationEmail: undefined,
registrationUrl: undefined,
Expand Down Expand Up @@ -172,6 +173,7 @@ describe("ProductRegistrationPage", () => {
...mockConfig,
product: {
id: "sle",
mode: "standard",
registrationCode: "INTERNAL-USE-ONLY-1234-5678",
registrationEmail: "example@company.test",
registrationUrl: undefined,
Expand Down Expand Up @@ -211,6 +213,7 @@ describe("ProductRegistrationPage", () => {
...mockConfig,
product: {
id: "sle",
mode: "standard",
registrationUrl: "https://custom-server.test",
registrationCode: undefined,
registrationEmail: undefined,
Expand Down Expand Up @@ -272,6 +275,7 @@ describe("ProductRegistrationPage", () => {
...mockConfig,
product: {
id: "sle",
mode: "standard",
registrationUrl: "https://custom-server.test",
registrationCode: "INTERNAL-USE-ONLY-1234-5678",
registrationEmail: undefined,
Expand Down
1 change: 1 addition & 0 deletions web/src/components/product/ProductRegistrationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ const RegistrationFormSection = () => {
...config,
product: {
id: product.id,
mode: product.mode,
registrationCode: isKeyRequired ? key : undefined,
registrationEmail: provideEmail ? email : undefined,
registrationUrl: isUrlRequired ? url : undefined,
Expand Down
8 changes: 4 additions & 4 deletions web/src/components/product/ProductSelectionPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const microOs: Product = {
modes: [],
};

const mockPatchConfigFn = jest.fn();
const mockPutConfigFn = jest.fn();
const mockUseSystemFn: jest.Mock<ReturnType<typeof useSystem>> = jest.fn();
const mockUseSystemSoftwareFn: jest.Mock<ReturnType<typeof useSystemSoftware>> = jest.fn();

Expand All @@ -61,7 +61,7 @@ jest.mock("~/components/product/LicenseDialog", () => () => <div>LicenseDialog M

jest.mock("~/api", () => ({
...jest.requireActual("~/api"),
patchConfig: (payload) => mockPatchConfigFn(payload),
putConfig: (payload) => mockPutConfigFn(payload),
}));

jest.mock("~/hooks/model/system", () => ({
Expand Down Expand Up @@ -183,7 +183,7 @@ describe("ProductSelectionPage", () => {
const selectButton = screen.getByRole("button", { name: "Select" });
await user.click(productOption);
await user.click(selectButton);
expect(mockPatchConfigFn).toHaveBeenCalledWith({ product: { id: tumbleweed.id } });
expect(mockPutConfigFn).toHaveBeenCalledWith({ product: { id: tumbleweed.id } });
});

it("does not trigger the product selection if user selects a product but clicks o cancel button", async () => {
Expand All @@ -194,7 +194,7 @@ describe("ProductSelectionPage", () => {
expect(cancel).toHaveAttribute("href", ROOT.overview);
await user.click(productOption);
await user.click(cancel);
expect(mockPatchConfigFn).not.toHaveBeenCalled();
expect(mockPutConfigFn).not.toHaveBeenCalled();
});

it.todo("make navigation test work");
Expand Down
9 changes: 6 additions & 3 deletions web/src/components/product/ProductSelectionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import ProductLogo from "~/components/product/ProductLogo";
import LicenseDialog from "~/components/product/LicenseDialog";
import Text from "~/components/core/Text";
import agama from "~/agama";
import { patchConfig } from "~/api";
import { putConfig } from "~/api";
import { useProduct, useProductInfo } from "~/hooks/model/config/product";
import { useSystem } from "~/hooks/model/system";
import { useSystem as useSystemSoftware } from "~/hooks/model/system/software";
Expand Down Expand Up @@ -363,7 +363,10 @@ const ProductForm = ({ products, currentProduct, isSubmitted, onSubmit }: Produc
const [eulaAccepted, setEulaAccepted] = useState(false);
const mountEulaCheckbox = selectedProduct && !isEmpty(selectedProduct.license);
const isSelectionDisabled =
!selectedProduct || isSubmitted || (mountEulaCheckbox && !eulaAccepted);
!selectedProduct ||
isSubmitted ||
(mountEulaCheckbox && !eulaAccepted) ||
(!isEmpty(selectedProduct.modes) && !selectedMode);

const onProductSelectionChange = (product) => {
setEulaAccepted(false);
Expand Down Expand Up @@ -532,7 +535,7 @@ const ProductSelectionContent = () => {
setIsSubmmited(true);
setSubmmitedSelection(selectedProduct);
// FIXME: use Mode as expected
patchConfig({ product: { id: selectedProduct.id, mode: selectedMode } });
putConfig({ product: { id: selectedProduct.id, mode: selectedMode } });
};

const introText = n_(
Expand Down
Loading