diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 66549e648..b5be81ef8 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -59,6 +59,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", + "strum", "tempfile", "thiserror", "tokio", @@ -1907,7 +1908,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.10", + "socket2 0.5.7", "tokio", "tower-service", "tracing", @@ -3785,6 +3786,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.79", +] + [[package]] name = "subprocess" version = "0.2.9" diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index 8c9e63b39..081ca8251 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -28,6 +28,7 @@ curl = { version = "0.4.47", features = ["protocol-ftp"] } jsonwebtoken = "9.3.0" chrono = { version = "0.4.38", default-features = false, features = ["now", "std", "alloc", "clock"] } home = "0.5.9" +strum = { version = "0.26.3", features = ["derive"] } [dev-dependencies] httpmock = "0.7.0" diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 8081fff53..674cd50f5 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -54,9 +54,7 @@ "items": { "type": "object", "additionalProperties": false, - "required": [ - "id" - ], + "required": ["id"], "properties": { "id": { "title": "Connection ID", @@ -79,22 +77,12 @@ "method4": { "title": "IPv4 configuration method", "type": "string", - "enum": [ - "auto", - "manual", - "link-local", - "disabled" - ] + "enum": ["auto", "manual", "link-local", "disabled"] }, "method6": { "title": "IPv6 configuration method", "type": "string", - "enum": [ - "auto", - "manual", - "link-local", - "disabled" - ] + "enum": ["auto", "manual", "link-local", "disabled"] }, "gateway4": { "title": "Connection gateway address", @@ -162,12 +150,7 @@ "mode": { "title": "Wireless network mode", "type": "string", - "enum": [ - "infrastructure", - "adhoc", - "mesh", - "ap" - ] + "enum": ["infrastructure", "adhoc", "mesh", "ap"] }, "hidden": { "title": "Indicates that the wireless network is not broadcasting its SSID", @@ -176,15 +159,12 @@ "band": { "title": "Frequency band of the wireless network", "type": "string", - "enum": [ - "a", - "bg" - ] + "enum": ["a", "bg"] }, "channel": { "title": "Wireless channel of the wireless network", "type": "integer", - "minimum" : 0 + "minimum": 0 }, "bssid": { "title": "Only allow connection to this mac address", @@ -195,12 +175,7 @@ "items": { "title": "A list of group/broadcast encryption algorithms", "type": "string", - "enum": [ - "wep40", - "wep104", - "tkip", - "ccmp" - ] + "enum": ["wep40", "wep104", "tkip", "ccmp"] } }, "pairwiseAlgorithms": { @@ -208,10 +183,7 @@ "items": { "title": "A list of pairwise encryption algorithms", "type": "string", - "enum": [ - "tkip", - "ccmp" - ] + "enum": ["tkip", "ccmp"] } }, "wpaProtocolVersions": { @@ -219,10 +191,7 @@ "items": { "title": "A list of allowed WPA protocol versions", "type": "string", - "enum": [ - "wpa", - "rsn" - ] + "enum": ["wpa", "rsn"] } }, "pmf": { @@ -369,10 +338,7 @@ "peapVersion": { "title": "Which PEAP version is used when PEAP is set as the EAP method in the 'eap' property", "type": "string", - "enum": [ - "0", - "1" - ] + "enum": ["0", "1"] }, "peapLabel": { "title": "Force the use of the new PEAP label during key derivation", @@ -406,11 +372,7 @@ "examples": ["nots3cr3t"] } }, - "required": [ - "fullName", - "userName", - "password" - ] + "required": ["fullName", "userName", "password"] }, "root": { "title": "Root authentication settings", @@ -906,7 +868,7 @@ "description": "Name of a disk device.", "type": "string", "examples": ["/dev/vda"] - } + } } }, { @@ -967,6 +929,30 @@ "items": { "type": "object" } + }, + "scripts": { + "title": "User-defined installation scripts", + "description": "User-defined scripts to run at different points of the installation", + "type": "object", + "additionalProperties": false, + "properties": { + "pre": { + "title": "Pre-installation scripts", + "description": "User-defined scripts to run before the installation starts", + "type": "array", + "items": { + "$ref": "#/$defs/script" + } + }, + "post": { + "title": "Post-installation scripts", + "description": "User-defined scripts to run after the installation finishes", + "type": "array", + "items": { + "$ref": "#/$defs/script" + } + } + } } }, "$defs": { @@ -1013,7 +999,12 @@ }, "minItems": 1, "maxItems": 2, - "examples": [[1024, "current"], ["1 GiB", "5 GiB"], [1024, "2 GiB"], ["2 GiB"]] + "examples": [ + [1024, "current"], + ["1 GiB", "5 GiB"], + [1024, "2 GiB"], + ["2 GiB"] + ] }, { "title": "Size range", @@ -1206,8 +1197,22 @@ { "title": "File system type", "enum": [ - "bcachefs", "btrfs", "exfat", "ext2", "ext3", "ext4", "f2fs", "jfs", - "nfs", "nilfs2", "ntfs", "reiserfs", "swap", "tmpfs", "vfat", "xfs" + "bcachefs", + "btrfs", + "exfat", + "ext2", + "ext3", + "ext4", + "f2fs", + "jfs", + "nfs", + "nilfs2", + "ntfs", + "reiserfs", + "swap", + "tmpfs", + "vfat", + "xfs" ] }, { @@ -1330,7 +1335,15 @@ }, "id": { "title": "Partition ID", - "enum": ["linux", "swap", "lvm", "raid", "esp", "prep", "bios_boot"] + "enum": [ + "linux", + "swap", + "lvm", + "raid", + "esp", + "prep", + "bios_boot" + ] }, "size": { "title": "Partition size", @@ -1394,6 +1407,28 @@ "lvStripeSize": { "title": "Stripe size", "$ref": "#/$defs/sizeValue" + }, + "script": { + "title": "User-defined installation script", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "Script name, to be used as file name", + "type": "string" + }, + "body": { + "title": "Script content", + "description": "Script content, starting with the shebang", + "type": "string" + }, + "url": { + "title": "Script URL", + "description": "URL to fetch the script from" + } + }, + "required": ["name"], + "oneOf": [{ "required": ["body"] }, { "required": ["url"] }] } } } diff --git a/rust/agama-lib/src/install_settings.rs b/rust/agama-lib/src/install_settings.rs index c5fbd2b8f..fe5113da0 100644 --- a/rust/agama-lib/src/install_settings.rs +++ b/rust/agama-lib/src/install_settings.rs @@ -23,7 +23,7 @@ //! This module implements the mechanisms to load and store the installation settings. use crate::{ localization::LocalizationSettings, network::NetworkSettings, product::ProductSettings, - software::SoftwareSettings, users::UserSettings, + scripts::ScriptsConfig, software::SoftwareSettings, users::UserSettings, }; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; @@ -55,6 +55,8 @@ pub struct InstallSettings { pub network: Option, #[serde(default)] pub localization: Option, + #[serde(default)] + pub scripts: Option, } impl InstallSettings { diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 6c86b035a..659b6505f 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -63,6 +63,7 @@ pub mod proxies; mod store; pub use store::Store; pub mod questions; +pub mod scripts; pub mod transfer; use crate::error::ServiceError; use reqwest::{header, Client}; diff --git a/rust/agama-lib/src/scripts.rs b/rust/agama-lib/src/scripts.rs new file mode 100644 index 000000000..2636b6f61 --- /dev/null +++ b/rust/agama-lib/src/scripts.rs @@ -0,0 +1,32 @@ +// 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. + +//! Implements support for handling the user-defined scripts. + +mod client; +mod error; +mod model; +mod settings; +mod store; + +pub use error::ScriptError; +pub use model::*; +pub use settings::*; +pub use store::ScriptsStore; diff --git a/rust/agama-lib/src/scripts/client.rs b/rust/agama-lib/src/scripts/client.rs new file mode 100644 index 000000000..78703703a --- /dev/null +++ b/rust/agama-lib/src/scripts/client.rs @@ -0,0 +1,58 @@ +// 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. + +use crate::{base_http_client::BaseHTTPClient, error::ServiceError}; + +use super::{Script, ScriptsGroup}; + +/// HTTP client to interact with scripts. +pub struct ScriptsClient { + client: BaseHTTPClient, +} + +impl ScriptsClient { + pub fn new(base: BaseHTTPClient) -> Self { + Self { client: base } + } + + /// Adds a script to the given group. + /// + /// * `script`: script's definition. + pub async fn add_script(&self, script: &Script) -> Result<(), ServiceError> { + self.client.post_void("/scripts", &script).await + } + + /// Runs user-defined scripts of the given group. + /// + /// * `group`: group of the scripts to run + pub async fn run_scripts(&self, group: ScriptsGroup) -> Result<(), ServiceError> { + self.client.post_void("/scripts/run", &group).await + } + + pub async fn scripts(&self) -> Result, ServiceError> { + let scripts = self.client.get("/scripts").await?; + Ok(scripts) + } + + /// Remove all the user-defined scripts. + pub async fn delete_scripts(&self) -> Result<(), ServiceError> { + self.client.delete_void("/scripts").await + } +} diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs new file mode 100644 index 000000000..d6dbc3b3b --- /dev/null +++ b/rust/agama-lib/src/scripts/error.rs @@ -0,0 +1,32 @@ +// 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. + +use std::io; +use thiserror::Error; + +use crate::transfer::TransferError; + +#[derive(Error, Debug)] +pub enum ScriptError { + #[error("Could not fetch the profile: '{0}'")] + Unreachable(#[from] TransferError), + #[error("I/O error: '{0}'")] + InputOutputError(#[from] io::Error), +} diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs new file mode 100644 index 000000000..421eaf839 --- /dev/null +++ b/rust/agama-lib/src/scripts/model.rs @@ -0,0 +1,215 @@ +// 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. + +use std::{ + fs, + io::Write, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, + process, +}; + +use serde::{Deserialize, Serialize}; + +use crate::transfer::Transfer; + +use super::ScriptError; + +#[derive(Debug, Clone, Copy, PartialEq, strum::Display, Serialize, Deserialize)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum ScriptsGroup { + Pre, + Post, +} + +/// Represents a script to run as part of the installation process. +#[derive(Clone, Serialize, Deserialize)] +pub struct Script { + /// Script's name. + pub name: String, + /// Script's body. Either the body or the URL must be specified. + pub body: Option, + /// URL to get the script from. Either the body or the URL must be specified. + pub url: Option, + /// Script's group + pub group: ScriptsGroup, +} + +impl Script { + /// Runs the script and returns the output. + /// + /// * `workdir`: where to write assets (script, logs and exit code). + pub async fn run(&self, workdir: &Path) -> Result<(), ScriptError> { + let dir = workdir.join(self.group.to_string()); + + let path = dir.join(&self.name); + self.write(&path).await?; + + let output = process::Command::new(&path).output()?; + + let stdout_log = dir.join(format!("{}.log", &self.name)); + fs::write(stdout_log, output.stdout)?; + + let stderr_log = dir.join(format!("{}.err", &self.name)); + fs::write(stderr_log, output.stderr)?; + + let status_file = dir.join(format!("{}.out", &self.name)); + fs::write(status_file, output.status.to_string())?; + + Ok(()) + } + + /// Writes the script to the file system. + /// + /// * `path`: path to write the script to. + async fn write>(&self, path: P) -> Result<(), ScriptError> { + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o500) + .open(&path)?; + + if let Some(url) = &self.url { + Transfer::get(url, file)?; + } else if let Some(body) = &self.body { + write!(file, "{}", &body)?; + } + // FIXME: else: invalid script definition + + Ok(()) + } +} + +/// Manages a set of installation scripts. +/// +/// It offers an API to add and execute installation scripts. +pub struct ScriptsRepository { + workdir: PathBuf, + pub scripts: Vec