use crate::error::RegentError;
use crate::hosts::managed_host::InternalApiCallOutcome;
use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
use crate::hosts::properties::{HostProperties, OsKind};
use crate::secrets::SecretProvidersPool;
use crate::state::Check;
use crate::state::attribute::HostHandler;
use crate::state::attribute::Privilege;
use crate::state::attribute::Remediation;
use crate::state::attribute::RemediationsList;
use crate::state::compliance::AttributeComplianceAssessment;
use serde::{Deserialize, Serialize};
use std::time::Duration;
const REGENT_MARKER_PREFIX: &str = "# regent: ";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CronExpectedState {
Present,
Absent,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CronSpecialTime {
Reboot,
Yearly,
Annually,
Monthly,
Weekly,
Daily,
Hourly,
}
impl std::fmt::Display for CronSpecialTime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
CronSpecialTime::Reboot => "reboot",
CronSpecialTime::Yearly => "yearly",
CronSpecialTime::Annually => "annually",
CronSpecialTime::Monthly => "monthly",
CronSpecialTime::Weekly => "weekly",
CronSpecialTime::Daily => "daily",
CronSpecialTime::Hourly => "hourly",
};
write!(f, "{}", s)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CronSchedule {
Custom {
minute: Option<String>,
hour: Option<String>,
day: Option<String>,
month: Option<String>,
weekday: Option<String>,
},
SpecialTime(CronSpecialTime),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CronCommand {
Any,
#[serde(untagged)]
Specific(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CronFile {
All,
#[serde(untagged)]
Specific(String),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CronTarget {
Crontab(Option<String>), #[serde(untagged)]
CronDFile(String, String), }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[serde(rename_all = "PascalCase")]
pub struct CronBlockExpectedState {
schedule: CronSchedule,
command: CronCommand,
state: CronExpectedState,
name: Option<String>,
target: CronTarget,
}
impl Timeout for CronBlockExpectedState {
fn default_timeout(&self) -> Duration {
Duration::from_secs(5)
}
}
impl CronBlockExpectedState {
pub fn absent(
schedule: CronSchedule,
command: CronCommand,
name: Option<String>,
target: CronTarget,
) -> CronBlockExpectedState {
CronBlockExpectedState {
schedule,
command,
state: CronExpectedState::Absent,
name,
target,
}
}
pub fn present(
schedule: CronSchedule,
command: CronCommand,
name: Option<String>,
target: CronTarget,
) -> CronBlockExpectedState {
CronBlockExpectedState {
schedule,
command,
state: CronExpectedState::Present,
name,
target,
}
}
}
impl Check for CronBlockExpectedState {
fn check(&self) -> Result<(), RegentError> {
Ok(())
}
fn check_host_compatibility(
&self,
host_properties: &HostProperties,
) -> Result<(), RegentError> {
match host_properties.os_kind() {
OsKind::Linux(_) => Ok(()),
incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
"Host is {:?} but cron management is only supported on Linux",
incompatible_os_kind
))),
}
}
}
impl<Handler: HostHandler> AssessCompliance<Handler> for CronBlockExpectedState {
async fn assess_compliance(
&self,
host_handler: &mut Handler,
host_properties: &Option<HostProperties>,
privilege: &Privilege,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<AttributeComplianceAssessment, RegentError> {
if let Some(props) = host_properties {
self.check_host_compatibility(props)?;
}
match &self.target {
CronTarget::Crontab(_) => {
if !host_handler
.is_this_command_available("crontab", privilege)
.await
.unwrap()
{
return Err(RegentError::FailedDryRunEvaluation(
"crontab not available on this host".to_string(),
));
}
}
CronTarget::CronDFile(_, _) => {
}
}
let content = match get_cron_content(host_handler, &self.target).await {
Ok(c) => c,
Err(e) => return Err(RegentError::FailedDryRunEvaluation(e)),
};
let name = self.name.as_ref().map_or("", |n| n.as_str());
let existing_entry = find_cron_entry(&content, name);
let cron_d_user = match &self.target {
CronTarget::Crontab(_) => None,
CronTarget::CronDFile(user, _) => Some(user.clone()),
};
let command = match &self.command {
CronCommand::Any => String::new(),
CronCommand::Specific(cmd) => cmd.clone(),
};
match &self.state {
CronExpectedState::Absent => {
if existing_entry.is_none() {
return Ok(AttributeComplianceAssessment::Compliant);
}
let (name, user, cron_file) = match &self.target {
CronTarget::Crontab(user) => {
(self.name.clone().unwrap_or_default(), user.clone(), None)
}
CronTarget::CronDFile(user, file) => (
self.name.clone().unwrap_or_default(),
Some(user.clone()),
Some(file.clone()),
),
};
Ok(AttributeComplianceAssessment::NonCompliant(
RemediationsList::from(vec![Remediation::Cron(CronApiCall::from(
CronModuleInternalApiCall::Remove {
name,
user,
cron_file,
},
privilege.clone(),
))])
.unwrap(),
))
}
CronExpectedState::Present => {
let expected_line = build_cron_line(&self.schedule, cron_d_user, command);
let needs_upsert = match existing_entry {
None => true,
Some(ref current) => current != &expected_line,
};
if needs_upsert {
let (name, user, cron_file) = match &self.target {
CronTarget::Crontab(user) => {
(self.name.clone().unwrap_or_default(), user.clone(), None)
}
CronTarget::CronDFile(user, file) => (
self.name.clone().unwrap_or_default(),
Some(user.clone()),
Some(file.clone()),
),
};
return Ok(AttributeComplianceAssessment::NonCompliant(
RemediationsList::from(vec![Remediation::Cron(CronApiCall::from(
CronModuleInternalApiCall::Upsert {
name,
cron_line: expected_line,
user,
cron_file,
},
privilege.clone(),
))])
.unwrap(),
));
}
Ok(AttributeComplianceAssessment::Compliant)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CronModuleInternalApiCall {
Upsert {
name: String,
cron_line: String,
user: Option<String>,
cron_file: Option<String>,
},
Remove {
name: String,
user: Option<String>,
cron_file: Option<String>,
},
}
impl std::fmt::Display for CronModuleInternalApiCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CronModuleInternalApiCall::Upsert { name, .. } => {
write!(f, "upsert cron entry '{}'", name)
}
CronModuleInternalApiCall::Remove { name, .. } => {
write!(f, "remove cron entry '{}'", name)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CronApiCall {
pub api_call: CronModuleInternalApiCall,
privilege: Privilege,
}
impl CronApiCall {
pub fn display(&self) -> String {
match &self.api_call {
CronModuleInternalApiCall::Upsert { name, .. } => {
format!("Upsert cron entry '{}'", name)
}
CronModuleInternalApiCall::Remove { name, .. } => {
format!("Remove cron entry '{}'", name)
}
}
}
fn from(api_call: CronModuleInternalApiCall, privilege: Privilege) -> CronApiCall {
CronApiCall {
api_call,
privilege,
}
}
}
impl Check for CronApiCall {
fn check(&self) -> Result<(), RegentError> {
Ok(())
}
fn check_host_compatibility(
&self,
host_properties: &HostProperties,
) -> Result<(), RegentError> {
match host_properties.os_kind() {
OsKind::Linux(_) => Ok(()),
incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
"Host is {:?} but cron management is only supported on Linux",
incompatible_os_kind
))),
}
}
}
impl<Handler: HostHandler> ReachCompliance<Handler> for CronApiCall {
async fn call(
&self,
host_handler: &mut Handler,
host_properties: &Option<HostProperties>,
_optional_secret_provider: &Option<SecretProvidersPool>,
) -> Result<InternalApiCallOutcome, RegentError> {
if let Some(props) = host_properties {
self.check_host_compatibility(props)?;
}
let (cmd, privilege) = match &self.api_call {
CronModuleInternalApiCall::Upsert {
name,
cron_line,
user,
cron_file,
} => {
let cmd = if let Some(file) = cron_file {
format!(
"touch /etc/cron.d/{f} && sed -i '/^# regent: {n}$/{{N;d;}}' /etc/cron.d/{f} && printf '# regent: {n}\\n{l}\\n' >> /etc/cron.d/{f}",
f = file,
n = name,
l = cron_line
)
} else {
let uf = user_flag(user);
format!(
"(crontab -l {uf}2>/dev/null | sed '/^# regent: {n}$/{{N;d;}}'; printf '# regent: {n}\\n{l}\\n') | crontab {uf}-",
uf = uf,
n = name,
l = cron_line
)
};
(cmd, &self.privilege)
}
CronModuleInternalApiCall::Remove {
name,
user,
cron_file,
} => {
let cmd = if let Some(file) = cron_file {
format!(
"sed -i '/^# regent: {n}$/{{N;d;}}' /etc/cron.d/{f}",
n = name,
f = file
)
} else {
let uf = user_flag(user);
format!(
"crontab -l {uf}2>/dev/null | sed '/^# regent: {n}$/{{N;d;}}' | crontab {uf}-",
uf = uf,
n = name
)
};
(cmd, &self.privilege)
}
};
let cmd_result = host_handler
.run_command(cmd.as_str(), privilege)
.await
.unwrap();
if cmd_result.return_code == 0 {
Ok(InternalApiCallOutcome::Success(None))
} else {
Ok(InternalApiCallOutcome::Failure(format!(
"RC: {}, STDOUT: {}, STDERR: {}",
cmd_result.return_code, cmd_result.stdout, cmd_result.stderr
)))
}
}
}
fn user_flag(user: &Option<String>) -> String {
match user {
Some(u) => format!("-u {} ", u),
None => String::new(),
}
}
async fn get_cron_content<Handler: HostHandler>(
host_handler: &mut Handler,
target: &CronTarget,
) -> Result<String, String> {
match target {
CronTarget::Crontab(potential_user) => {
let cmd = match potential_user {
Some(u) => format!("crontab -l -u {}", u),
None => "crontab -l".to_string(),
};
let result = host_handler
.run_command(&cmd, &Privilege::None)
.await
.map_err(|e| format!("Failed to read crontab: {:?}", e))?;
Ok(if result.return_code == 0 {
result.stdout
} else {
String::new()
})
}
CronTarget::CronDFile(user, filename) => {
let result = host_handler
.run_command(&format!("cat /etc/cron.d/{}", filename), &Privilege::None)
.await
.map_err(|e| format!("Failed to read cron file: {:?}", e))?;
Ok(if result.return_code == 0 {
result.stdout
} else {
String::new()
})
}
}
}
fn find_cron_entry(content: &str, name: &str) -> Option<String> {
let marker = format!("{}{}", REGENT_MARKER_PREFIX, name);
let mut lines = content.lines();
while let Some(line) = lines.next() {
if line == marker {
return lines
.skip_while(|l| l.trim().is_empty())
.next()
.map(|l| l.to_string());
}
}
None
}
fn build_cron_line(
schedule: &CronSchedule,
cron_d_user: Option<String>,
command: String,
) -> String {
let timing = match schedule {
CronSchedule::Custom {
minute,
hour,
day,
month,
weekday,
} => {
format!(
"{} {} {} {} {}",
minute.as_deref().unwrap_or("*"),
hour.as_deref().unwrap_or("*"),
day.as_deref().unwrap_or("*"),
month.as_deref().unwrap_or("*"),
weekday.as_deref().unwrap_or("*"),
)
}
CronSchedule::SpecialTime(cron_special_time) => {
format!("@{}", cron_special_time)
}
};
let body = match cron_d_user {
Some(user) => {
format!("{} {} {}", timing, user, command)
}
None => {
format!("{} {}", timing, command)
}
};
body
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn find_cron_entry_found() {
let content = "# regent: backup\n0 2 * * * /usr/local/bin/backup.sh\n";
assert_eq!(
find_cron_entry(content, "backup"),
Some("0 2 * * * /usr/local/bin/backup.sh".to_string())
);
}
#[test]
fn find_cron_entry_not_found() {
let content = "# regent: other\n0 2 * * * /usr/local/bin/other.sh\n";
assert!(find_cron_entry(content, "backup").is_none());
}
}