From 1c3cd61728c5eedc115e89eb7bc4efc7aa8d3d71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 28 Apr 2025 22:09:49 +0100 Subject: [PATCH 01/32] feat(rust): add a Url enum to support relative URLs --- rust/Cargo.lock | 2 + rust/agama-lib/Cargo.toml | 4 +- rust/agama-lib/src/lib.rs | 3 +- rust/agama-lib/src/url.rs | 114 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 rust/agama-lib/src/url.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 4eb1f5bf74..817eaea7a1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4190,6 +4190,7 @@ dependencies = [ "form_urlencoded", "idna 0.5.0", "percent-encoding", + "serde", ] [[package]] @@ -4236,6 +4237,7 @@ dependencies = [ "quote", "regex", "syn 2.0.87", + "url", "uuid", ] diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 90efb95bf0..9c8276d9b1 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -20,8 +20,8 @@ tempfile = "3.13.0" thiserror = "1.0.64" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1.16" -url = "2.5.2" -utoipa = "5.2.0" +url = { version = "2.5.2", features = ["serde"] } +utoipa = { version = "5.2.0", features = ["url"] } zbus = { version = "5", default-features = false, features = ["tokio"] } # Needed to define curl error in profile errors curl = { version = "0.4.47", features = ["protocol-ftp"] } diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index e7383646c3..bbd5fc8e5e 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -70,6 +70,7 @@ pub mod openapi; pub mod questions; pub mod scripts; pub mod security; +pub mod url; pub mod utils; use crate::error::ServiceError; diff --git a/rust/agama-lib/src/url.rs b/rust/agama-lib/src/url.rs new file mode 100644 index 0000000000..6dd7137650 --- /dev/null +++ b/rust/agama-lib/src/url.rs @@ -0,0 +1,114 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use serde::{Deserialize, Serialize}; + +/// Represents a URL for scripts and files. +/// +/// It extends the original [url::Url] struct with support for relative URLs +/// (`relurl:///` in YaST). +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, utoipa::ToSchema)] +#[serde(untagged)] +pub enum Url { + Absolute(url::Url), + Relative(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum UrlError { + #[error("Error parsing URL: {0} ({1})")] + ParseError(String, url::ParseError), + #[error("Cannot resolve an absolute URL")] + CannotResolveAbsoluteUrl(url::Url), + #[error("Cannot join URL")] + CannotJoin(url::ParseError), +} + +impl Url { + /// Parses a string representing a URL into a Url enum. + pub fn parse(url: &str) -> Result { + match url::Url::parse(url) { + Ok(url) => Ok(Url::Absolute(url)), + Err(url::ParseError::RelativeUrlWithoutBase) => Ok(Url::Relative(url.to_string())), + Err(err) => Err(UrlError::ParseError(url.to_string(), err)), + } + } + + pub fn to_string(&self) -> String { + match self { + Url::Absolute(url) => url.to_string(), + Url::Relative(url) => url.to_string(), + } + } + + pub fn join(&self, input: &str) -> Result { + match self { + Url::Absolute(url) => { + let joined = url.join(input).map_err(|e| UrlError::CannotJoin(e))?; + Ok(Url::Absolute(joined)) + } + Url::Relative(url) => { + let joined = format!("{url}/{input}"); + Ok(Url::Relative(joined)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_absolute_url() { + let url = Url::parse("https://example.com").unwrap(); + assert!(matches!(url, Url::Absolute(_))); + } + + #[test] + fn test_parse_relative_url() { + let url = Url::parse("/path/to/profile").unwrap(); + assert!(matches!(url, Url::Relative(_))); + } + + #[test] + fn test_parse_invalid_url() { + let result = Url::parse("http:///"); + dbg!(&result); + assert!(result.is_err()); + } + + #[test] + fn test_join_absolute_url() { + let url = Url::parse("https://example.com").unwrap(); + let joined = url.join("test").unwrap(); + assert_eq!(&joined.to_string(), "https://example.com/test"); + + let joined = url.join("https://override.lan").unwrap(); + assert_eq!(&joined.to_string(), "https://override.lan"); + } + + #[test] + fn test_join_relative_url() { + let url = Url::parse("/path/to").unwrap(); + let joined = url.join("profile").unwrap(); + assert_eq!(&joined.to_string(), "/path/to/profile"); + } +} From 5ba274c59538675dfc5530e2ee5ae8617cef181a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 28 Apr 2025 22:11:22 +0100 Subject: [PATCH 02/32] feat(rust): add support for relative URLs to scripts --- rust/agama-lib/src/scripts/error.rs | 4 +- rust/agama-lib/src/scripts/model.rs | 122 +++++++++++++++++++++++++++- rust/agama-lib/src/url.rs | 4 +- 3 files changed, 123 insertions(+), 7 deletions(-) diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs index fec37981ee..7a04b583b9 100644 --- a/rust/agama-lib/src/scripts/error.rs +++ b/rust/agama-lib/src/scripts/error.rs @@ -21,7 +21,7 @@ use std::io; use thiserror::Error; -use crate::utils::TransferError; +use crate::{url::UrlError, utils::TransferError}; #[derive(Error, Debug)] pub enum ScriptError { @@ -31,4 +31,6 @@ pub enum ScriptError { InputOutputError(#[from] io::Error), #[error("Wrong script type")] WrongScriptType, + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] UrlError), } diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index a00462321e..d27841ed09 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -27,8 +27,12 @@ use std::{ }; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; -use crate::utils::Transfer; +use crate::{ + url::{Url, UrlError}, + utils::Transfer, +}; use super::ScriptError; @@ -52,6 +56,9 @@ pub struct BaseScript { } impl BaseScript { + /// Writes the script to the given directory. + /// + /// * `workdir`: directory to write the script to. fn write>(&self, workdir: P) -> Result<(), ScriptError> { let script_path = workdir.as_ref().join(&self.name); std::fs::create_dir_all(script_path.parent().unwrap())?; @@ -65,20 +72,49 @@ impl BaseScript { match &self.source { ScriptSource::Text { content } => write!(file, "{}", &content)?, - ScriptSource::Remote { url } => Transfer::get(url, &mut file)?, + ScriptSource::Remote { url } => Transfer::get(&url.to_string(), &mut file)?, }; Ok(()) } + + /// Returns the base script using an absolute URL if it was using a relative one. + /// + /// * `base`: base URL. + fn resolve_url(&self, base: &Url) -> Result { + let mut clone = self.clone(); + clone.source = self.source.resolve_url(base)?; + Ok(clone) + } } +#[serde_as] #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(untagged)] pub enum ScriptSource { /// Script's content. Text { content: String }, /// URL to get the script from. - Remote { url: String }, + Remote { url: Url }, +} + +impl ScriptSource { + /// Returns a new source using an absolute URL if it was using a relative one. + /// + /// If it was not using a relative URL, it just returns a clone. + /// + /// * `base`: base URL. + pub fn resolve_url(&self, base: &Url) -> Result { + let resolved = match self { + Self::Text { content } => Self::Text { + content: content.clone(), + }, + Self::Remote { url } => Self::Remote { + url: base.join(&url.to_string())?, + }, + }; + Ok(resolved) + } } /// Represents a script to run as part of the installation process. @@ -103,6 +139,15 @@ impl Script { } } + fn base_mut(&mut self) -> &mut BaseScript { + match self { + Script::Pre(inner) => &mut inner.base, + Script::PostPartitioning(inner) => &mut inner.base, + Script::Post(inner) => &mut inner.base, + Script::Init(inner) => &mut inner.base, + } + } + /// Returns the name of the script. pub fn name(&self) -> &str { self.base().name.as_str() @@ -128,6 +173,13 @@ impl Script { } } + /// Script's source. + /// + /// It returns the script source. + pub fn source(&self) -> &ScriptSource { + &self.base().source + } + /// Runs the script in the given work directory. /// /// It saves the logs and the exit status of the execution. @@ -152,6 +204,16 @@ impl Script { runner.run(&path) } + + /// Resolves the URL of the script. + /// + /// This method returns a new `Script` instance with the resolved URL. + pub fn resolve_url(&self, base: &Url) -> Result { + let mut clone = self.clone(); + let clone_base = clone.base_mut(); + *clone_base = self.base().resolve_url(base)?; + Ok(clone) + } } /// Trait to allow getting the runner for a script. @@ -384,7 +446,10 @@ mod test { use tempfile::TempDir; use tokio::test; - use crate::scripts::{BaseScript, PreScript, Script, ScriptSource}; + use crate::{ + scripts::{BaseScript, PreScript, Script, ScriptSource}, + url::Url, + }; use super::{ScriptsGroup, ScriptsRepository}; @@ -454,4 +519,53 @@ mod test { _ = repo.clear(); assert!(!script_path.exists()); } + + #[test] + async fn test_resolve_url_relative() { + let base_url = Url::parse("http://example.lan/sles").unwrap(); + + let relative = BaseScript { + name: "test".to_string(), + source: ScriptSource::Remote { + url: Url::parse("../agama/enable-sshd.sh").unwrap(), + }, + }; + let script = Script::Pre(PreScript { base: relative }); + let resolved = script.resolve_url(&base_url).unwrap(); + let expected_url = Url::parse("http://example.lan/agama/enable-sshd.sh").unwrap(); + + assert!(matches!( + resolved.source(), + ScriptSource::Remote { url } if url == &expected_url + )); + + let absolute = BaseScript { + name: "test".to_string(), + source: ScriptSource::Remote { + url: Url::parse("http://example.orig").unwrap(), + }, + }; + let script = Script::Pre(PreScript { base: absolute }); + let resolved = script.resolve_url(&base_url).unwrap(); + let expected_url = Url::parse("http://example.orig").unwrap(); + + assert!(matches!( + resolved.source(), + ScriptSource::Remote { url } if url == &expected_url + )); + + let text = BaseScript { + name: "test".to_string(), + source: ScriptSource::Text { + content: "#!/bin/bash\necho hello".to_string(), + }, + }; + let script = Script::Pre(PreScript { base: text }); + let resolved = script.resolve_url(&base_url).unwrap(); + + assert!(matches!( + resolved.source(), + ScriptSource::Text { content: _ } + )); + } } diff --git a/rust/agama-lib/src/url.rs b/rust/agama-lib/src/url.rs index 6dd7137650..d0d3db8ef7 100644 --- a/rust/agama-lib/src/url.rs +++ b/rust/agama-lib/src/url.rs @@ -101,8 +101,8 @@ mod tests { let joined = url.join("test").unwrap(); assert_eq!(&joined.to_string(), "https://example.com/test"); - let joined = url.join("https://override.lan").unwrap(); - assert_eq!(&joined.to_string(), "https://override.lan"); + let joined = url.join("https://override.lan/").unwrap(); + assert_eq!(&joined.to_string(), "https://override.lan/"); } #[test] From 9f119cac76a33ba0019a2249887f8b318ba33ea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Mon, 28 Apr 2025 22:13:50 +0100 Subject: [PATCH 03/32] feat(rust): resolve scripts URLs --- rust/agama-cli/src/config.rs | 5 +++-- rust/agama-cli/src/profile.rs | 18 +++++++++++----- rust/agama-lib/src/lib.rs | 2 +- rust/agama-lib/src/scripts/store.rs | 32 ++++++++++++++++++++++++----- rust/agama-lib/src/store.rs | 27 +++++++++++++++++++++--- 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index 6a0cf65bc8..ea07bed540 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -27,6 +27,7 @@ use std::{ use crate::show_progress; use agama_lib::{ base_http_client::BaseHTTPClient, install_settings::InstallSettings, Store as SettingsStore, + StoreContext, }; use anyhow::anyhow; use clap::Subcommand; @@ -79,7 +80,7 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any tokio::spawn(async move { show_progress().await.unwrap(); }); - store.store(&result).await?; + store.store(&result, &StoreContext::from_env()?).await?; Ok(()) } ConfigCommands::Edit { editor } => { @@ -91,7 +92,7 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any tokio::spawn(async move { show_progress().await.unwrap(); }); - store.store(&result).await?; + store.store(&result, &StoreContext::default()).await?; Ok(()) } } diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index f0f010f5a0..f3646c3688 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -20,8 +20,11 @@ use crate::show_progress; use agama_lib::{ - base_http_client::BaseHTTPClient, install_settings::InstallSettings, - profile::ValidationOutcome, utils::FileFormat, utils::Transfer, Store as SettingsStore, + base_http_client::BaseHTTPClient, + install_settings::InstallSettings, + profile::ValidationOutcome, + utils::{FileFormat, Transfer}, + Store as SettingsStore, StoreContext, }; use anyhow::Context; use clap::Subcommand; @@ -241,7 +244,8 @@ async fn import(client: BaseHTTPClient, url_string: String) -> anyhow::Result<() // None means the profile is a script and it has been executed if let Some(profile_json) = profile_json { validate(&client, CliInput::Full(profile_json.clone())).await?; - store_settings(client, &profile_json).await?; + let context = StoreContext { source: Some(url) }; + store_settings(client, &profile_json, &context).await?; } Ok(()) } @@ -280,10 +284,14 @@ async fn pre_process_profile( } } -async fn store_settings(client: BaseHTTPClient, profile_json: &str) -> anyhow::Result<()> { +async fn store_settings( + client: BaseHTTPClient, + profile_json: &str, + context: &StoreContext, +) -> anyhow::Result<()> { let store = SettingsStore::new(client).await?; let settings: InstallSettings = serde_json::from_str(profile_json)?; - store.store(&settings).await?; + store.store(&settings, context).await?; Ok(()) } diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index bbd5fc8e5e..417512aab9 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -65,7 +65,7 @@ pub mod dbus; pub mod progress; pub mod proxies; mod store; -pub use store::Store; +pub use store::{Store, StoreContext}; pub mod openapi; pub mod questions; pub mod scripts; diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index 348bb54a4a..856fc54732 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -21,6 +21,8 @@ use crate::{ base_http_client::BaseHTTPClient, software::{model::ResolvableType, SoftwareHTTPClient, SoftwareHTTPClientError}, + url::Url, + StoreContext, }; use super::{ @@ -63,31 +65,36 @@ impl ScriptsStore { }) } - pub async fn store(&self, settings: &ScriptsConfig) -> ScriptStoreResult<()> { + pub async fn store( + &self, + settings: &ScriptsConfig, + context: &StoreContext, + ) -> ScriptStoreResult<()> { + let base_url = context.source.as_ref().map(|u| Url::Absolute(u.clone())); self.scripts.delete_scripts().await?; if let Some(scripts) = &settings.pre { for pre in scripts { - self.scripts.add_script(pre.clone().into()).await?; + self.add_script(pre.clone().into(), &base_url).await?; } } if let Some(scripts) = &settings.post_partitioning { for post in scripts { - self.scripts.add_script(post.clone().into()).await?; + self.add_script(post.clone().into(), &base_url).await?; } } if let Some(scripts) = &settings.post { for post in scripts { - self.scripts.add_script(post.clone().into()).await?; + self.add_script(post.clone().into(), &base_url).await?; } } let mut packages = vec![]; if let Some(scripts) = &settings.init { for init in scripts { - self.scripts.add_script(init.clone().into()).await?; + self.add_script(init.clone().into(), &base_url).await?; } packages.push("agama-scripts"); } @@ -98,6 +105,21 @@ impl ScriptsStore { Ok(()) } + /// Registers an script. + /// + /// If it uses a relative URL, it will be resolved against the base URL if given. + /// + /// * `script`: script definition. + /// * `base_url`: base URL to resolve the script URL against. + async fn add_script(&self, script: Script, base_url: &Option) -> ScriptStoreResult<()> { + let resolved = match base_url { + Some(source) => script.resolve_url(&source).unwrap(), + None => script, + }; + self.scripts.add_script(resolved).await?; + Ok(()) + } + fn scripts_by_type(scripts: &[Script]) -> Option> where T: TryFrom + Clone, diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 79d445ac24..521bee8a1a 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -72,6 +72,8 @@ pub enum StoreError { ScriptsClient(#[from] ScriptsClientError), #[error(transparent)] Manager(#[from] ManagerHTTPClientError), + #[error("Could not calculate the context")] + InvalidStoreContext, } /// Struct that loads/stores the settings from/to the D-Bus services. @@ -142,16 +144,20 @@ impl Store { Ok(settings) } - /// Stores the given installation settings in the D-Bus service + /// Stores the given installation settings in the Agama service /// /// As part of the process it runs pre-scripts and forces a probe if the installation phase is /// "config". It causes the storage proposal to be reset. This behavior should be revisited in /// the future but it might be the storage service the responsible for dealing with this. /// /// * `settings`: installation settings. - pub async fn store(&self, settings: &InstallSettings) -> Result<(), StoreError> { + pub async fn store( + &self, + settings: &InstallSettings, + context: &StoreContext, + ) -> Result<(), StoreError> { if let Some(scripts) = &settings.scripts { - self.scripts.store(scripts).await?; + self.scripts.store(scripts, &context).await?; if scripts.pre.as_ref().is_some_and(|s| !s.is_empty()) { self.run_pre_scripts().await?; @@ -216,3 +222,18 @@ impl Store { Ok(()) } } + +// It contains context information for the store. +#[derive(Debug, Default)] +pub struct StoreContext { + pub source: Option, +} + +impl StoreContext { + pub fn from_env() -> Result { + let current_path = std::env::current_dir().map_err(|_| StoreError::InvalidStoreContext)?; + let url = format!("file://{}", current_path.as_path().display()); + let url = url::Url::parse(&url).map_err(|_| StoreError::InvalidStoreContext)?; + Ok(Self { source: Some(url) }) + } +} From a86549ff07c7ba2d682d07d8a9dcc51920fa1398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 29 Apr 2025 11:09:07 +0100 Subject: [PATCH 04/32] feat(ruby): remove the relurl:// prefix --- service/lib/agama/autoyast/scripts_reader.rb | 2 +- service/test/agama/autoyast/scripts_reader_test.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/autoyast/scripts_reader.rb b/service/lib/agama/autoyast/scripts_reader.rb index 817bab26f8..96d9e8fc48 100755 --- a/service/lib/agama/autoyast/scripts_reader.rb +++ b/service/lib/agama/autoyast/scripts_reader.rb @@ -118,7 +118,7 @@ def read_script(section) } if section["location"] && !section["location"].empty? - script["url"] = section["location"] + script["url"] = section["location"].delete_prefix("relurl://") elsif section["source"] script["content"] = section["source"] end diff --git a/service/test/agama/autoyast/scripts_reader_test.rb b/service/test/agama/autoyast/scripts_reader_test.rb index e96c94c929..264b6e26bb 100644 --- a/service/test/agama/autoyast/scripts_reader_test.rb +++ b/service/test/agama/autoyast/scripts_reader_test.rb @@ -41,6 +41,17 @@ expect(scripts.first).to include("url" => "https://example.com/script.sh") end + context "when the script uses a relative URL" do + let(:script) do + { "location" => "relurl://script.sh" } + end + + it "removes the \"relurl\" from the \"location\"" do + scripts = subject.read["scripts"][section] + expect(scripts.first).to include("url" => "script.sh") + end + end + context "and the script filename is not specified" do let(:script) do { "location" => "https://example.com/script.sh" } From 5e1773244fdce87256ccfe962f0fe4b6c9514179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 29 Apr 2025 13:44:24 +0100 Subject: [PATCH 05/32] refactor(rust): replace our own Url with fluent_uri --- rust/Cargo.lock | 39 ++++++++++ rust/agama-cli/Cargo.toml | 1 + rust/agama-cli/src/profile.rs | 17 +++-- rust/agama-lib/Cargo.toml | 1 + rust/agama-lib/src/lib.rs | 1 - rust/agama-lib/src/scripts/error.rs | 7 +- rust/agama-lib/src/scripts/model.rs | 47 +++++++----- rust/agama-lib/src/scripts/store.rs | 21 +++-- rust/agama-lib/src/store.rs | 10 ++- rust/agama-lib/src/url.rs | 114 ---------------------------- 10 files changed, 103 insertions(+), 155 deletions(-) delete mode 100644 rust/agama-lib/src/url.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 817eaea7a1..1e97cc60e5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -28,6 +28,7 @@ dependencies = [ "clap", "console", "curl", + "fluent-uri", "home", "indicatif", "inquire", @@ -52,6 +53,7 @@ dependencies = [ "cidr", "curl", "env_logger", + "fluent-uri", "fs_extra", "futures-util", "home", @@ -722,6 +724,12 @@ dependencies = [ "piper", ] +[[package]] +name = "borrow-or-share" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" + [[package]] name = "brotli" version = "7.0.0" @@ -1391,6 +1399,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3050,6 +3069,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "regex" version = "1.11.1" diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index 315d9266fe..c04964ed76 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -25,6 +25,7 @@ chrono = "0.4.38" regex = "1.11.1" home = "0.5.11" serde = { version = "1.0.219", features = ["derive"] } +fluent-uri = "0.3.2" [[bin]] name = "agama" diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index f3646c3688..70293b02b9 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -29,6 +29,7 @@ use agama_lib::{ use anyhow::Context; use clap::Subcommand; use console::style; +use fluent_uri::Uri; use std::{ io, io::Read, @@ -231,11 +232,11 @@ async fn import(client: BaseHTTPClient, url_string: String) -> anyhow::Result<() } }); - let url = Url::parse(&url_string)?; - let path = url.path(); + let url = Uri::parse(url_string.as_str())?; + let path = url.path().to_string(); let profile_json = if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { // AutoYaST specific download and convert to JSON - let config_string = autoyast_client(&client, &url).await?; + let config_string = autoyast_client(&client, &url.to_owned()).await?; Some(config_string) } else { pre_process_profile(&client, &url_string).await? @@ -244,7 +245,9 @@ async fn import(client: BaseHTTPClient, url_string: String) -> anyhow::Result<() // None means the profile is a script and it has been executed if let Some(profile_json) = profile_json { validate(&client, CliInput::Full(profile_json.clone())).await?; - let context = StoreContext { source: Some(url) }; + let context = StoreContext { + source: Some(url.to_owned()), + }; store_settings(client, &profile_json, &context).await?; } Ok(()) @@ -299,7 +302,7 @@ async fn store_settings( /// Note that this client does not act on this *url*, it passes it as a parameter /// to our web backend. /// Return well-formed Agama JSON on success. -async fn autoyast_client(client: &BaseHTTPClient, url: &Url) -> anyhow::Result { +async fn autoyast_client(client: &BaseHTTPClient, url: &Uri) -> anyhow::Result { // FIXME: how to escape it? let api_url = format!("/profile/autoyast?url={}", url); let output: Box = client.post(&api_url, &()).await?; @@ -308,8 +311,8 @@ async fn autoyast_client(client: &BaseHTTPClient, url: &Url) -> anyhow::Result anyhow::Result<()> { - let url = Url::parse(&url_string)?; - let output = autoyast_client(&client, &url).await?; + let url = Uri::parse(url_string.as_str())?; + let output = autoyast_client(&client, &url.to_owned()).await?; println!("{}", output); Ok(()) } diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 9c8276d9b1..3f41a29bf0 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -37,6 +37,7 @@ strum = { version = "0.27.1", features = ["derive"] } fs_extra = "1.3.0" serde_with = "3.12.0" regex = "1.11.1" +fluent-uri = { version = "0.3.2", features = ["serde"] } [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 417512aab9..5f922aa8ee 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -70,7 +70,6 @@ pub mod openapi; pub mod questions; pub mod scripts; pub mod security; -pub mod url; pub mod utils; use crate::error::ServiceError; diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs index 7a04b583b9..28fe1e81db 100644 --- a/rust/agama-lib/src/scripts/error.rs +++ b/rust/agama-lib/src/scripts/error.rs @@ -18,10 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use fluent_uri::error::ResolveError; use std::io; use thiserror::Error; -use crate::{url::UrlError, utils::TransferError}; +use crate::utils::TransferError; #[derive(Error, Debug)] pub enum ScriptError { @@ -31,6 +32,6 @@ pub enum ScriptError { InputOutputError(#[from] io::Error), #[error("Wrong script type")] WrongScriptType, - #[error("Invalid URL: {0}")] - InvalidUrl(#[from] UrlError), + #[error("Could not resolve the URL: {0}")] + ResolveUrlError(String, #[source] ResolveError), } diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index d27841ed09..4b45142046 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -26,13 +26,11 @@ use std::{ process, }; +use fluent_uri::{Uri, UriRef}; use serde::{Deserialize, Serialize}; use serde_with::serde_as; -use crate::{ - url::{Url, UrlError}, - utils::Transfer, -}; +use crate::utils::Transfer; use super::ScriptError; @@ -81,7 +79,7 @@ impl BaseScript { /// Returns the base script using an absolute URL if it was using a relative one. /// /// * `base`: base URL. - fn resolve_url(&self, base: &Url) -> Result { + fn resolve_url(&self, base: &Uri) -> Result { let mut clone = self.clone(); clone.source = self.source.resolve_url(base)?; Ok(clone) @@ -95,7 +93,10 @@ pub enum ScriptSource { /// Script's content. Text { content: String }, /// URL to get the script from. - Remote { url: Url }, + Remote { + #[schema(value_type = String)] + url: UriRef, + }, } impl ScriptSource { @@ -104,14 +105,22 @@ impl ScriptSource { /// If it was not using a relative URL, it just returns a clone. /// /// * `base`: base URL. - pub fn resolve_url(&self, base: &Url) -> Result { + pub fn resolve_url(&self, base: &Uri) -> Result { let resolved = match self { Self::Text { content } => Self::Text { content: content.clone(), }, - Self::Remote { url } => Self::Remote { - url: base.join(&url.to_string())?, - }, + Self::Remote { url } => { + let resolved_url = if url.has_scheme() { + url.clone() + } else { + let resolved = url + .resolve_against(base) + .map_err(|e| ScriptError::ResolveUrlError(url.to_string(), e))?; + UriRef::parse(resolved.to_string()).unwrap() + }; + Self::Remote { url: resolved_url } + } }; Ok(resolved) } @@ -208,7 +217,7 @@ impl Script { /// Resolves the URL of the script. /// /// This method returns a new `Script` instance with the resolved URL. - pub fn resolve_url(&self, base: &Url) -> Result { + pub fn resolve_url(&self, base: &Uri) -> Result { let mut clone = self.clone(); let clone_base = clone.base_mut(); *clone_base = self.base().resolve_url(base)?; @@ -443,13 +452,11 @@ impl ScriptRunner { #[cfg(test)] mod test { + use fluent_uri::{Uri, UriRef}; use tempfile::TempDir; use tokio::test; - use crate::{ - scripts::{BaseScript, PreScript, Script, ScriptSource}, - url::Url, - }; + use crate::scripts::{BaseScript, PreScript, Script, ScriptSource}; use super::{ScriptsGroup, ScriptsRepository}; @@ -522,17 +529,17 @@ mod test { #[test] async fn test_resolve_url_relative() { - let base_url = Url::parse("http://example.lan/sles").unwrap(); + let base_url = Uri::parse("http://example.lan/sles").unwrap().to_owned(); let relative = BaseScript { name: "test".to_string(), source: ScriptSource::Remote { - url: Url::parse("../agama/enable-sshd.sh").unwrap(), + url: UriRef::parse("../agama/enable-sshd.sh").unwrap().to_owned(), }, }; let script = Script::Pre(PreScript { base: relative }); let resolved = script.resolve_url(&base_url).unwrap(); - let expected_url = Url::parse("http://example.lan/agama/enable-sshd.sh").unwrap(); + let expected_url = UriRef::parse("http://example.lan/agama/enable-sshd.sh").unwrap(); assert!(matches!( resolved.source(), @@ -542,12 +549,12 @@ mod test { let absolute = BaseScript { name: "test".to_string(), source: ScriptSource::Remote { - url: Url::parse("http://example.orig").unwrap(), + url: UriRef::parse("http://example.orig").unwrap().to_owned(), }, }; let script = Script::Pre(PreScript { base: absolute }); let resolved = script.resolve_url(&base_url).unwrap(); - let expected_url = Url::parse("http://example.orig").unwrap(); + let expected_url = UriRef::parse("http://example.orig").unwrap().to_owned(); assert!(matches!( resolved.source(), diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index 856fc54732..5ce81778dc 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -18,10 +18,11 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. +use fluent_uri::Uri; + use crate::{ base_http_client::BaseHTTPClient, software::{model::ResolvableType, SoftwareHTTPClient, SoftwareHTTPClientError}, - url::Url, StoreContext, }; @@ -70,31 +71,33 @@ impl ScriptsStore { settings: &ScriptsConfig, context: &StoreContext, ) -> ScriptStoreResult<()> { - let base_url = context.source.as_ref().map(|u| Url::Absolute(u.clone())); self.scripts.delete_scripts().await?; if let Some(scripts) = &settings.pre { for pre in scripts { - self.add_script(pre.clone().into(), &base_url).await?; + self.add_script(pre.clone().into(), &context.source).await?; } } if let Some(scripts) = &settings.post_partitioning { for post in scripts { - self.add_script(post.clone().into(), &base_url).await?; + self.add_script(post.clone().into(), &context.source) + .await?; } } if let Some(scripts) = &settings.post { for post in scripts { - self.add_script(post.clone().into(), &base_url).await?; + self.add_script(post.clone().into(), &context.source) + .await?; } } let mut packages = vec![]; if let Some(scripts) = &settings.init { for init in scripts { - self.add_script(init.clone().into(), &base_url).await?; + self.add_script(init.clone().into(), &context.source) + .await?; } packages.push("agama-scripts"); } @@ -111,7 +114,11 @@ impl ScriptsStore { /// /// * `script`: script definition. /// * `base_url`: base URL to resolve the script URL against. - async fn add_script(&self, script: Script, base_url: &Option) -> ScriptStoreResult<()> { + async fn add_script( + &self, + script: Script, + base_url: &Option>, + ) -> ScriptStoreResult<()> { let resolved = match base_url { Some(source) => script.resolve_url(&source).unwrap(), None => script, diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 521bee8a1a..751ea554fd 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -21,6 +21,8 @@ //! Load/store the settings from/to the D-Bus services. // TODO: quickly explain difference between FooSettings and FooStore, with an example +use fluent_uri::Uri; + use crate::{ base_http_client::BaseHTTPClient, bootloader::store::{BootloaderStore, BootloaderStoreError}, @@ -226,14 +228,16 @@ impl Store { // It contains context information for the store. #[derive(Debug, Default)] pub struct StoreContext { - pub source: Option, + pub source: Option>, } impl StoreContext { pub fn from_env() -> Result { let current_path = std::env::current_dir().map_err(|_| StoreError::InvalidStoreContext)?; let url = format!("file://{}", current_path.as_path().display()); - let url = url::Url::parse(&url).map_err(|_| StoreError::InvalidStoreContext)?; - Ok(Self { source: Some(url) }) + let url = Uri::parse(url.as_str()).map_err(|_| StoreError::InvalidStoreContext)?; + Ok(Self { + source: Some(url.to_owned()), + }) } } diff --git a/rust/agama-lib/src/url.rs b/rust/agama-lib/src/url.rs deleted file mode 100644 index d0d3db8ef7..0000000000 --- a/rust/agama-lib/src/url.rs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) [2025] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use serde::{Deserialize, Serialize}; - -/// Represents a URL for scripts and files. -/// -/// It extends the original [url::Url] struct with support for relative URLs -/// (`relurl:///` in YaST). -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, utoipa::ToSchema)] -#[serde(untagged)] -pub enum Url { - Absolute(url::Url), - Relative(String), -} - -#[derive(Debug, thiserror::Error)] -pub enum UrlError { - #[error("Error parsing URL: {0} ({1})")] - ParseError(String, url::ParseError), - #[error("Cannot resolve an absolute URL")] - CannotResolveAbsoluteUrl(url::Url), - #[error("Cannot join URL")] - CannotJoin(url::ParseError), -} - -impl Url { - /// Parses a string representing a URL into a Url enum. - pub fn parse(url: &str) -> Result { - match url::Url::parse(url) { - Ok(url) => Ok(Url::Absolute(url)), - Err(url::ParseError::RelativeUrlWithoutBase) => Ok(Url::Relative(url.to_string())), - Err(err) => Err(UrlError::ParseError(url.to_string(), err)), - } - } - - pub fn to_string(&self) -> String { - match self { - Url::Absolute(url) => url.to_string(), - Url::Relative(url) => url.to_string(), - } - } - - pub fn join(&self, input: &str) -> Result { - match self { - Url::Absolute(url) => { - let joined = url.join(input).map_err(|e| UrlError::CannotJoin(e))?; - Ok(Url::Absolute(joined)) - } - Url::Relative(url) => { - let joined = format!("{url}/{input}"); - Ok(Url::Relative(joined)) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_absolute_url() { - let url = Url::parse("https://example.com").unwrap(); - assert!(matches!(url, Url::Absolute(_))); - } - - #[test] - fn test_parse_relative_url() { - let url = Url::parse("/path/to/profile").unwrap(); - assert!(matches!(url, Url::Relative(_))); - } - - #[test] - fn test_parse_invalid_url() { - let result = Url::parse("http:///"); - dbg!(&result); - assert!(result.is_err()); - } - - #[test] - fn test_join_absolute_url() { - let url = Url::parse("https://example.com").unwrap(); - let joined = url.join("test").unwrap(); - assert_eq!(&joined.to_string(), "https://example.com/test"); - - let joined = url.join("https://override.lan/").unwrap(); - assert_eq!(&joined.to_string(), "https://override.lan/"); - } - - #[test] - fn test_join_relative_url() { - let url = Url::parse("/path/to").unwrap(); - let joined = url.join("profile").unwrap(); - assert_eq!(&joined.to_string(), "/path/to/profile"); - } -} From 12239965bf200bf746bec0d3e8550b08f52a1449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 29 Apr 2025 15:04:12 +0100 Subject: [PATCH 06/32] fix(rust): drop unneeded serde_as --- rust/agama-lib/src/scripts/model.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 4b45142046..7bfbce6d97 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -28,11 +28,9 @@ use std::{ use fluent_uri::{Uri, UriRef}; use serde::{Deserialize, Serialize}; -use serde_with::serde_as; - -use crate::utils::Transfer; use super::ScriptError; +use crate::utils::Transfer; #[derive( Debug, Clone, Copy, PartialEq, strum::Display, Serialize, Deserialize, utoipa::ToSchema, From 0c60dd42fd9f080fbe3b04003ac35665c5f2658f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 29 Apr 2025 15:05:31 +0100 Subject: [PATCH 07/32] chore(rust): imprrove ScriptSource OpenAPI description --- rust/agama-lib/src/scripts/model.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 7bfbce6d97..988e8ef033 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -84,15 +84,14 @@ impl BaseScript { } } -#[serde_as] #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(untagged)] pub enum ScriptSource { /// Script's content. Text { content: String }, - /// URL to get the script from. + /// URI or relative reference to get the script from. Remote { - #[schema(value_type = String)] + #[schema(value_type = String, examples("http://example.com/script.sh", "/script.sh"))] url: UriRef, }, } From 714c163876225c04d2361b2e8e902905142a064a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Tue, 29 Apr 2025 17:19:47 +0100 Subject: [PATCH 08/32] refactor(rust): unify ScriptSouce y FileSource --- rust/agama-lib/src/file_source.rs | 167 ++++++++++++++++++++++ rust/agama-lib/src/files/error.rs | 4 +- rust/agama-lib/src/files/model.rs | 37 +---- rust/agama-lib/src/lib.rs | 1 + rust/agama-lib/src/scripts/error.rs | 7 +- rust/agama-lib/src/scripts/model.rs | 89 +++--------- rust/agama-server/src/web/docs/scripts.rs | 2 +- 7 files changed, 198 insertions(+), 109 deletions(-) create mode 100644 rust/agama-lib/src/file_source.rs diff --git a/rust/agama-lib/src/file_source.rs b/rust/agama-lib/src/file_source.rs new file mode 100644 index 0000000000..04389b6dbc --- /dev/null +++ b/rust/agama-lib/src/file_source.rs @@ -0,0 +1,167 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::utils::{Transfer, TransferError}; +use fluent_uri::{error::ResolveError, Uri, UriRef}; +use serde::{Deserialize, Serialize}; +use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt, path::Path}; + +#[derive(Debug, thiserror::Error)] +pub enum FileSourceError { + #[error("Could not resolve the URL: {0}")] + ResolveUrlError(String, #[source] ResolveError), + #[error("Transfer error: {0}")] + TransferFailed(#[from] TransferError), + #[error("I/O error: {0}")] + IO(#[from] std::io::Error), +} + +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum FileSource { + /// File content. + Text { content: String }, + /// URI or relative reference to get the script from. + Remote { + #[schema(value_type = String, examples("http://example.com/script.sh", "/file.txt"))] + url: UriRef, + }, +} + +impl FileSource { + /// Returns a new source using an absolute URL if it was using a relative one. + /// + /// If it was not using a relative URL, it just returns a clone. + /// + /// * `base`: base URL. + pub fn resolve_url(&self, base: &Uri) -> Result { + let resolved = match self { + Self::Text { content } => Self::Text { + content: content.clone(), + }, + Self::Remote { url } => { + let resolved_url = if url.has_scheme() { + url.clone() + } else { + let resolved = url + .resolve_against(base) + .map_err(|e| FileSourceError::ResolveUrlError(url.to_string(), e))?; + UriRef::parse(resolved.to_string()).unwrap() + }; + Self::Remote { url: resolved_url } + } + }; + Ok(resolved) + } + + /// Writes the file to the given writer. + /// + /// * `file`: where to write the data. + pub fn write>(&self, path: P, mode: u32) -> Result<(), FileSourceError> { + let mut file = OpenOptions::new() + .mode(mode) + .write(true) + .create(true) + .open(path)?; + + match &self { + FileSource::Text { content } => write!(file, "{}", &content)?, + FileSource::Remote { url } => Transfer::get(&url.to_string(), &mut file)?, + } + + file.flush()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::{fs::File, io::Write}; + + use fluent_uri::{Uri, UriRef}; + + use super::FileSource; + + #[test] + fn test_write_content() { + let file = FileSource::Text { + content: "foobar".to_string(), + }; + + let tmpdir = tempfile::TempDir::with_prefix("agama-tests-").unwrap(); + let target = tmpdir.path().join("foobar.txt"); + file.write(&target, 0o400).unwrap(); + + let written = std::fs::read_to_string(&target).unwrap(); + assert_eq!(written.as_str(), "foobar"); + } + + #[test] + fn test_write_from_url() { + let tmpdir = tempfile::TempDir::with_prefix("agama-tests-").unwrap(); + let source = tmpdir.path().join("source.txt"); + let mut file = File::create(&source).unwrap(); + file.write_all(b"foobar").unwrap(); + + let url = format!("file://{}", source.display()); + let file = FileSource::Remote { + url: UriRef::parse(url).unwrap(), + }; + let target = tmpdir.path().join("foobar.txt"); + file.write(&target, 0o400).unwrap(); + + let written = std::fs::read_to_string(&target).unwrap(); + assert_eq!(written.as_str(), "foobar"); + } + + #[test] + fn test_resolve_url_relative() { + let file = FileSource::Remote { + url: UriRef::parse("file.txt").unwrap().to_owned(), + }; + + let base_url = Uri::parse("http://example.lan/sles").unwrap().to_owned(); + let resolved = file.resolve_url(&base_url).unwrap(); + let expected_url = "http://example.lan/file.txt"; + + assert!(matches!( + resolved, + FileSource::Remote { url } if url.as_str() == expected_url + )); + } + + #[test] + fn test_resolve_url_absolute() { + let file = FileSource::Remote { + url: UriRef::parse("http://example.lan/agama/file.txt") + .unwrap() + .to_owned(), + }; + + let base_url = Uri::parse("http://example.lan/sles").unwrap().to_owned(); + let resolved = file.resolve_url(&base_url).unwrap(); + let expected_url = "http://example.lan/agama/file.txt"; + + assert!(matches!( + resolved, + FileSource::Remote { url } if url.as_str() == expected_url + )); + } +} diff --git a/rust/agama-lib/src/files/error.rs b/rust/agama-lib/src/files/error.rs index c8f3b46300..91b6378017 100644 --- a/rust/agama-lib/src/files/error.rs +++ b/rust/agama-lib/src/files/error.rs @@ -21,7 +21,7 @@ use std::{io, num::ParseIntError}; use thiserror::Error; -use crate::utils::TransferError; +use crate::{file_source::FileSourceError, utils::TransferError}; #[derive(Error, Debug)] pub enum FileError { @@ -35,4 +35,6 @@ pub enum FileError { OwnerChangeError(String, String), #[error("Failed to create directories: command '{0}' stderr '{1}'")] MkdirError(String, String), + #[error(transparent)] + FileSourceError(#[from] FileSourceError), } diff --git a/rust/agama-lib/src/files/model.rs b/rust/agama-lib/src/files/model.rs index 17f1eb9623..2f49b4796e 100644 --- a/rust/agama-lib/src/files/model.rs +++ b/rust/agama-lib/src/files/model.rs @@ -20,30 +20,16 @@ //! Implements a data model for Files configuration. -use serde::{Deserialize, Serialize}; -use std::fs::OpenOptions; -use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; -use std::process; - -use crate::utils::Transfer; - use super::error::FileError; - -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(untagged)] -pub enum FileSource { - /// File body directly written - Text { content: String }, - /// URL to get the file from. - Remote { url: String }, -} +use crate::file_source::FileSource; +use serde::{Deserialize, Serialize}; +use std::{path::Path, process}; /// Represents individual settings for single file deployment #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct UserFile { + /// File content or URL. #[serde(flatten)] pub source: FileSource, /// Permissions for file @@ -119,20 +105,7 @@ impl UserFile { )); } // cannot set owner here as user and group can exist only on target destination - let mut target = OpenOptions::new() - .mode(int_mode) - .write(true) - .create(true) - .open(path)?; - match &self.source { - FileSource::Remote { url } => { - Transfer::get(url, &mut target)?; - } - FileSource::Text { content } => { - target.write_all(content.as_bytes())?; - } - } - target.flush()?; + self.source.write(path, int_mode)?; let mut cmd2 = process::Command::new("chroot"); cmd2.args([ diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 5f922aa8ee..9cd71e1361 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -47,6 +47,7 @@ pub mod auth; pub mod base_http_client; pub mod bootloader; pub mod error; +pub mod file_source; pub mod files; pub mod hostname; pub mod install_settings; diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs index 28fe1e81db..3e9b561fc7 100644 --- a/rust/agama-lib/src/scripts/error.rs +++ b/rust/agama-lib/src/scripts/error.rs @@ -18,11 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use fluent_uri::error::ResolveError; use std::io; use thiserror::Error; -use crate::utils::TransferError; +use crate::{file_source::FileSourceError, utils::TransferError}; #[derive(Error, Debug)] pub enum ScriptError { @@ -32,6 +31,6 @@ pub enum ScriptError { InputOutputError(#[from] io::Error), #[error("Wrong script type")] WrongScriptType, - #[error("Could not resolve the URL: {0}")] - ResolveUrlError(String, #[source] ResolveError), + #[error(transparent)] + FileSourceError(#[from] FileSourceError), } diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 988e8ef033..391eaab89a 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -20,17 +20,15 @@ use std::{ fs, - io::Write, - os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, process, }; -use fluent_uri::{Uri, UriRef}; +use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use super::ScriptError; -use crate::utils::Transfer; +use crate::file_source::FileSource; #[derive( Debug, Clone, Copy, PartialEq, strum::Display, Serialize, Deserialize, utoipa::ToSchema, @@ -48,7 +46,7 @@ pub enum ScriptsGroup { pub struct BaseScript { pub name: String, #[serde(flatten)] - pub source: ScriptSource, + pub source: FileSource, } impl BaseScript { @@ -58,19 +56,7 @@ impl BaseScript { fn write>(&self, workdir: P) -> Result<(), ScriptError> { let script_path = workdir.as_ref().join(&self.name); std::fs::create_dir_all(script_path.parent().unwrap())?; - - let mut file = fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o500) - .open(&script_path)?; - - match &self.source { - ScriptSource::Text { content } => write!(file, "{}", &content)?, - ScriptSource::Remote { url } => Transfer::get(&url.to_string(), &mut file)?, - }; - + self.source.write(&script_path, 0o500)?; Ok(()) } @@ -84,45 +70,6 @@ impl BaseScript { } } -#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(untagged)] -pub enum ScriptSource { - /// Script's content. - Text { content: String }, - /// URI or relative reference to get the script from. - Remote { - #[schema(value_type = String, examples("http://example.com/script.sh", "/script.sh"))] - url: UriRef, - }, -} - -impl ScriptSource { - /// Returns a new source using an absolute URL if it was using a relative one. - /// - /// If it was not using a relative URL, it just returns a clone. - /// - /// * `base`: base URL. - pub fn resolve_url(&self, base: &Uri) -> Result { - let resolved = match self { - Self::Text { content } => Self::Text { - content: content.clone(), - }, - Self::Remote { url } => { - let resolved_url = if url.has_scheme() { - url.clone() - } else { - let resolved = url - .resolve_against(base) - .map_err(|e| ScriptError::ResolveUrlError(url.to_string(), e))?; - UriRef::parse(resolved.to_string()).unwrap() - }; - Self::Remote { url: resolved_url } - } - }; - Ok(resolved) - } -} - /// Represents a script to run as part of the installation process. /// /// There are different types of scripts that can run at different stages of the installation. @@ -182,7 +129,7 @@ impl Script { /// Script's source. /// /// It returns the script source. - pub fn source(&self) -> &ScriptSource { + pub fn source(&self) -> &FileSource { &self.base().source } @@ -453,7 +400,10 @@ mod test { use tempfile::TempDir; use tokio::test; - use crate::scripts::{BaseScript, PreScript, Script, ScriptSource}; + use crate::{ + file_source::FileSource, + scripts::{BaseScript, PreScript, Script}, + }; use super::{ScriptsGroup, ScriptsRepository}; @@ -464,7 +414,7 @@ mod test { let base = BaseScript { name: "test".to_string(), - source: ScriptSource::Text { + source: FileSource::Text { content: "".to_string(), }, }; @@ -486,7 +436,7 @@ mod test { let base = BaseScript { name: "test".to_string(), - source: ScriptSource::Text { content }, + source: FileSource::Text { content }, }; let script = Script::Pre(PreScript { base }); repo.add(script).unwrap(); @@ -513,7 +463,7 @@ mod test { let base = BaseScript { name: "test".to_string(), - source: ScriptSource::Text { content }, + source: FileSource::Text { content }, }; let script = Script::Pre(PreScript { base }); repo.add(script).expect("add the script to the repository"); @@ -530,7 +480,7 @@ mod test { let relative = BaseScript { name: "test".to_string(), - source: ScriptSource::Remote { + source: FileSource::Remote { url: UriRef::parse("../agama/enable-sshd.sh").unwrap().to_owned(), }, }; @@ -540,12 +490,12 @@ mod test { assert!(matches!( resolved.source(), - ScriptSource::Remote { url } if url == &expected_url + FileSource::Remote { url } if url == &expected_url )); let absolute = BaseScript { name: "test".to_string(), - source: ScriptSource::Remote { + source: FileSource::Remote { url: UriRef::parse("http://example.orig").unwrap().to_owned(), }, }; @@ -555,21 +505,18 @@ mod test { assert!(matches!( resolved.source(), - ScriptSource::Remote { url } if url == &expected_url + FileSource::Remote { url } if url == &expected_url )); let text = BaseScript { name: "test".to_string(), - source: ScriptSource::Text { + source: FileSource::Text { content: "#!/bin/bash\necho hello".to_string(), }, }; let script = Script::Pre(PreScript { base: text }); let resolved = script.resolve_url(&base_url).unwrap(); - assert!(matches!( - resolved.source(), - ScriptSource::Text { content: _ } - )); + assert!(matches!(resolved.source(), FileSource::Text { content: _ })); } } diff --git a/rust/agama-server/src/web/docs/scripts.rs b/rust/agama-server/src/web/docs/scripts.rs index 72cf63b98d..ade78ea2f5 100644 --- a/rust/agama-server/src/web/docs/scripts.rs +++ b/rust/agama-server/src/web/docs/scripts.rs @@ -46,7 +46,7 @@ impl ApiDocBuilder for ScriptsApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .build() } } From e7b3271750b4ec50f70641b5764e99ca3f428a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 07:19:21 +0100 Subject: [PATCH 09/32] refactor(rust): move logic to resolve URLs to a WithFileSource trait --- rust/agama-lib/src/file_source.rs | 19 ++++++++++++ rust/agama-lib/src/scripts/model.rs | 47 +++++++++++------------------ rust/agama-lib/src/scripts/store.rs | 5 ++- rust/agama-lib/src/store.rs | 4 +-- 4 files changed, 43 insertions(+), 32 deletions(-) diff --git a/rust/agama-lib/src/file_source.rs b/rust/agama-lib/src/file_source.rs index 04389b6dbc..357237f78d 100644 --- a/rust/agama-lib/src/file_source.rs +++ b/rust/agama-lib/src/file_source.rs @@ -91,6 +91,25 @@ impl FileSource { } } +/// Implements an API to work with a file source. +pub trait WithFileSource: Clone { + /// File source. + fn file_source(&self) -> &FileSource; + + /// Mutable file source. + fn file_source_mut(&mut self) -> &mut FileSource; + + /// Returns a clone using an absolute URL for the file source. + /// + /// * `base`: base URL. + fn resolve_url(&self, base: &Uri) -> Result { + let mut clone = self.clone(); + let source = clone.file_source_mut(); + *source = self.file_source().resolve_url(base)?; + Ok(clone) + } +} + #[cfg(test)] mod tests { use std::{fs::File, io::Write}; diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 391eaab89a..939f181e14 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -28,7 +28,7 @@ use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use super::ScriptError; -use crate::file_source::FileSource; +use crate::file_source::{FileSource, WithFileSource}; #[derive( Debug, Clone, Copy, PartialEq, strum::Display, Serialize, Deserialize, utoipa::ToSchema, @@ -59,15 +59,6 @@ impl BaseScript { self.source.write(&script_path, 0o500)?; Ok(()) } - - /// Returns the base script using an absolute URL if it was using a relative one. - /// - /// * `base`: base URL. - fn resolve_url(&self, base: &Uri) -> Result { - let mut clone = self.clone(); - clone.source = self.source.resolve_url(base)?; - Ok(clone) - } } /// Represents a script to run as part of the installation process. @@ -126,13 +117,6 @@ impl Script { } } - /// Script's source. - /// - /// It returns the script source. - pub fn source(&self) -> &FileSource { - &self.base().source - } - /// Runs the script in the given work directory. /// /// It saves the logs and the exit status of the execution. @@ -157,15 +141,17 @@ impl Script { runner.run(&path) } +} - /// Resolves the URL of the script. - /// - /// This method returns a new `Script` instance with the resolved URL. - pub fn resolve_url(&self, base: &Uri) -> Result { - let mut clone = self.clone(); - let clone_base = clone.base_mut(); - *clone_base = self.base().resolve_url(base)?; - Ok(clone) +impl WithFileSource for Script { + /// Script's file source. + fn file_source(&self) -> &FileSource { + &self.base().source + } + + /// Mutable script's file source. + fn file_source_mut(&mut self) -> &mut FileSource { + &mut self.base_mut().source } } @@ -401,7 +387,7 @@ mod test { use tokio::test; use crate::{ - file_source::FileSource, + file_source::{FileSource, WithFileSource}, scripts::{BaseScript, PreScript, Script}, }; @@ -489,7 +475,7 @@ mod test { let expected_url = UriRef::parse("http://example.lan/agama/enable-sshd.sh").unwrap(); assert!(matches!( - resolved.source(), + resolved.file_source(), FileSource::Remote { url } if url == &expected_url )); @@ -504,7 +490,7 @@ mod test { let expected_url = UriRef::parse("http://example.orig").unwrap().to_owned(); assert!(matches!( - resolved.source(), + resolved.file_source(), FileSource::Remote { url } if url == &expected_url )); @@ -517,6 +503,9 @@ mod test { let script = Script::Pre(PreScript { base: text }); let resolved = script.resolve_url(&base_url).unwrap(); - assert!(matches!(resolved.source(), FileSource::Text { content: _ })); + assert!(matches!( + resolved.file_source(), + FileSource::Text { content: _ } + )); } } diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index 5ce81778dc..94750721a6 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -22,6 +22,7 @@ use fluent_uri::Uri; use crate::{ base_http_client::BaseHTTPClient, + file_source::{FileSourceError, WithFileSource}, software::{model::ResolvableType, SoftwareHTTPClient, SoftwareHTTPClientError}, StoreContext, }; @@ -38,6 +39,8 @@ pub enum ScriptsStoreError { Script(#[from] ScriptsClientError), #[error("Error selecting software: {0}")] Software(#[from] SoftwareHTTPClientError), + #[error(transparent)] + FileSourceError(#[from] FileSourceError), } type ScriptStoreResult = Result; @@ -120,7 +123,7 @@ impl ScriptsStore { base_url: &Option>, ) -> ScriptStoreResult<()> { let resolved = match base_url { - Some(source) => script.resolve_url(&source).unwrap(), + Some(source) => script.resolve_url(&source)?, None => script, }; self.scripts.add_script(resolved).await?; diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 751ea554fd..d53d94ede5 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -159,7 +159,7 @@ impl Store { context: &StoreContext, ) -> Result<(), StoreError> { if let Some(scripts) = &settings.scripts { - self.scripts.store(scripts, &context).await?; + self.scripts.store(scripts, context).await?; if scripts.pre.as_ref().is_some_and(|s| !s.is_empty()) { self.run_pre_scripts().await?; @@ -167,7 +167,7 @@ impl Store { } if let Some(files) = &settings.files { - self.files.store(files).await?; + self.files.store(files, context).await?; } // import the users (esp. the root password) before initializing software, From c5fcb9ac8e01e6a688716808c5cda61451005e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 08:08:00 +0100 Subject: [PATCH 10/32] feat(rust): support relative URLs for files --- rust/agama-lib/src/files/model.rs | 14 +++++++++++++- rust/agama-lib/src/files/store.rs | 24 +++++++++++++++++++++--- rust/agama-lib/src/scripts/store.rs | 8 +++++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/rust/agama-lib/src/files/model.rs b/rust/agama-lib/src/files/model.rs index 2f49b4796e..5761ef399b 100644 --- a/rust/agama-lib/src/files/model.rs +++ b/rust/agama-lib/src/files/model.rs @@ -21,7 +21,7 @@ //! Implements a data model for Files configuration. use super::error::FileError; -use crate::file_source::FileSource; +use crate::file_source::{FileSource, WithFileSource}; use serde::{Deserialize, Serialize}; use std::{path::Path, process}; @@ -125,3 +125,15 @@ impl UserFile { Ok(()) } } + +impl WithFileSource for UserFile { + /// File source. + fn file_source(&self) -> &FileSource { + &self.source + } + + /// Mutable file source. + fn file_source_mut(&mut self) -> &mut FileSource { + &mut self.source + } +} diff --git a/rust/agama-lib/src/files/store.rs b/rust/agama-lib/src/files/store.rs index ef951863ac..07ad02fd6a 100644 --- a/rust/agama-lib/src/files/store.rs +++ b/rust/agama-lib/src/files/store.rs @@ -24,7 +24,7 @@ use super::{ client::{FilesClient, FilesHTTPClientError}, model::UserFile, }; -use crate::base_http_client::BaseHTTPClient; +use crate::{base_http_client::BaseHTTPClient, file_source::WithFileSource, StoreContext}; #[derive(Debug, thiserror::Error)] #[error("Error processing files settings: {0}")] @@ -55,7 +55,25 @@ impl FilesStore { } /// stores the list of user files via http API - pub async fn store(&self, files: &Vec) -> FilesStoreResult<()> { - Ok(self.files_client.set_files(files).await?) + pub async fn store( + &self, + files: &Vec, + context: &StoreContext, + ) -> FilesStoreResult<()> { + let resolved_files = if let Some(base) = &context.source { + &files + .iter() + .filter_map(|file| { + file.resolve_url(&base) + .inspect_err(|e| { + log::warn!("Error processing file {}: {e}", file.destination) + }) + .ok() + }) + .collect() + } else { + files + }; + Ok(self.files_client.set_files(&resolved_files).await?) } } diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index 94750721a6..266e5d87b3 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -123,7 +123,13 @@ impl ScriptsStore { base_url: &Option>, ) -> ScriptStoreResult<()> { let resolved = match base_url { - Some(source) => script.resolve_url(&source)?, + Some(source) => match script.resolve_url(&source) { + Ok(resolved) => resolved, + Err(e) => { + log::warn!("Error processing script {}: {e}", script.name()); + return Ok(()); + } + }, None => script, }; self.scripts.add_script(resolved).await?; From b8700ed552fca3c02f567c18f99cc635bdcd6b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 08:48:32 +0100 Subject: [PATCH 11/32] fix(ruby): fix conversion of file_owner --- service/lib/agama/autoyast/files_reader.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/service/lib/agama/autoyast/files_reader.rb b/service/lib/agama/autoyast/files_reader.rb index b9e11950fe..04145c6366 100755 --- a/service/lib/agama/autoyast/files_reader.rb +++ b/service/lib/agama/autoyast/files_reader.rb @@ -86,10 +86,11 @@ def file_owner(file) res = {} return res if file.nil? || file.empty? || !file["file_owner"] - user, group = file["file_owner"].split(".", 2) + # a colon if preferred, but the dot is documented too + user, group = file["file_owner"].split(/[:.]/, 2) - res["user"] = user if user - res["group"] = group if group + res["user"] = user unless user.to_s.empty? + res["group"] = group unless group.to_s.empty? res end From b214bd9545162d9e1e5cd34aac302f46955a5429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 08:49:27 +0100 Subject: [PATCH 12/32] chore(ruby): add tests for the FilesReader class --- .../test/agama/autoyast/files_reader_test.rb | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 service/test/agama/autoyast/files_reader_test.rb diff --git a/service/test/agama/autoyast/files_reader_test.rb b/service/test/agama/autoyast/files_reader_test.rb new file mode 100644 index 0000000000..2c1039c112 --- /dev/null +++ b/service/test/agama/autoyast/files_reader_test.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +# Copyright (c) [2025] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# 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. + +require_relative "../../test_helper" +require "yast" +require "agama/autoyast/files_reader" + +Yast.import "Profile" + +describe Agama::AutoYaST::FilesReader do + let(:profile) do + { + "files" => [file] + } + end + + subject do + described_class.new(Yast::ProfileHash.new(profile)) + end + + let(:file) do + { + "file_path" => "/etc/issue.d/motd", + "file_contents" => "Hello!", + "file_owner" => owner, + "file_permissions" => "0400" + } + end + + let(:owner) { "root:wheel" } + + describe "#read" do + context "when there is no \"files\" section" do + let(:profile) { {} } + + it "returns an empty hash" do + expect(subject.read).to be_empty + end + end + + it "returns an array with a hash per file" do + files = subject.read["files"] + expect(files.size).to eq(1) + expect(files[0]).to be_a(Hash) + end + + it "includes the path as \"destination\"" do + files = subject.read["files"] + expect(files[0]).to include("destination" => "/etc/issue.d/motd") + end + + it "includes the permissions" do + files = subject.read["files"] + expect(files[0]).to include("permissions" => "0400") + end + + it "includes the user and the group" do + files = subject.read["files"] + expect(files[0]).to include("user" => "root") + expect(files[0]).to include("group" => "wheel") + end + + context "when only the user is given" do + let(:owner) { "root" } + + it "includes the user but not the group" do + files = subject.read["files"] + expect(files[0]).to include("user" => "root") + expect(files[0]["group"]).to be_nil + end + end + + context "when only the group is given" do + let(:owner) { ":wheel" } + + it "includes the group but not the user" do + files = subject.read["files"] + expect(files[0]).to include("group" => "wheel") + expect(files[0]["user"]).to be_nil + end + end + + context "when user and group are separted by a dot" do + let(:owner) { "root.wheel" } + + it "includes the user and the group" do + files = subject.read["files"] + expect(files[0]).to include("user" => "root") + expect(files[0]).to include("group" => "wheel") + end + end + + context "when the content is given" do + it "includes the content" do + files = subject.read["files"] + expect(files[0]).to include("content" => "Hello!") + end + end + + context "when a location is given" do + let(:file) do + { + "file_location" => "https://example.com/file.txt" + } + end + + it "includes the location as \"url\"" do + files = subject.read["files"] + expect(files[0]).to include("url" => file["file_location"]) + end + end + end +end From 720c9d8c5ada5bc3e2f103bc1af9e46017ef625e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 08:50:17 +0100 Subject: [PATCH 13/32] feat(ruby): remove the relurl:// schema when reading files --- service/lib/agama/autoyast/files_reader.rb | 2 +- service/test/agama/autoyast/files_reader_test.rb | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/service/lib/agama/autoyast/files_reader.rb b/service/lib/agama/autoyast/files_reader.rb index 04145c6366..b086651989 100755 --- a/service/lib/agama/autoyast/files_reader.rb +++ b/service/lib/agama/autoyast/files_reader.rb @@ -74,7 +74,7 @@ def file_source(file) return {} if file.nil? || file.empty? if file.key?("file_location") - { "url" => file["file_location"] } + { "url" => file["file_location"].delete_prefix("relurl://") } elsif file.key?("file_contents") { "content" => file["file_contents"] } else diff --git a/service/test/agama/autoyast/files_reader_test.rb b/service/test/agama/autoyast/files_reader_test.rb index 2c1039c112..5c08d3d8f2 100644 --- a/service/test/agama/autoyast/files_reader_test.rb +++ b/service/test/agama/autoyast/files_reader_test.rb @@ -127,5 +127,18 @@ expect(files[0]).to include("url" => file["file_location"]) end end + + context "when a relative location is given" do + let(:file) do + { + "file_location" => "relurl://file.txt" + } + end + + it "includes the location without the \"relurl://\" prefix" do + files = subject.read["files"] + expect(files[0]).to include("url" => "file.txt") + end + end end end From eeee6384ef660c4b36c598c49c518ddbaa22a30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 09:14:11 +0100 Subject: [PATCH 14/32] fix(rust): do not export the chroot if not given --- rust/agama-lib/src/scripts/model.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 939f181e14..af355fd61b 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -220,6 +220,7 @@ pub struct PostScript { #[serde(flatten)] pub base: BaseScript, /// Whether the script should be run in a chroot environment. + #[serde(skip_serializing_if = "Option::is_none")] pub chroot: Option, } From f0f2b48c45f1882fe5ff4b17189065a247272bc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 09:15:55 +0100 Subject: [PATCH 15/32] chore(rust): drop unneeded use --- rust/agama-lib/src/scripts/model.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index af355fd61b..dfe6df68c0 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -24,7 +24,6 @@ use std::{ process, }; -use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use super::ScriptError; From 60b9cee1b278cf615f029ae11a3b0f4ff63497d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 09:20:10 +0100 Subject: [PATCH 16/32] fix(rust): fix a typo --- rust/agama-lib/src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 788d8f5a65..a500ad2568 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -59,7 +59,7 @@ pub enum ServiceError { // FIXME reroute the error to a better place #[error("Profile error: {0}")] Profile(#[from] ProfileError), - #[error("Unsupported SSL Fingeprint algorithm '#{0}'.")] + #[error("Unsupported SSL Fingerprint algorithm '#{0}'.")] UnsupportedSSLFingerprintAlgorithm(String), } From a8dc1943f325b4f8b9d1b98c091ec28cd22ce5d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 09:23:02 +0100 Subject: [PATCH 17/32] docs: update changes files --- rust/package/agama.changes | 6 ++++++ service/package/rubygem-agama-yast.changes | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/rust/package/agama.changes b/rust/package/agama.changes index bcf4c901d1..452d084634 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Apr 30 08:18:10 UTC 2025 - Imobach Gonzalez Sosa + +- Add support for relative URLs in files and scripts definitions + (gh#agama-project/agama#2305). + ------------------------------------------------------------------- Fri Apr 25 10:50:01 UTC 2025 - Imobach Gonzalez Sosa diff --git a/service/package/rubygem-agama-yast.changes b/service/package/rubygem-agama-yast.changes index 7099fa6bb2..e1a3a00754 100644 --- a/service/package/rubygem-agama-yast.changes +++ b/service/package/rubygem-agama-yast.changes @@ -1,3 +1,9 @@ +------------------------------------------------------------------- +Wed Apr 30 08:18:27 UTC 2025 - Imobach Gonzalez Sosa + +- Convert relurl:// URLs in AutoYaST files and scripts definitions + (gh#agama-project/agama#2305). + ------------------------------------------------------------------- Fri Apr 25 13:36:13 UTC 2025 - Ancor Gonzalez Sosa From e54ba2a21b37e605db0351913e8d0cd002067edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 11:00:53 +0100 Subject: [PATCH 18/32] feat(rust): report the URL when curl fails --- rust/agama-lib/src/utils/transfer.rs | 2 ++ rust/agama-lib/src/utils/transfer/handlers/generic.rs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/rust/agama-lib/src/utils/transfer.rs b/rust/agama-lib/src/utils/transfer.rs index b60d4db2bb..af2506d278 100644 --- a/rust/agama-lib/src/utils/transfer.rs +++ b/rust/agama-lib/src/utils/transfer.rs @@ -60,6 +60,8 @@ use handlers::{DeviceHandler, GenericHandler, HdHandler, LabelHandler}; pub enum TransferError { #[error("Could not retrieve the file: {0}")] CurlError(#[from] curl::Error), + #[error("Could not retrieve '{0}': {1}")] + CurlTransferError(String, #[source] curl::Error), #[error("Could not parse the URL: {0}")] ParseError(#[from] url::ParseError), #[error("File not found: {0}")] diff --git a/rust/agama-lib/src/utils/transfer/handlers/generic.rs b/rust/agama-lib/src/utils/transfer/handlers/generic.rs index b0ddca3935..718d12d28c 100644 --- a/rust/agama-lib/src/utils/transfer/handlers/generic.rs +++ b/rust/agama-lib/src/utils/transfer/handlers/generic.rs @@ -23,7 +23,7 @@ use std::io::Write; use curl::easy::Easy; use url::Url; -use crate::utils::TransferResult; +use crate::utils::{TransferError, TransferResult}; /// Generic handler to retrieve any URL. /// @@ -40,7 +40,9 @@ impl GenericHandler { let mut transfer = handle.transfer(); transfer.write_function(|buf| Ok(out_fd.write(buf).unwrap()))?; - transfer.perform()?; + transfer + .perform() + .map_err(|e| TransferError::CurlTransferError(url.to_string(), e))?; Ok(()) } } From 0447c49d6434700708514a473a7ef686c81e48bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 13:47:39 +0100 Subject: [PATCH 19/32] Apply suggestions from code review Co-authored-by: Martin Vidner --- rust/agama-lib/src/file_source.rs | 3 ++- rust/agama-lib/src/scripts/model.rs | 2 +- rust/agama-lib/src/store.rs | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/agama-lib/src/file_source.rs b/rust/agama-lib/src/file_source.rs index 357237f78d..2515dc75d4 100644 --- a/rust/agama-lib/src/file_source.rs +++ b/rust/agama-lib/src/file_source.rs @@ -35,6 +35,7 @@ pub enum FileSourceError { #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(untagged)] +/// Text or URL Reference of a config file or a script pub enum FileSource { /// File content. Text { content: String }, @@ -82,7 +83,7 @@ impl FileSource { .open(path)?; match &self { - FileSource::Text { content } => write!(file, "{}", &content)?, + FileSource::Text { content } => file.write_all(content.as_bytes())?, FileSource::Remote { url } => Transfer::get(&url.to_string(), &mut file)?, } diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index dfe6df68c0..1cffa25f57 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -462,7 +462,7 @@ mod test { #[test] async fn test_resolve_url_relative() { - let base_url = Uri::parse("http://example.lan/sles").unwrap().to_owned(); + let base_url = Uri::parse("http://example.lan/sles.json").unwrap().to_owned(); let relative = BaseScript { name: "test".to_string(), diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index d53d94ede5..3cab50053c 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -232,6 +232,7 @@ pub struct StoreContext { } impl StoreContext { + /// Sets _source_ to the current directory pub fn from_env() -> Result { let current_path = std::env::current_dir().map_err(|_| StoreError::InvalidStoreContext)?; let url = format!("file://{}", current_path.as_path().display()); From 3bbe0669785548995bb1ca50c455de032a332696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 14:49:35 +0100 Subject: [PATCH 20/32] Apply suggestion from code review --- rust/agama-lib/src/file_source.rs | 2 +- rust/agama-lib/src/scripts/model.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/agama-lib/src/file_source.rs b/rust/agama-lib/src/file_source.rs index 2515dc75d4..585461c364 100644 --- a/rust/agama-lib/src/file_source.rs +++ b/rust/agama-lib/src/file_source.rs @@ -64,7 +64,7 @@ impl FileSource { let resolved = url .resolve_against(base) .map_err(|e| FileSourceError::ResolveUrlError(url.to_string(), e))?; - UriRef::parse(resolved.to_string()).unwrap() + UriRef::parse(resolved.to_string()).unwrap() // unwrap OK: resolved is already a Uri }; Self::Remote { url: resolved_url } } diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 1cffa25f57..a4b66518bd 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -462,7 +462,9 @@ mod test { #[test] async fn test_resolve_url_relative() { - let base_url = Uri::parse("http://example.lan/sles.json").unwrap().to_owned(); + let base_url = Uri::parse("http://example.lan/sles.json") + .unwrap() + .to_owned(); let relative = BaseScript { name: "test".to_string(), From 46408338486d71ea4f3ea1582f6562048a5e9db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 15:14:16 +0100 Subject: [PATCH 21/32] refactor(rust): make StoreContext source mandatory --- rust/agama-cli/src/config.rs | 2 +- rust/agama-cli/src/profile.rs | 2 +- rust/agama-lib/src/files/store.rs | 32 ++++++++++++++--------------- rust/agama-lib/src/scripts/store.rs | 20 ++++-------------- rust/agama-lib/src/store.rs | 6 +++--- 5 files changed, 24 insertions(+), 38 deletions(-) diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index ea07bed540..6158d3a221 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -92,7 +92,7 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any tokio::spawn(async move { show_progress().await.unwrap(); }); - store.store(&result, &StoreContext::default()).await?; + store.store(&result, &StoreContext::from_env()?).await?; Ok(()) } } diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index 70293b02b9..24cb7cdb44 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -246,7 +246,7 @@ async fn import(client: BaseHTTPClient, url_string: String) -> anyhow::Result<() if let Some(profile_json) = profile_json { validate(&client, CliInput::Full(profile_json.clone())).await?; let context = StoreContext { - source: Some(url.to_owned()), + source: url.to_owned(), }; store_settings(client, &profile_json, &context).await?; } diff --git a/rust/agama-lib/src/files/store.rs b/rust/agama-lib/src/files/store.rs index 07ad02fd6a..fffa611f9d 100644 --- a/rust/agama-lib/src/files/store.rs +++ b/rust/agama-lib/src/files/store.rs @@ -24,11 +24,19 @@ use super::{ client::{FilesClient, FilesHTTPClientError}, model::UserFile, }; -use crate::{base_http_client::BaseHTTPClient, file_source::WithFileSource, StoreContext}; +use crate::{ + base_http_client::BaseHTTPClient, + file_source::{FileSourceError, WithFileSource}, + StoreContext, +}; #[derive(Debug, thiserror::Error)] -#[error("Error processing files settings: {0}")] -pub struct FilesStoreError(#[from] FilesHTTPClientError); +pub enum FilesStoreError { + #[error("Error processing files settings: {0}")] + FilesHTTPClient(#[from] FilesHTTPClientError), + #[error(transparent)] + FileSourceError(#[from] FileSourceError), +} type FilesStoreResult = Result; @@ -60,20 +68,10 @@ impl FilesStore { files: &Vec, context: &StoreContext, ) -> FilesStoreResult<()> { - let resolved_files = if let Some(base) = &context.source { - &files - .iter() - .filter_map(|file| { - file.resolve_url(&base) - .inspect_err(|e| { - log::warn!("Error processing file {}: {e}", file.destination) - }) - .ok() - }) - .collect() - } else { - files - }; + let resolved_files = files + .iter() + .map(|f| f.resolve_url(&context.source)) + .collect::, _>>()?; Ok(self.files_client.set_files(&resolved_files).await?) } } diff --git a/rust/agama-lib/src/scripts/store.rs b/rust/agama-lib/src/scripts/store.rs index 266e5d87b3..c13ec2ff24 100644 --- a/rust/agama-lib/src/scripts/store.rs +++ b/rust/agama-lib/src/scripts/store.rs @@ -117,22 +117,10 @@ impl ScriptsStore { /// /// * `script`: script definition. /// * `base_url`: base URL to resolve the script URL against. - async fn add_script( - &self, - script: Script, - base_url: &Option>, - ) -> ScriptStoreResult<()> { - let resolved = match base_url { - Some(source) => match script.resolve_url(&source) { - Ok(resolved) => resolved, - Err(e) => { - log::warn!("Error processing script {}: {e}", script.name()); - return Ok(()); - } - }, - None => script, - }; - self.scripts.add_script(resolved).await?; + async fn add_script(&self, script: Script, base_url: &Uri) -> ScriptStoreResult<()> { + self.scripts + .add_script(script.resolve_url(base_url)?) + .await?; Ok(()) } diff --git a/rust/agama-lib/src/store.rs b/rust/agama-lib/src/store.rs index 3cab50053c..78edf6cbc7 100644 --- a/rust/agama-lib/src/store.rs +++ b/rust/agama-lib/src/store.rs @@ -226,9 +226,9 @@ impl Store { } // It contains context information for the store. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct StoreContext { - pub source: Option>, + pub source: Uri, } impl StoreContext { @@ -238,7 +238,7 @@ impl StoreContext { let url = format!("file://{}", current_path.as_path().display()); let url = Uri::parse(url.as_str()).map_err(|_| StoreError::InvalidStoreContext)?; Ok(Self { - source: Some(url.to_owned()), + source: url.to_owned(), }) } } From 85af008196c6414def245d278ceb481895da6601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 15:15:49 +0100 Subject: [PATCH 22/32] fix(rust): use 0o700 for scripts --- rust/agama-lib/src/scripts/model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index a4b66518bd..853d41bd58 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -55,7 +55,7 @@ impl BaseScript { fn write>(&self, workdir: P) -> Result<(), ScriptError> { let script_path = workdir.as_ref().join(&self.name); std::fs::create_dir_all(script_path.parent().unwrap())?; - self.source.write(&script_path, 0o500)?; + self.source.write(&script_path, 0o700)?; Ok(()) } } From dd811675a44297d3e8c7a8d6d075d2bbb46f9f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 15:43:46 +0100 Subject: [PATCH 23/32] Apply suggestions from code review Co-authored-by: Martin Vidner --- rust/agama-lib/src/file_source.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/agama-lib/src/file_source.rs b/rust/agama-lib/src/file_source.rs index 585461c364..e95c3ed344 100644 --- a/rust/agama-lib/src/file_source.rs +++ b/rust/agama-lib/src/file_source.rs @@ -64,7 +64,7 @@ impl FileSource { let resolved = url .resolve_against(base) .map_err(|e| FileSourceError::ResolveUrlError(url.to_string(), e))?; - UriRef::parse(resolved.to_string()).unwrap() // unwrap OK: resolved is already a Uri + UriRef::from(resolved) }; Self::Remote { url: resolved_url } } From 153bdd2ba73494fcd32039d49e9787a690548d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Imobach=20Gonz=C3=A1lez=20Sosa?= Date: Wed, 30 Apr 2025 19:37:42 +0100 Subject: [PATCH 24/32] refactor(rust): move URL solving to InstallSettings --- rust/agama-cli/src/config.rs | 15 +++--- rust/agama-cli/src/profile.rs | 11 +++-- rust/agama-lib/src/context.rs | 45 +++++++++++++++++ rust/agama-lib/src/file_source.rs | 9 ++-- rust/agama-lib/src/files/settings.rs | 3 +- rust/agama-lib/src/files/store.rs | 20 ++------ rust/agama-lib/src/install_settings.rs | 60 ++++++++++++++++++++--- rust/agama-lib/src/lib.rs | 7 +-- rust/agama-lib/src/scripts/model.rs | 67 ++++++++++++++++++++++---- rust/agama-lib/src/scripts/settings.rs | 26 ++++++++++ rust/agama-lib/src/scripts/store.rs | 35 +++----------- rust/agama-lib/src/store.rs | 30 ++---------- 12 files changed, 217 insertions(+), 111 deletions(-) create mode 100644 rust/agama-lib/src/context.rs diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index 6158d3a221..b2047ceb14 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -26,8 +26,8 @@ use std::{ use crate::show_progress; use agama_lib::{ - base_http_client::BaseHTTPClient, install_settings::InstallSettings, Store as SettingsStore, - StoreContext, + base_http_client::BaseHTTPClient, context::InstallationContext, + install_settings::InstallSettings, Store as SettingsStore, }; use anyhow::anyhow; use clap::Subcommand; @@ -76,11 +76,11 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any let mut stdin = io::stdin(); let mut contents = String::new(); stdin.read_to_string(&mut contents)?; - let result: InstallSettings = serde_json::from_str(&contents)?; + let result = InstallSettings::from_json(&contents, &InstallationContext::from_env()?)?; tokio::spawn(async move { show_progress().await.unwrap(); }); - store.store(&result, &StoreContext::from_env()?).await?; + store.store(&result).await?; Ok(()) } ConfigCommands::Edit { editor } => { @@ -92,7 +92,7 @@ pub async fn run(http_client: BaseHTTPClient, subcommand: ConfigCommands) -> any tokio::spawn(async move { show_progress().await.unwrap(); }); - store.store(&result, &StoreContext::from_env()?).await?; + store.store(&result).await?; Ok(()) } } @@ -113,7 +113,10 @@ fn edit(model: &InstallSettings, editor: &str) -> anyhow::Result anyhow::Result<() // None means the profile is a script and it has been executed if let Some(profile_json) = profile_json { validate(&client, CliInput::Full(profile_json.clone())).await?; - let context = StoreContext { + let context = InstallationContext { source: url.to_owned(), }; store_settings(client, &profile_json, &context).await?; @@ -290,11 +291,11 @@ async fn pre_process_profile( async fn store_settings( client: BaseHTTPClient, profile_json: &str, - context: &StoreContext, + context: &InstallationContext, ) -> anyhow::Result<()> { let store = SettingsStore::new(client).await?; - let settings: InstallSettings = serde_json::from_str(profile_json)?; - store.store(&settings, context).await?; + let settings = InstallSettings::from_json(profile_json, context)?; + store.store(&settings).await?; Ok(()) } diff --git a/rust/agama-lib/src/context.rs b/rust/agama-lib/src/context.rs new file mode 100644 index 0000000000..a6411e481a --- /dev/null +++ b/rust/agama-lib/src/context.rs @@ -0,0 +1,45 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use fluent_uri::Uri; + +#[derive(Debug, thiserror::Error)] +#[error("Could not determine the installation context")] +pub struct InstallationContextError(String); + +/// It contains context information for the store. +#[derive(Debug)] +pub struct InstallationContext { + /// Where the installation settings are from. + pub source: Uri, +} + +impl InstallationContext { + /// Sets _source_ to the current directory. + pub fn from_env() -> Result { + let current_path = + std::env::current_dir().map_err(|e| InstallationContextError(e.to_string()))?; + let url = format!("file://{}", current_path.as_path().display()); + let url = Uri::parse(url.as_str()).map_err(|e| InstallationContextError(e.to_string()))?; + Ok(Self { + source: url.to_owned(), + }) + } +} diff --git a/rust/agama-lib/src/file_source.rs b/rust/agama-lib/src/file_source.rs index e95c3ed344..2424766e05 100644 --- a/rust/agama-lib/src/file_source.rs +++ b/rust/agama-lib/src/file_source.rs @@ -103,11 +103,10 @@ pub trait WithFileSource: Clone { /// Returns a clone using an absolute URL for the file source. /// /// * `base`: base URL. - fn resolve_url(&self, base: &Uri) -> Result { - let mut clone = self.clone(); - let source = clone.file_source_mut(); - *source = self.file_source().resolve_url(base)?; - Ok(clone) + fn resolve_url(&mut self, base: &Uri) -> Result<(), FileSourceError> { + let source = self.file_source_mut(); + *source = source.resolve_url(base)?; + Ok(()) } } diff --git a/rust/agama-lib/src/files/settings.rs b/rust/agama-lib/src/files/settings.rs index 212e10b4ba..2cc36ef377 100644 --- a/rust/agama-lib/src/files/settings.rs +++ b/rust/agama-lib/src/files/settings.rs @@ -18,9 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use serde::{Deserialize, Serialize}; - use super::model::UserFile; +use serde::{Deserialize, Serialize}; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/agama-lib/src/files/store.rs b/rust/agama-lib/src/files/store.rs index fffa611f9d..ef6cdd16d2 100644 --- a/rust/agama-lib/src/files/store.rs +++ b/rust/agama-lib/src/files/store.rs @@ -24,18 +24,12 @@ use super::{ client::{FilesClient, FilesHTTPClientError}, model::UserFile, }; -use crate::{ - base_http_client::BaseHTTPClient, - file_source::{FileSourceError, WithFileSource}, - StoreContext, -}; +use crate::base_http_client::BaseHTTPClient; #[derive(Debug, thiserror::Error)] pub enum FilesStoreError { #[error("Error processing files settings: {0}")] FilesHTTPClient(#[from] FilesHTTPClientError), - #[error(transparent)] - FileSourceError(#[from] FileSourceError), } type FilesStoreResult = Result; @@ -63,15 +57,7 @@ impl FilesStore { } /// stores the list of user files via http API - pub async fn store( - &self, - files: &Vec, - context: &StoreContext, - ) -> FilesStoreResult<()> { - let resolved_files = files - .iter() - .map(|f| f.resolve_url(&context.source)) - .collect::, _>>()?; - Ok(self.files_client.set_files(&resolved_files).await?) + pub async fn store(&self, files: &Vec) -> FilesStoreResult<()> { + Ok(self.files_client.set_files(files).await?) } } diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index ea1124272f..6c3a46dc54 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -22,6 +22,8 @@ //! //! This module implements the mechanisms to load and store the installation settings. use crate::bootloader::model::BootloaderSettings; +use crate::context::InstallationContext; +use crate::file_source::{FileSourceError, WithFileSource}; use crate::files::model::UserFile; use crate::hostname::model::HostnameSettings; use crate::security::settings::SecuritySettings; @@ -29,13 +31,22 @@ use crate::{ localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, scripts::ScriptsConfig, software::SoftwareSettings, users::UserSettings, }; +use fluent_uri::Uri; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::default::Default; -use std::fs::File; -use std::io::BufReader; use std::path::Path; +#[derive(Debug, thiserror::Error)] +pub enum InstallSettingsError { + #[error("I/O error: {0}")] + InputOuputError(#[from] std::io::Error), + #[error("Could not parse the settings: {0}")] + ParseError(#[from] serde_json::Error), + #[error(transparent)] + FileSourceError(#[from] FileSourceError), +} + /// Installation settings /// /// This struct represents installation settings. It serves as an entry point and it is composed of @@ -80,10 +91,43 @@ pub struct InstallSettings { } impl InstallSettings { - pub fn from_file>(path: P) -> Result { - let file = File::open(path)?; - let reader = BufReader::new(file); - let data = serde_json::from_reader(reader)?; - Ok(data) + /// Returns install settings from a file. + pub fn from_file>( + path: P, + context: &InstallationContext, + ) -> Result { + let content = std::fs::read_to_string(path)?; + Ok(Self::from_json(&content, context)?) + } + + /// Reads install settings from a JSON string. + /// + /// - `json`: JSON string. + /// - `context`: Store context. + pub fn from_json( + json: &str, + context: &InstallationContext, + ) -> Result { + let mut settings: InstallSettings = serde_json::from_str(json)?; + settings.resolve_urls(&context.source).unwrap(); + Ok(settings) + } + + /// Resolves URLs in the settings. + /// + // Ideally, the context could be ready when deserializing the settings so + // the URLs can be resolved. One possible solution would be to use + // [DeserializeSeed](https://docs.rs/serde/1.0.219/serde/de/trait.DeserializeSeed.html). + fn resolve_urls(&mut self, source_uri: &Uri) -> Result<(), InstallSettingsError> { + if let Some(ref mut scripts) = self.scripts { + scripts.resolve_urls(source_uri)?; + } + + if let Some(ref mut files) = self.files { + for file in files.iter_mut() { + file.resolve_url(source_uri)?; + } + } + Ok(()) } } diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 9cd71e1361..59924d4756 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -46,6 +46,7 @@ pub mod auth; pub mod base_http_client; pub mod bootloader; +pub mod context; pub mod error; pub mod file_source; pub mod files; @@ -63,14 +64,14 @@ pub mod storage; pub mod users; // TODO: maybe expose only clients when we have it? pub mod dbus; +pub mod openapi; pub mod progress; pub mod proxies; -mod store; -pub use store::{Store, StoreContext}; -pub mod openapi; pub mod questions; pub mod scripts; pub mod security; +mod store; +pub use store::Store; pub mod utils; use crate::error::ServiceError; diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 853d41bd58..4727af5808 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -188,6 +188,18 @@ impl TryFrom