diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 8b053d6238..138de166bf 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -71,6 +71,7 @@ jobs: run: zypper --non-interactive install clang-devel dbus-1-daemon + golang-github-google-jsonnet jq libopenssl-3-devel openssl-3 diff --git a/rust/agama-cli/src/lib.rs b/rust/agama-cli/src/lib.rs index f4fa82312d..836a9b787e 100644 --- a/rust/agama-cli/src/lib.rs +++ b/rust/agama-cli/src/lib.rs @@ -32,7 +32,7 @@ mod questions; use crate::error::CliError; use agama_lib::base_http_client::BaseHTTPClient; use agama_lib::{ - error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, transfer::Transfer, + error::ServiceError, manager::ManagerClient, progress::ProgressMonitor, utils::Transfer, }; use auth::run as run_auth_cmd; use commands::Commands; diff --git a/rust/agama-cli/src/profile.rs b/rust/agama-cli/src/profile.rs index 1e7018cdac..9f2cbcce0d 100644 --- a/rust/agama-cli/src/profile.rs +++ b/rust/agama-cli/src/profile.rs @@ -22,14 +22,15 @@ use crate::show_progress; use agama_lib::{ base_http_client::BaseHTTPClient, install_settings::InstallSettings, - profile::{AutoyastProfile, ProfileEvaluator, ProfileValidator, ValidationResult}, - transfer::Transfer, + profile::{AutoyastProfileImporter, ProfileEvaluator, ProfileValidator, ValidationResult}, + utils::FileFormat, + utils::Transfer, Store as SettingsStore, }; use anyhow::Context; use clap::Subcommand; use console::style; -use std::os::unix::process::CommandExt; +use std::os::unix::{fs::PermissionsExt, process::CommandExt}; use std::{ fs::File, io::stdout, @@ -114,48 +115,62 @@ async fn import(url_string: String, dir: Option) -> anyhow::Result<()> tokio::spawn(async move { show_progress().await.unwrap(); }); + let url = Url::parse(&url_string)?; let tmpdir = TempDir::new()?; // TODO: create it only if dir is not passed + let work_dir = dir.unwrap_or_else(|| tmpdir.into_path()); + let profile_path = work_dir.join("profile.json"); + + // Specific AutoYaST handling let path = url.path(); - let output_file = if path.ends_with(".sh") { - "profile.sh" - } else if path.ends_with(".jsonnet") { - "profile.jsonnet" - } else { - "profile.json" - }; - let output_dir = dir.unwrap_or_else(|| tmpdir.into_path()); - let mut output_path = output_dir.join(output_file); - let output_fd = File::create(output_path.clone())?; if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { - // autoyast specific download and convert to json - AutoyastProfile::new(&url)?.read_into(output_fd)?; + // AutoYaST specific download and convert to JSON + AutoyastProfileImporter::read(&url)?.write_file(&profile_path)?; } else { - // just download profile - Transfer::get(&url_string, output_fd)?; + pre_process_profile(&url_string, &profile_path)?; } - // exec shell scripts - if output_file.ends_with(".sh") { - let err = Command::new("bash") - .args([output_path.to_str().context("Wrong path to shell script")?]) - .exec(); - eprintln!("Exec failed: {}", err); - } + validate(&profile_path)?; + store_settings(&profile_path).await?; - // evaluate jsonnet profiles - if output_file.ends_with(".jsonnet") { - let fd = File::create(output_dir.join("profile.json"))?; - let evaluator = ProfileEvaluator {}; - evaluator - .evaluate(&output_path, fd) - .context("Could not evaluate the profile".to_string())?; - output_path = output_dir.join("profile.json"); - } + Ok(()) +} - validate(&output_path)?; - store_settings(&output_path).await?; +// Preprocess the profile. +// +// The profile can be a JSON or a Jsonnet file or a script. +// +// * If it is a JSON file, no preprocessing is needed. +// * If it is a Jsonnet file, it is converted to JSON. +// * If it is a script, it is executed. +fn pre_process_profile>(url_string: &str, path: P) -> anyhow::Result<()> { + let work_dir = path.as_ref().parent().unwrap(); + let tmp_profile_path = work_dir.join("profile.temp"); + let tmp_file = File::create(&tmp_profile_path)?; + Transfer::get(url_string, tmp_file)?; + match FileFormat::from_file(&tmp_profile_path)? { + FileFormat::Jsonnet => { + let file = File::create(path)?; + let evaluator = ProfileEvaluator {}; + evaluator + .evaluate(&tmp_profile_path, file) + .context("Could not evaluate the profile".to_string())?; + } + FileFormat::Script => { + let mut perms = std::fs::metadata(&tmp_profile_path)?.permissions(); + perms.set_mode(0o750); + std::fs::set_permissions(&tmp_profile_path, perms)?; + let err = Command::new(&tmp_profile_path).exec(); + eprintln!("Exec failed: {}", err); + } + FileFormat::Json => { + std::fs::rename(&tmp_profile_path, path.as_ref())?; + } + _ => { + return Err(anyhow::Error::msg("Unsupported file format")); + } + } Ok(()) } @@ -168,8 +183,8 @@ async fn store_settings>(path: P) -> anyhow::Result<()> { fn autoyast(url_string: String) -> anyhow::Result<()> { let url = Url::parse(&url_string)?; - let reader = AutoyastProfile::new(&url)?; - reader.read_into(std::io::stdout())?; + let importer = AutoyastProfileImporter::read(&url)?; + importer.write(std::io::stdout())?; Ok(()) } diff --git a/rust/agama-lib/src/error.rs b/rust/agama-lib/src/error.rs index 380aaafd8d..8d9a31790c 100644 --- a/rust/agama-lib/src/error.rs +++ b/rust/agama-lib/src/error.rs @@ -23,7 +23,7 @@ use std::io; use thiserror::Error; use zbus::{self, zvariant}; -use crate::transfer::TransferError; +use crate::utils::TransferError; #[derive(Error, Debug)] pub enum ServiceError { diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index a2213c2cad..d7aab9c5c1 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -66,7 +66,7 @@ pub use store::Store; pub mod openapi; pub mod questions; pub mod scripts; -pub mod transfer; +pub mod utils; use crate::error::ServiceError; use reqwest::{header, Client}; diff --git a/rust/agama-lib/src/profile.rs b/rust/agama-lib/src/profile.rs index d05a91abd6..b1adffef3b 100644 --- a/rust/agama-lib/src/profile.rs +++ b/rust/agama-lib/src/profile.rs @@ -23,43 +23,49 @@ use anyhow::Context; use jsonschema::JSONSchema; use log::info; use serde_json; -use std::{fs, io::Write, path::Path, process::Command}; +use std::{ + fs::{self, File}, + io::Write, + path::Path, + process::Command, +}; use tempfile::{tempdir, TempDir}; use url::Url; /// Downloads and converts autoyast profile. -pub struct AutoyastProfile { - url: Url, +pub struct AutoyastProfileImporter { + content: String, } -impl AutoyastProfile { - pub fn new(url: &Url) -> anyhow::Result { - Ok(Self { url: url.clone() }) - } - - pub fn read_into(&self, mut out_fd: impl Write) -> anyhow::Result<()> { - let path = self.url.path(); - if path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/') { - let content = self.read_from_autoyast()?; - out_fd.write_all(content.as_bytes())?; - Ok(()) - } else { - let msg = format!("Unsupported AutoYaST format at {}", self.url); - Err(anyhow::Error::msg(msg)) +impl AutoyastProfileImporter { + pub fn read(url: &Url) -> anyhow::Result { + let path = url.path(); + if !path.ends_with(".xml") && !path.ends_with(".erb") && !path.ends_with('/') { + let msg = format!("Unsupported AutoYaST format at {}", url); + return Err(anyhow::Error::msg(msg)); } - } - fn read_from_autoyast(&self) -> anyhow::Result { const TMP_DIR_PREFIX: &str = "autoyast"; const AUTOINST_JSON: &str = "autoinst.json"; let tmp_dir = TempDir::with_prefix(TMP_DIR_PREFIX)?; Command::new("agama-autoyast") - .args([self.url.as_str(), &tmp_dir.path().to_string_lossy()]) + .args([url.as_str(), &tmp_dir.path().to_string_lossy()]) .status()?; let autoinst_json = tmp_dir.path().join(AUTOINST_JSON); - Ok(fs::read_to_string(autoinst_json)?) + let content = fs::read_to_string(autoinst_json)?; + Ok(Self { content }) + } + + pub fn write(&self, mut file: impl Write) -> anyhow::Result<()> { + file.write_all(self.content.as_bytes())?; + Ok(()) + } + + pub fn write_file>(&self, path: P) -> anyhow::Result<()> { + let mut file = File::create(path)?; + self.write(&mut file) } } @@ -170,7 +176,7 @@ impl ProfileEvaluator { .args(["-json"]) .output() .context("Failed to run lshw")?; - let helpers = fs::read_to_string("agama.libsonnet") + let helpers = fs::read_to_string("share/agama.libsonnet") .or_else(|_| fs::read_to_string("/usr/share/agama-cli/agama.libsonnet")) .context("Failed to read agama.libsonnet")?; let mut file = fs::File::create(path)?; diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs index 575981aeb7..fec37981ee 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::transfer::TransferError; +use crate::utils::TransferError; #[derive(Error, Debug)] pub enum ScriptError { diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index ddf3c651f2..3ad9343712 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -28,7 +28,7 @@ use std::{ use serde::{Deserialize, Serialize}; -use crate::transfer::Transfer; +use crate::utils::Transfer; use super::ScriptError; diff --git a/rust/agama-lib/src/utils.rs b/rust/agama-lib/src/utils.rs new file mode 100644 index 0000000000..f6fb4f9020 --- /dev/null +++ b/rust/agama-lib/src/utils.rs @@ -0,0 +1,27 @@ +// Copyright (c) [2024] 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. + +//! Utility module for Agama. + +mod file_format; +mod transfer; + +pub use file_format::*; +pub use transfer::*; diff --git a/rust/agama-lib/src/utils/file_format.rs b/rust/agama-lib/src/utils/file_format.rs new file mode 100644 index 0000000000..48c857e3c3 --- /dev/null +++ b/rust/agama-lib/src/utils/file_format.rs @@ -0,0 +1,158 @@ +// Copyright (c) [2024] 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. + +//! File format detection for Agama. +//! +//! It implements a simple API to detect the file formats that are relevent for Agama. + +use std::{ + io::Write, + path::Path, + process::{Command, Stdio}, +}; + +/// Relevant file formats for Agama. +#[derive(Debug, PartialEq, Copy, Clone)] +pub enum FileFormat { + Json, + Jsonnet, + Script, + Unknown, +} + +const JSONNETFMT_BIN: &str = "jsonnetfmt"; + +impl FileFormat { + /// Tries to guess the file format from the content of a file. + pub fn from_file>(file_path: P) -> Result { + let content = std::fs::read_to_string(file_path)?; + Ok(Self::from_string(&content)) + } + + /// Tries to guess the file format from a string. + pub fn from_string(content: &str) -> Self { + if Self::is_json(content) { + return Self::Json; + } else if Self::is_script(content) { + return Self::Script; + } else if Self::is_jsonnet(content) { + return Self::Jsonnet; + } + + Self::Unknown + } + + /// Whether the format is JSON. + /// + /// It tries to parse the content as JSON and returns `true` if it succeeds. + fn is_json(content: &str) -> bool { + let json = serde_json::from_str::(content); + json.is_ok() + } + + /// Whether the format is Jsonnet. + /// + /// It tries to process the content with the jsonnetfmt tool and returns `true` if it succeeds. + fn is_jsonnet(content: &str) -> bool { + let child = Command::new(JSONNETFMT_BIN) + .args(["-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + let Ok(mut child) = child else { + return false; + }; + + let Some(mut stdin) = child.stdin.take() else { + return false; + }; + + stdin + .write_all(content.as_bytes()) + .expect("Failed to write to stdin"); + drop(stdin); + + child.wait().is_ok_and(|s| s.success()) + } + + /// Whether is is a script. + /// + /// It returns `true` if the content starts with a shebang. However, it excludes the + /// case of a "jsonnet" script because it needs special handling: injecting the hardware + /// information and processing its output. + fn is_script(content: &str) -> bool { + let Some(first_line) = content.lines().next() else { + return false; + }; + first_line.starts_with("#!") && !first_line.contains("jsonnet") + } +} + +#[cfg(test)] +mod tests { + use super::FileFormat; + + #[test] + fn test_json() { + let content = r#" + { "name:": "value"} + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Json); + } + + #[test] + fn test_jsonnet() { + let content = r#" + { name: "value" } + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Jsonnet); + + let content = r#"#!/usr/bin/jsonnet + print + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Jsonnet); + } + + #[test] + fn test_script() { + let content = r#"#!/bin/bash + echo "Hello World" + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Script); + + let content = r#"#!/usr/bin/python3 + print + "#; + assert_eq!(FileFormat::from_string(content), FileFormat::Script); + } + + #[test] + fn test_unknown() { + let empty = ""; + assert_eq!(FileFormat::from_string(empty), FileFormat::Unknown); + + let text = r#" + Some text content. + "#; + assert_eq!(FileFormat::from_string(text), FileFormat::Unknown); + } +} diff --git a/rust/agama-lib/src/transfer.rs b/rust/agama-lib/src/utils/transfer.rs similarity index 54% rename from rust/agama-lib/src/transfer.rs rename to rust/agama-lib/src/utils/transfer.rs index 2566c93c7b..a9c9475387 100644 --- a/rust/agama-lib/src/transfer.rs +++ b/rust/agama-lib/src/utils/transfer.rs @@ -1,3 +1,23 @@ +// Copyright (c) [2024] 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. + //! File transfer API for Agama. //! //! Implement a file transfer API which, in the future, will support Agama specific URLs. Check the @@ -27,6 +47,8 @@ impl Transfer { /// * `out_fd`: where to write the data. pub fn get(url: &str, mut out_fd: impl Write) -> TransferResult<()> { let mut handle = Easy::new(); + handle.follow_location(true)?; + handle.fail_on_error(true)?; handle.url(url)?; let mut transfer = handle.transfer(); diff --git a/rust/package/agama.changes b/rust/package/agama.changes index 4024e507a9..e9b252f690 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,12 @@ +------------------------------------------------------------------- +Thu Dec 12 16:42:18 UTC 2024 - Imobach Gonzalez Sosa + +- Fix profile URL handling (bsc#1234362): + - Follow redirections. + - Determine the file format from the content instead of the + extension. It does not apply to AutoYaST profiles, where it still + uses the extension in the URL for backward compatibility. + ------------------------------------------------------------------- Tue Dec 3 12:58:48 UTC 2024 - Imobach Gonzalez Sosa