use crate::context::GlobalParams;
use crate::error::{Error, ErrorKind, Result};
use crate::logger;
use crate::modules::{Module, ModuleResult, parse_params};
#[cfg(feature = "docs")]
use rash_derive::DocJsonSchema;
use minijinja::Value;
#[cfg(feature = "docs")]
use schemars::{JsonSchema, Schema};
use serde::Deserialize;
use serde_norway::Value as YamlValue;
use serde_norway::value;
use serde_with::{OneOrMany, serde_as};
use std::path::PathBuf;
use std::process::Command;
fn default_executable() -> Option<String> {
Some("dpkg".to_owned())
}
#[derive(Clone, Debug, Default, PartialEq, Deserialize)]
#[cfg_attr(feature = "docs", derive(JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum Selection {
Install,
Hold,
Deinstall,
Purge,
#[default]
Unhold,
}
impl Selection {
fn as_str(&self) -> &'static str {
match self {
Selection::Install => "install",
Selection::Hold => "hold",
Selection::Deinstall => "deinstall",
Selection::Purge => "purge",
Selection::Unhold => "install",
}
}
}
#[serde_as]
#[derive(Debug, PartialEq, Deserialize)]
#[cfg_attr(feature = "docs", derive(JsonSchema, DocJsonSchema))]
#[serde(deny_unknown_fields)]
pub struct Params {
#[serde(default = "default_executable")]
executable: Option<String>,
#[serde_as(deserialize_as = "OneOrMany<_>")]
#[serde(default)]
pub name: Vec<String>,
pub selection: Option<Selection>,
}
struct DpkgClient {
executable: PathBuf,
check_mode: bool,
}
impl DpkgClient {
pub fn new(params: &Params, check_mode: bool) -> Result<Self> {
Ok(DpkgClient {
executable: PathBuf::from(params.executable.as_ref().unwrap()),
check_mode,
})
}
fn get_current_selection(&self, package: &str) -> Result<Option<String>> {
let output = Command::new(&self.executable)
.arg("--get-selections")
.arg(package)
.output()
.map_err(|e| {
Error::new(
ErrorKind::SubprocessFail,
format!(
"Failed to execute {} --get-selections: {}",
self.executable.display(),
e
),
)
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() || !output.status.success() {
return Ok(None);
}
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 && parts[0] == package {
Ok(Some(parts[1].to_string()))
} else {
Ok(None)
}
}
fn set_selection(&self, package: &str, selection: &str) -> Result<()> {
if self.check_mode {
return Ok(());
}
let input = format!("{} {}", package, selection);
let child = Command::new(&self.executable)
.arg("--set-selections")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
Error::new(
ErrorKind::SubprocessFail,
format!(
"Failed to execute {} --set-selections: {}",
self.executable.display(),
e
),
)
})?;
if let Some(mut stdin) = child.stdin.as_ref() {
use std::io::Write;
writeln!(stdin, "{}", input).map_err(|e| {
Error::new(
ErrorKind::SubprocessFail,
format!(
"Failed to write to {} --set-selections: {}",
self.executable.display(),
e
),
)
})?;
}
let output = child.wait_with_output().map_err(|e| {
Error::new(
ErrorKind::SubprocessFail,
format!(
"Failed to wait for {} --set-selections: {}",
self.executable.display(),
e
),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::new(
ErrorKind::SubprocessFail,
format!(
"{} --set-selections failed: {}",
self.executable.display(),
stderr
),
));
}
Ok(())
}
}
fn dpkg_selections_impl(params: Params, check_mode: bool) -> Result<ModuleResult> {
if params.name.is_empty() {
return Err(Error::new(
ErrorKind::InvalidData,
"name parameter is required",
));
}
let packages: Vec<&str> = params.name.iter().map(|s| s.trim()).collect();
for pkg in &packages {
if pkg.is_empty() {
return Err(Error::new(
ErrorKind::InvalidData,
"package name cannot be empty",
));
}
}
let client = DpkgClient::new(¶ms, check_mode)?;
let selection = params.selection.unwrap_or_default();
let selection_str = selection.as_str();
let mut changed_packages: Vec<String> = Vec::new();
let mut unchanged_packages: Vec<String> = Vec::new();
let mut current_selections: Vec<(String, String)> = Vec::new();
for package in &packages {
let current = client.get_current_selection(package)?;
current_selections.push((
package.to_string(),
current.clone().unwrap_or_else(|| "unknown".to_string()),
));
if let Some(ref current_sel) = current
&& current_sel == selection_str
{
unchanged_packages.push(package.to_string());
continue;
}
changed_packages.push(package.to_string());
client.set_selection(package, selection_str)?;
}
let changed = !changed_packages.is_empty();
if changed {
logger::add(&changed_packages);
}
let extra = Some(value::to_value(json!({
"packages": packages.iter().map(|s| s.to_string()).collect::<Vec<String>>(),
"selection": selection_str,
"changed_packages": changed_packages,
"unchanged_packages": unchanged_packages,
"current_selections": current_selections,
}))?);
Ok(ModuleResult {
changed,
output: None,
extra,
})
}
#[derive(Debug)]
pub struct DpkgSelections;
impl Module for DpkgSelections {
fn get_name(&self) -> &str {
"dpkg_selections"
}
fn exec(
&self,
_global_params: &GlobalParams,
optional_params: YamlValue,
_vars: &Value,
check_mode: bool,
) -> Result<(ModuleResult, Option<Value>)> {
let params: Params = parse_params(optional_params)?;
Ok((dpkg_selections_impl(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())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_params_single_package() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: nginx
selection: hold
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.name, vec!["nginx".to_string()]);
assert_eq!(params.selection, Some(Selection::Hold));
}
#[test]
fn test_parse_params_multiple_packages() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name:
- nginx
- docker-ce
selection: hold
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(
params.name,
vec!["nginx".to_string(), "docker-ce".to_string()]
);
assert_eq!(params.selection, Some(Selection::Hold));
}
#[test]
fn test_parse_params_no_selection() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: nginx
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.name, vec!["nginx".to_string()]);
assert_eq!(params.selection, None);
}
#[test]
fn test_parse_params_install() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: nginx
selection: install
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.selection, Some(Selection::Install));
}
#[test]
fn test_parse_params_deinstall() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: old-package
selection: deinstall
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.selection, Some(Selection::Deinstall));
}
#[test]
fn test_parse_params_purge() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: old-package
selection: purge
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.selection, Some(Selection::Purge));
}
#[test]
fn test_parse_params_unhold() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: nginx
selection: unhold
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.selection, Some(Selection::Unhold));
}
#[test]
fn test_parse_params_unknown_field() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: nginx
unknown: field
"#,
)
.unwrap();
let error = parse_params::<Params>(yaml).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidData);
}
#[test]
fn test_selection_as_str() {
assert_eq!(Selection::Install.as_str(), "install");
assert_eq!(Selection::Hold.as_str(), "hold");
assert_eq!(Selection::Deinstall.as_str(), "deinstall");
assert_eq!(Selection::Purge.as_str(), "purge");
assert_eq!(Selection::Unhold.as_str(), "install");
}
#[test]
fn test_parse_params_empty_name() {
let yaml: YamlValue = serde_norway::from_str(
r#"
name: []
selection: hold
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert!(params.name.is_empty());
}
#[test]
fn test_parse_params_executable() {
let yaml: YamlValue = serde_norway::from_str(
r#"
executable: /usr/bin/dpkg
name: nginx
selection: hold
"#,
)
.unwrap();
let params: Params = parse_params(yaml).unwrap();
assert_eq!(params.executable, Some("/usr/bin/dpkg".to_string()));
assert_eq!(params.name, vec!["nginx".to_string()]);
assert_eq!(params.selection, Some(Selection::Hold));
}
}