diff --git a/rust/agama-lib/share/examples/post-script.jsonnet b/rust/agama-lib/share/examples/post-script.jsonnet new file mode 100644 index 0000000000..268f2a2026 --- /dev/null +++ b/rust/agama-lib/share/examples/post-script.jsonnet @@ -0,0 +1,25 @@ +{ + user: { + fullName: 'Jane Doe', + userName: 'jane.doe', + password: 'nots3cr3t', + }, + root: { + password: 'nots3cr3t', + }, + product: { + id: 'Tumbleweed', + }, + scripts: { + post: [ + { + name: 'enable-sshd', + chroot: true, + body: ||| + #!/bin/bash + systemctl enable sshd + |||, + }, + ], + }, +} diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 7361b48427..ad86c440e2 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -18,7 +18,7 @@ "description": "User-defined scripts to run before the installation starts", "type": "array", "items": { - "$ref": "#/$defs/script" + "$ref": "#/$defs/preScript" } }, "post": { @@ -26,7 +26,7 @@ "description": "User-defined scripts to run after the installation finishes", "type": "array", "items": { - "$ref": "#/$defs/script" + "$ref": "#/$defs/postScript" } }, "init": { @@ -34,7 +34,7 @@ "description": "User-defined scripts to run booting the installed system", "type": "array", "items": { - "$ref": "#/$defs/script" + "$ref": "#/$defs/initScript" } } } @@ -1418,8 +1418,57 @@ "title": "Stripe size", "$ref": "#/$defs/sizeValue" }, - "script": { - "title": "User-defined installation script", + "preScript": { + "title": "User-defined installation script that runs before the installation starts", + "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"] }] + }, + "postScript": { + "title": "User-defined installation script that runs after the installation finishes", + "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" + }, + "chroot": { + "title": "Whether it should run in the installed system using a chroot environment", + "description": "whether to chroot to the target system (default: yes) or not", + "type": "boolean" + } + }, + "required": ["name"], + "oneOf": [{ "required": ["body"] }, { "required": ["url"] }] + }, + "initScript": { + "title": "User-defined installation script that runs during the first boot of the target system, once the installation is finished", "type": "object", "additionalProperties": false, "properties": { diff --git a/rust/agama-lib/src/scripts/client.rs b/rust/agama-lib/src/scripts/client.rs index f71c82fbac..dfb05ab703 100644 --- a/rust/agama-lib/src/scripts/client.rs +++ b/rust/agama-lib/src/scripts/client.rs @@ -35,7 +35,7 @@ impl ScriptsClient { /// Adds a script to the given group. /// /// * `script`: script's definition. - pub async fn add_script(&self, script: &Script) -> Result<(), ServiceError> { + pub async fn add_script(&self, script: Script) -> Result<(), ServiceError> { self.client.post_void("/scripts", &script).await } diff --git a/rust/agama-lib/src/scripts/error.rs b/rust/agama-lib/src/scripts/error.rs index d6dbc3b3b2..575981aeb7 100644 --- a/rust/agama-lib/src/scripts/error.rs +++ b/rust/agama-lib/src/scripts/error.rs @@ -29,4 +29,6 @@ pub enum ScriptError { Unreachable(#[from] TransferError), #[error("I/O error: '{0}'")] InputOutputError(#[from] io::Error), + #[error("Wrong script type")] + WrongScriptType, } diff --git a/rust/agama-lib/src/scripts/model.rs b/rust/agama-lib/src/scripts/model.rs index 4c6df019f1..0fe8db8320 100644 --- a/rust/agama-lib/src/scripts/model.rs +++ b/rust/agama-lib/src/scripts/model.rs @@ -43,6 +43,34 @@ pub enum ScriptsGroup { Init, } +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct BaseScript { + pub name: String, + #[serde(flatten)] + pub source: ScriptSource, +} + +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 { body } => write!(file, "{}", &body)?, + ScriptSource::Remote { url } => Transfer::get(url, file)?, + }; + + Ok(()) + } +} + #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] #[serde(untagged)] pub enum ScriptSource { @@ -53,54 +81,169 @@ pub enum ScriptSource { } /// 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. #[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] -pub struct Script { - /// Script's name. - pub name: String, - #[serde(flatten)] - pub source: ScriptSource, - /// Script's group - pub group: ScriptsGroup, +#[serde(tag = "type")] +pub enum Script { + Pre(PreScript), + Post(PostScript), + Init(InitScript), } 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); - let output = process::Command::new(&path).output()?; - - let stdout_log = dir.join(format!("{}.log", &self.name)); - fs::write(stdout_log, output.stdout)?; + fn base(&self) -> &BaseScript { + match self { + Script::Pre(inner) => &inner.base, + Script::Post(inner) => &inner.base, + Script::Init(inner) => &inner.base, + } + } - let stderr_log = dir.join(format!("{}.err", &self.name)); - fs::write(stderr_log, output.stderr)?; + /// Returns the name of the script. + pub fn name(&self) -> &str { + self.base().name.as_str() + } - let status_file = dir.join(format!("{}.out", &self.name)); - fs::write(status_file, output.status.to_string())?; + /// Writes the script to the given work directory. + /// + /// The name of the script depends on the work directory and the script's group. + pub fn write>(&self, workdir: P) -> Result<(), ScriptError> { + let path = workdir.as_ref().join(&self.group().to_string()); + self.base().write(&path) + } - Ok(()) + /// Script's group. + /// + /// It determines whether the script runs. + pub fn group(&self) -> ScriptsGroup { + match self { + Script::Pre(_) => ScriptsGroup::Pre, + Script::Post(_) => ScriptsGroup::Post, + Script::Init(_) => ScriptsGroup::Init, + } } - /// Writes the script to the file system. + /// Runs the script in the given work directory. /// - /// * `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)?; + /// It saves the logs and the exit status of the execution. + /// + /// * `workdir`: where to run the script. + pub fn run>(&self, workdir: P) -> Result<(), ScriptError> { + let path = workdir + .as_ref() + .join(self.group().to_string()) + .join(self.name()); + let runner = match self { + Script::Pre(inner) => &inner.runner(), + Script::Post(inner) => &inner.runner(), + Script::Init(inner) => &inner.runner(), + }; - match &self.source { - ScriptSource::Text { body } => write!(file, "{}", &body)?, - ScriptSource::Remote { url } => Transfer::get(url, file)?, + let Some(runner) = runner else { + log::info!("No runner defined for script {:?}", &self); + return Ok(()); }; - Ok(()) + return runner.run(&path); + } +} + +/// Trait to allow getting the runner for a script. +trait WithRunner { + /// Returns the runner for the script if any. + fn runner(&self) -> Option { + Some(ScriptRunner::default()) + } +} + +/// Represents a script that runs before the installation starts. +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PreScript { + #[serde(flatten)] + pub base: BaseScript, +} + +impl From for Script { + fn from(value: PreScript) -> Self { + Self::Pre(value) + } +} + +impl TryFrom