Repository: agama-project/agama
Pull request #2153: feat: allow selecting individual packages
Author: login: imobachgs
Created: 2025-03-13T17:32:26Z | Merged: 2025-03-14T14:42:43Z

This pull request implements support for selecting individual packages during the installation.

```json
{
  "software": {
    "patterns": ["gnome_desktop"],
    "packages": ["vim"],
  }
}
```

## Links

* Trello: https://trello.com/c/Ondb9itc/

## To improve

When a package does not exist, the problem is logged but not reported. We could handle this problem in a separate PR (or even a separate PBI) because we might need a way to handle a) errors in the `PackagesProposal`.



Diff:
diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json
index 220aa14ed2..b0c74b610c 100644
--- a/rust/agama-lib/share/profile.schema.json
+++ b/rust/agama-lib/share/profile.schema.json
@@ -81,6 +81,14 @@
             "type": "string",
             "examples": ["minimal_base"]
           }
+        },
+        "packages": {
+          "title": "List of packages to install",
+          "type": "array",
+          "items": {
+            "type": "string",
+            "examples": ["vim"]
+          }
         }
       }
     },
diff --git a/rust/agama-lib/src/product/http_client.rs b/rust/agama-lib/src/product/http_client.rs
index 29a36f3b3a..6802b05c2c 100644
--- a/rust/agama-lib/src/product/http_client.rs
+++ b/rust/agama-lib/src/product/http_client.rs
@@ -56,6 +56,7 @@ impl ProductHTTPClient {
         let config = SoftwareConfig {
             product: Some(product_id.to_owned()),
             patterns: None,
+            packages: None,
         };
         self.set_software(&config).await
     }
diff --git a/rust/agama-lib/src/product/store.rs b/rust/agama-lib/src/product/store.rs
index e077ced587..21b90d8d14 100644
--- a/rust/agama-lib/src/product/store.rs
+++ b/rust/agama-lib/src/product/store.rs
@@ -155,6 +155,7 @@ mod test {
                 .body(
                     r#"{
                     "patterns": {},
+                    "packages": [],
                     "product": ""
                 }"#,
                 );
@@ -163,7 +164,7 @@ mod test {
             when.method(PUT)
                 .path("/api/software/config")
                 .header("content-type", "application/json")
-                .body(r#"{"patterns":null,"product":"Tumbleweed"}"#);
+                .body(r#"{"patterns":null,"packages":null,"product":"Tumbleweed"}"#);
             then.status(200);
         });
         let manager_mock = server.mock(|when, then| {
diff --git a/rust/agama-lib/src/software/client.rs b/rust/agama-lib/src/software/client.rs
index f868235a93..9ad2d0dc79 100644
--- a/rust/agama-lib/src/software/client.rs
+++ b/rust/agama-lib/src/software/client.rs
@@ -28,6 +28,8 @@ use serde_repr::Serialize_repr;
 use std::collections::HashMap;
 use zbus::Connection;
 
+const USER_RESOLVABLES_LIST: &str = "user";
+
 // TODO: move it to model?
 /// Represents a software product
 #[derive(Debug, Serialize, utoipa::ToSchema)]
@@ -189,6 +191,28 @@ impl<'a> SoftwareClient<'a> {
         }
     }
 
+    /// Selects packages by user
+    ///
+    /// Adds the given packages to the proposal.
+    ///
+    /// * `names`: package names.
+    pub async fn select_packages(&self, names: Vec<String>) -> Result<(), ServiceError> {
+        let names: Vec<_> = names.iter().map(|n| n.as_ref()).collect();
+        self.set_resolvables(
+            USER_RESOLVABLES_LIST,
+            ResolvableType::Package,
+            names.as_slice(),
+            true,
+        )
+        .await?;
+        Ok(())
+    }
+
+    pub async fn user_selected_packages(&self) -> Result<Vec<String>, ServiceError> {
+        self.get_resolvables(USER_RESOLVABLES_LIST, ResolvableType::Package, true)
+            .await
+    }
+
     /// Returns the required space for installing the selected patterns.
     ///
     /// It returns a formatted string including the size and the unit.
@@ -219,4 +243,22 @@ impl<'a> SoftwareClient<'a> {
             .await?;
         Ok(())
     }
+
+    /// Gets a resolvables list.
+    ///
+    /// * `id`: resolvable list ID.
+    /// * `r#type`: type of the resolvables.
+    /// * `optional`: whether the resolvables are optional.
+    pub async fn get_resolvables(
+        &self,
+        id: &str,
+        r#type: ResolvableType,
+        optional: bool,
+    ) -> Result<Vec<String>, ServiceError> {
+        let packages = self
+            .proposal_proxy
+            .get_resolvables(id, r#type as u8, optional)
+            .await?;
+        Ok(packages)
+    }
 }
diff --git a/rust/agama-lib/src/software/http_client.rs b/rust/agama-lib/src/software/http_client.rs
index 21877e9b84..de366e618f 100644
--- a/rust/agama-lib/src/software/http_client.rs
+++ b/rust/agama-lib/src/software/http_client.rs
@@ -73,6 +73,7 @@ impl SoftwareHTTPClient {
             product: None,
             // TODO: SoftwareStore only passes true bools, false branch is untested
             patterns: Some(patterns),
+            packages: None,
         };
         self.set_config(&config).await
     }
diff --git a/rust/agama-lib/src/software/model/packages.rs b/rust/agama-lib/src/software/model/packages.rs
index f402c292a2..ea1bc007da 100644
--- a/rust/agama-lib/src/software/model/packages.rs
+++ b/rust/agama-lib/src/software/model/packages.rs
@@ -26,6 +26,8 @@ use std::collections::HashMap;
 pub struct SoftwareConfig {
     /// A map where the keys are the pattern names and the values whether to install them or not.
     pub patterns: Option<HashMap<String, bool>>,
+    /// Packages to install.
+    pub packages: Option<Vec<String>>,
     /// Name of the product to install.
     pub product: Option<String>,
 }
diff --git a/rust/agama-lib/src/software/settings.rs b/rust/agama-lib/src/software/settings.rs
index d872f65e87..c73a0da667 100644
--- a/rust/agama-lib/src/software/settings.rs
+++ b/rust/agama-lib/src/software/settings.rs
@@ -26,6 +26,10 @@ use serde::{Deserialize, Serialize};
 #[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
 pub struct SoftwareSettings {
-    /// List of patterns to install. If empty use default.
-    pub patterns: Vec<String>,
+    /// List of user selected patterns to install.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub patterns: Option<Vec<String>>,
+    /// List of user selected packages to install.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub packages: Option<Vec<String>>,
 }
diff --git a/rust/agama-lib/src/software/store.rs b/rust/agama-lib/src/software/store.rs
index dbe4c6f3fa..e139677437 100644
--- a/rust/agama-lib/src/software/store.rs
+++ b/rust/agama-lib/src/software/store.rs
@@ -22,6 +22,7 @@
 
 use std::collections::HashMap;
 
+use super::model::SoftwareConfig;
 use super::{SoftwareHTTPClient, SoftwareSettings};
 use crate::base_http_client::BaseHTTPClient;
 use crate::error::ServiceError;
@@ -40,16 +41,31 @@ impl SoftwareStore {
 
     pub async fn load(&self) -> Result<SoftwareSettings, ServiceError> {
         let patterns = self.software_client.user_selected_patterns().await?;
-        Ok(SoftwareSettings { patterns })
+        // FIXME: user_selected_patterns is calling get_config too.
+        let config = self.software_client.get_config().await?;
+        Ok(SoftwareSettings {
+            patterns: if patterns.is_empty() {
+                None
+            } else {
+                Some(patterns)
+            },
+            packages: config.packages,
+        })
     }
 
     pub async fn store(&self, settings: &SoftwareSettings) -> Result<(), ServiceError> {
-        let patterns: HashMap<String, bool> = settings
+        let patterns: Option<HashMap<String, bool>> = settings
             .patterns
-            .iter()
-            .map(|name| (name.to_owned(), true))
-            .collect();
-        self.software_client.select_patterns(patterns).await?;
+            .clone()
+            .map(|pat| pat.iter().map(|n| (n.to_owned(), true)).collect());
+
+        let config = SoftwareConfig {
+            // do not change the product
+            product: None,
+            patterns,
+            packages: settings.packages.clone(),
+        };
+        self.software_client.set_config(&config).await?;
 
         Ok(())
     }
@@ -82,6 +98,7 @@ mod test {
                 .body(
                     r#"{
                     "patterns": {"xfce":true},
+                    "packages": ["vim"],
                     "product": "Tumbleweed"
                 }"#,
                 );
@@ -92,13 +109,15 @@ mod test {
         let settings = store.load().await?;
 
         let expected = SoftwareSettings {
-            patterns: vec!["xfce".to_owned()],
+            patterns: Some(vec!["xfce".to_owned()]),
+            packages: Some(vec!["vim".to_owned()]),
         };
         // main assertion
         assert_eq!(settings, expected);
 
+        // FIXME: at this point it is calling the method twice
         // Ensure the specified mock was called exactly one time (or fail with a detailed error description).
-        software_mock.assert();
+        software_mock.assert_hits(2);
         Ok(())
     }
 
@@ -109,14 +128,15 @@ mod test {
             when.method(PUT)
                 .path("/api/software/config")
                 .header("content-type", "application/json")
-                .body(r#"{"patterns":{"xfce":true},"product":null}"#);
+                .body(r#"{"patterns":{"xfce":true},"packages":["vim"],"product":null}"#);
             then.status(200);
         });
         let url = server.url("/api");
 
         let store = software_store(url);
         let settings = SoftwareSettings {
-            patterns: vec!["xfce".to_owned()],
+            patterns: Some(vec!["xfce".to_owned()]),
+            packages: Some(vec!["vim".to_owned()]),
         };
 
         let result = store.store(&settings).await;
@@ -136,7 +156,7 @@ mod test {
             when.method(PUT)
                 .path("/api/software/config")
                 .header("content-type", "application/json")
-                .body(r#"{"patterns":{"no_such_pattern":true},"product":null}"#);
+                .body(r#"{"patterns":{"no_such_pattern":true},"packages":["vim"],"product":null}"#);
             then.status(400)
                 .body(r#"'{"error":"Agama service error: Failed to find these patterns: [\"no_such_pattern\"]"}"#);
         });
@@ -144,7 +164,8 @@ mod test {
 
         let store = software_store(url);
         let settings = SoftwareSettings {
-            patterns: vec!["no_such_pattern".to_owned()],
+            patterns: Some(vec!["no_such_pattern".to_owned()]),
+            packages: Some(vec!["vim".to_owned()]),
         };
 
         let result = store.store(&settings).await;
diff --git a/rust/agama-server/src/software/web.rs b/rust/agama-server/src/software/web.rs
index 92d94e4bf2..7440ba0da9 100644
--- a/rust/agama-server/src/software/web.rs
+++ b/rust/agama-server/src/software/web.rs
@@ -381,6 +381,10 @@ async fn set_config(
         state.software.select_patterns(patterns).await?;
     }
 
+    if let Some(packages) = config.packages {
+        state.software.select_packages(packages).await?;
+    }
+
     Ok(())
 }
 
@@ -411,8 +415,10 @@ async fn get_config(State(state): State<SoftwareState<'_>>) -> Result<Json<Softw
         .into_iter()
         .map(|p| (p, true))
         .collect();
+    let packages = state.software.user_selected_packages().await?;
     let config = SoftwareConfig {
         patterns: Some(patterns),
+        packages: Some(packages),
         product,
     };
     Ok(Json(config))
diff --git a/rust/package/agama.changes b/rust/package/agama.changes
index 2688538b4f..8de95ce77a 100644
--- a/rust/package/agama.changes
+++ b/rust/package/agama.changes
@@ -1,3 +1,9 @@
+-------------------------------------------------------------------
+Fri Mar 14 12:32:42 UTC 2025 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>
+
+- Allow selecting individual packages through a configuration file
+  (gh#agama-project/agama#2153).
+
 -------------------------------------------------------------------
 Wed Mar 12 17:12:10 UTC 2025 - Knut Alejandro Anderssen González <kanderssen@suse.com>
 
diff --git a/service/lib/agama/autoyast/software_reader.rb b/service/lib/agama/autoyast/software_reader.rb
index 213c7b4c48..c0699614f9 100755
--- a/service/lib/agama/autoyast/software_reader.rb
+++ b/service/lib/agama/autoyast/software_reader.rb
@@ -39,10 +39,13 @@ def initialize(profile)
       def read
         return {} if profile["software"].nil?
 
-        patterns = profile["software"].fetch_as_array("patterns")
-        return {} if patterns.empty?
+        software = {}
 
-        { "software" => { "patterns" => patterns } }
+        software["patterns"] = profile["software"].fetch_as_array("patterns")
+        software["packages"] = profile["software"].fetch_as_array("packages")
+        return {} if software.empty?
+
+        { "software" => software }
       end
 
     private
diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes
index a233a5d0a2..00612a4d0a 100644
--- a/service/package/rubygem-agama-yast.changes
+++ b/service/package/rubygem-agama-yast.changes
@@ -1,3 +1,9 @@
+-------------------------------------------------------------------
+Fri Mar 14 12:34:03 UTC 2025 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>
+
+- Import the packages list from an AutoYaST profile
+  (gh#agama-project/agama#2153).
+
 -------------------------------------------------------------------
 Wed Mar 12 00:44:33 UTC 2025 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>
 
diff --git a/service/share/autoyast-compat.json b/service/share/autoyast-compat.json
index 0e8ef83e7d..30ce0ffba9 100644
--- a/service/share/autoyast-compat.json
+++ b/service/share/autoyast-compat.json
@@ -397,7 +397,7 @@
       { "key": "install_recommended", "support": "no" },
       { "key": "instsource", "support": "no" },
       { "key": "kernel", "support": "no" },
-      { "key": "packages[]", "support": "planned" },
+      { "key": "packages[]", "support": "yes", "agama": "software.packages[]" },
       { "key": "post-packages[]", "support": "no" },
       { "key": "patterns[]", "support": "yes", "agama": "software.patterns[]" },
       { "key": "products[]", "support": "yes", "agama": "software.id" },
diff --git a/service/test/agama/autoyast/software_reader_test.rb b/service/test/agama/autoyast/software_reader_test.rb
index e66397ac90..fec65a5d48 100644
--- a/service/test/agama/autoyast/software_reader_test.rb
+++ b/service/test/agama/autoyast/software_reader_test.rb
@@ -30,7 +30,8 @@
     {
       "software" => {
         "products" => ["SLE"],
-        "patterns" => ["base", "gnome"]
+        "patterns" => ["base", "gnome"],
+        "packages" => ["vim"]
       }
     }
   end
@@ -54,5 +55,12 @@
         expect(patterns).to eq(["base", "gnome"])
       end
     end
+
+    context "when a list of packages is included" do
+      it "includes the list of patterns under 'software.packages'" do
+        patterns = subject.read.dig("software", "packages")
+        expect(patterns).to eq(["vim"])
+      end
+    end
   end
 end
diff --git a/web/src/types/software.ts b/web/src/types/software.ts
index 3791e9cfc2..e5563c1fd9 100644
--- a/web/src/types/software.ts
+++ b/web/src/types/software.ts
@@ -75,6 +75,8 @@ type SoftwareConfig = {
   product?: string;
   /** An object where the keys are the pattern names and the values whether to install them or not */
   patterns?: { [key: string]: boolean };
+  /** A list of user selected packages */
+  packages?: string[];
 };
 
 type Pattern = {


Reviews:
user: login: teclator
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:33:30Z
user: login: imobachgs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:34:59Z
user: login: teclator
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:35:10Z
user: login: teclator
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T14:39:55Z

Review comments:
diff_hunk: @@ -189,6 +191,28 @@ impl<'a> SoftwareClient<'a> {
         }
     }
 
+    /// Selects packages by user
+    ///
+    /// Adds the given packages to the proposal.
+    ///
+    /// * `names`: package names.
+    pub async fn select_packages(&self, names: Vec<String>) -> Result<(), ServiceError> {
+        let names: Vec<_> = names.iter().map(|n| n.as_ref()).collect();
+        self.set_resolvables(
+            USER_RESOLVABLES_LIST,
+            ResolvableType::Package,
+            names.as_slice(),
+            true,
+        )
+        .await?;
+        Ok(())
+    }
+
+    pub async fn user_selected_packages(&self) -> Result<Vec<String>, ServiceError> {
path: rust/agama-lib/src/software/client.rs
user: login: teclator
body: why not just selected_packages?
created_at: 2025-03-14T14:33:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -189,6 +191,28 @@ impl<'a> SoftwareClient<'a> {
         }
     }
 
+    /// Selects packages by user
+    ///
+    /// Adds the given packages to the proposal.
+    ///
+    /// * `names`: package names.
+    pub async fn select_packages(&self, names: Vec<String>) -> Result<(), ServiceError> {
+        let names: Vec<_> = names.iter().map(|n| n.as_ref()).collect();
+        self.set_resolvables(
+            USER_RESOLVABLES_LIST,
+            ResolvableType::Package,
+            names.as_slice(),
+            true,
+        )
+        .await?;
+        Ok(())
+    }
+
+    pub async fn user_selected_packages(&self) -> Result<Vec<String>, ServiceError> {
path: rust/agama-lib/src/software/client.rs
user: login: imobachgs
body: Because they are selected by the user, not by the product definition itself. And it is consistent with `user_selected_patterns`.
created_at: 2025-03-14T14:34:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -189,6 +191,28 @@ impl<'a> SoftwareClient<'a> {
         }
     }
 
+    /// Selects packages by user
+    ///
+    /// Adds the given packages to the proposal.
+    ///
+    /// * `names`: package names.
+    pub async fn select_packages(&self, names: Vec<String>) -> Result<(), ServiceError> {
+        let names: Vec<_> = names.iter().map(|n| n.as_ref()).collect();
+        self.set_resolvables(
+            USER_RESOLVABLES_LIST,
+            ResolvableType::Package,
+            names.as_slice(),
+            true,
+        )
+        .await?;
+        Ok(())
+    }
+
+    pub async fn user_selected_packages(&self) -> Result<Vec<String>, ServiceError> {
path: rust/agama-lib/src/software/client.rs
user: login: teclator
body: Ok, there is already user_selected_patterns and selected_patterns methods and this is the equivalent for the first one.
created_at: 2025-03-14T14:35:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: imobachgs
created_at: 2025-03-13T17:41:39Z
body: @lslezak I need your help here :wink: I wonder whether it is better:

* Add the packages to the proposal (using the `SetResolvables` method). The solver does not run after that, but we can fix that. Deselecting packages is not supported this way, but I'd like to know whether we need/want that.
* Follow a similar approach to what we do with patterns: have a SetUserPatterns method at D-Bus level. This one is more complex, but it won't be hard to implement it.

We can start with a) and move to b) later. However, I would like to have the point of view of a software management expert.
author_association: CONTRIBUTOR
user: login: lslezak
created_at: 2025-03-14T08:08:51Z
body: I do not know the details but I'd probably use the same mechanism as for selecting the mandatory packages from the product configuration. To be consistent.
author_association: CONTRIBUTOR
user: login: imobachgs
created_at: 2025-03-14T10:53:58Z
body: > I do not know the details but I'd probably use the same mechanism as for selecting the mandatory packages from the product configuration. To be consistent.

Good point. Then I will continue with the current implementation that tuses the proposal's SetResolvables.

Thanks!
author_association: CONTRIBUTOR
user: login: coveralls
created_at: 2025-03-14T15:25:46Z
body: ## Pull Request Test Coverage Report for [Build 13856620473](https://coveralls.io/builds/72723458)

### Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

- For more information on this, see <a target="_blank" href="https://docs.coveralls.io/build-types#tracking-coverage-changes-with-pull_request-builds">Tracking coverage changes with pull request builds</a>.
- To avoid this issue with future PRs, see these <a target="_blank" href="https://docs.coveralls.io/build-types#recommended-ci-configurations">Recommended CI Configurations</a>.
- For a quick fix, <a target="_blank" href="https://github.blog/changelog/2022-02-03-more-ways-to-keep-your-pull-request-branch-up-to-date/#update-your-pull-request-branch-by-rebasing">rebase this PR at GitHub</a>. Your next report should be accurate.

### Details

* **9** of **16**   **(56.25%)**  changed or added relevant lines in **4** files are covered.
* No unchanged relevant lines lost coverage.
* Overall first build on select-packages-proposal at **72.324%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [rust/agama-server/src/software/web.rs](https://coveralls.io/builds/72723458/source?filename=rust%2Fagama-server%2Fsrc%2Fsoftware%2Fweb.rs#L384) | 0 | 2 | 0.0%
| [rust/agama-lib/src/software/client.rs](https://coveralls.io/builds/72723458/source?filename=rust%2Fagama-lib%2Fsrc%2Fsoftware%2Fclient.rs#L195) | 0 | 5 | 0.0%
<!-- | **Total:** | **9** | **16** | **56.25%** | -->


|  Totals | [![Coverage Status](https://coveralls.io/builds/72723458/badge)](https://coveralls.io/builds/72723458) |
| :-- | --: |
| Change from base [Build 13837957018](https://coveralls.io/builds/72705936): |  72.3% |
| Covered Lines: | 20020 |
| Relevant Lines: | 27681 |

---
##### 💛  - [Coveralls](https://coveralls.io)

author_association: NONE