use crate::context::GlobalParams;
use crate::error::{Error, ErrorKind, Result};
use crate::modules::{Module, ModuleResult, parse_params};
#[cfg(feature = "docs")]
use rash_derive::DocJsonSchema;
use log::trace;
use minijinja::Value;
#[cfg(feature = "docs")]
use schemars::{JsonSchema, Schema};
use serde::Deserialize;
use serde_norway::Value as YamlValue;
use std::process::{Command, Output};
#[cfg(feature = "docs")]
use strum_macros::{Display, EnumString};
fn default_state() -> State {
State::Present
}
#[derive(Clone, Copy, Debug, PartialEq, Deserialize)]
#[cfg_attr(feature = "docs", derive(EnumString, Display, JsonSchema))]
#[serde(rename_all = "lowercase")]
enum State {
Present,
Absent,
}
#[derive(Debug, PartialEq, Deserialize)]
#[cfg_attr(feature = "docs", derive(JsonSchema, DocJsonSchema))]
#[serde(deny_unknown_fields)]
pub struct Params {
vg: String,
pvs: Option<String>,
#[serde(default = "default_state")]
state: State,
#[serde(default)]
force: bool,
}
#[derive(Debug)]
pub struct Lvg;
impl Module for Lvg {
fn get_name(&self) -> &str {
"lvg"
}
fn exec(
&self,
_: &GlobalParams,
optional_params: YamlValue,
_vars: &Value,
check_mode: bool,
) -> Result<(ModuleResult, Option<Value>)> {
Ok((
lvg_module(parse_params(optional_params)?, check_mode)?,
None,
))
}
fn force_string_on_params(&self) -> bool {
false
}
#[cfg(feature = "docs")]
fn get_json_schema(&self) -> Option<Schema> {
Some(Params::get_json_schema())
}
}
struct LvgClient {
check_mode: bool,
}
impl LvgClient {
pub fn new(check_mode: bool) -> Self {
LvgClient { check_mode }
}
fn exec_cmd(&self, cmd: &mut Command, check_success: bool) -> Result<Output> {
let output = cmd
.output()
.map_err(|e| Error::new(ErrorKind::SubprocessFail, e))?;
trace!("command: `{cmd:?}`");
trace!("{output:?}");
if check_success && !output.status.success() {
return Err(Error::new(
ErrorKind::SubprocessFail,
format!(
"Error executing LVM command: {}",
String::from_utf8_lossy(&output.stderr)
),
));
}
Ok(output)
}
pub fn vg_exists(&self, vg_name: &str) -> Result<bool> {
let output = self.exec_cmd(
Command::new("vgs").args(["--noheadings", "-o", "vg_name", vg_name]),
false,
)?;
Ok(output.status.success())
}
pub fn get_vg_pvs(&self, vg_name: &str) -> Result<Vec<String>> {
let output = self.exec_cmd(
Command::new("vgs")
.args(["--noheadings", "-o", "pv_name", "--separator", ","])
.arg(vg_name),
false,
)?;
if !output.status.success() {
return Ok(vec![]);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let pvs: Vec<String> = stdout
.trim()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(pvs)
}
pub fn create_vg(&self, vg_name: &str, pvs: &[String]) -> Result<()> {
if self.check_mode {
return Ok(());
}
let mut cmd = Command::new("vgcreate");
cmd.arg(vg_name);
for pv in pvs {
cmd.arg(pv);
}
self.exec_cmd(&mut cmd, true)?;
Ok(())
}
pub fn extend_vg(&self, vg_name: &str, new_pvs: &[String]) -> Result<()> {
if self.check_mode || new_pvs.is_empty() {
return Ok(());
}
let mut cmd = Command::new("vgextend");
cmd.arg(vg_name);
for pv in new_pvs {
cmd.arg(pv);
}
self.exec_cmd(&mut cmd, true)?;
Ok(())
}
pub fn remove_vg(&self, vg_name: &str, force: bool) -> Result<()> {
if self.check_mode {
return Ok(());
}
let mut cmd = Command::new("vgremove");
if force {
cmd.arg("-f");
}
cmd.arg(vg_name);
self.exec_cmd(&mut cmd, true)?;
Ok(())
}
}
fn parse_pvs(pvs_str: &str) -> Vec<String> {
pvs_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
fn lvg_module(params: Params, check_mode: bool) -> Result<ModuleResult> {
let client = LvgClient::new(check_mode);
match params.state {
State::Present => {
let pvs_str = params.pvs.as_ref().ok_or_else(|| {
Error::new(
ErrorKind::InvalidData,
"pvs is required when state is present",
)
})?;
let desired_pvs = parse_pvs(pvs_str);
if desired_pvs.is_empty() {
return Err(Error::new(
ErrorKind::InvalidData,
"At least one physical volume must be specified",
));
}
if client.vg_exists(¶ms.vg)? {
let current_pvs = client.get_vg_pvs(¶ms.vg)?;
let new_pvs: Vec<String> = desired_pvs
.into_iter()
.filter(|pv| !current_pvs.contains(pv))
.collect();
if new_pvs.is_empty() {
return Ok(ModuleResult::new(false, None, None));
}
client.extend_vg(¶ms.vg, &new_pvs)?;
Ok(ModuleResult::new(
true,
None,
Some(format!("Extended VG {} with new PVs", params.vg)),
))
} else {
client.create_vg(¶ms.vg, &desired_pvs)?;
Ok(ModuleResult::new(
true,
None,
Some(format!("Created VG {}", params.vg)),
))
}
}
State::Absent => {
if !client.vg_exists(¶ms.vg)? {
return Ok(ModuleResult::new(false, None, None));
}
client.remove_vg(¶ms.vg, params.force)?;
Ok(ModuleResult::new(
true,
None,
Some(format!("Removed VG {}", params.vg)),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_params_present() {
let yaml: YamlValue = serde_norway::from_str(
r#"
vg: data_vg
pvs: /dev/sdb1,/dev/sdc1
state: present
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.vg, "data_vg");
assert_eq!(params.pvs, Some("/dev/sdb1,/dev/sdc1".to_string()));
assert_eq!(params.state, State::Present);
assert!(!params.force);
}
#[test]
fn test_parse_params_absent() {
let yaml: YamlValue = serde_norway::from_str(
r#"
vg: old_vg
state: absent
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.vg, "old_vg");
assert_eq!(params.pvs, None);
assert_eq!(params.state, State::Absent);
assert!(!params.force);
}
#[test]
fn test_parse_params_force() {
let yaml: YamlValue = serde_norway::from_str(
r#"
vg: old_vg
state: absent
force: true
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert!(params.force);
}
#[test]
fn test_parse_params_default_state() {
let yaml: YamlValue = serde_norway::from_str(
r#"
vg: data_vg
pvs: /dev/sdb1
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.state, State::Present);
}
#[test]
fn test_parse_pvs() {
let pvs = parse_pvs("/dev/sdb1,/dev/sdc1,/dev/sdd1");
assert_eq!(pvs, vec!["/dev/sdb1", "/dev/sdc1", "/dev/sdd1"]);
}
#[test]
fn test_parse_pvs_with_spaces() {
let pvs = parse_pvs("/dev/sdb1, /dev/sdc1 , /dev/sdd1");
assert_eq!(pvs, vec!["/dev/sdb1", "/dev/sdc1", "/dev/sdd1"]);
}
#[test]
fn test_parse_pvs_empty() {
let pvs = parse_pvs("");
assert!(pvs.is_empty());
}
#[test]
fn test_parse_params_invalid_field() {
let yaml: YamlValue = serde_norway::from_str(
r#"
vg: data_vg
invalid_field: value
"#,
)
.unwrap();
let error = parse_params::<Params>(yaml).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidData);
}
}