use tokio::process::Command;
use tracing::trace;
use crate::ExecError::InvalidRunnerConfig;
use crate::subprocess::logger::LogConfig;
use crate::utils::{CgroupLimits, RlimitConfig, SecurityConfig};
use crate::utils::{attach_cgroup, attach_rlimits, attach_security};
#[derive(Debug, Clone, Default)]
pub struct SubprocessBackendConfig {
rlimits: Option<RlimitConfig>,
cgroups: Option<CgroupLimits>,
security: Option<SecurityConfig>,
logger: LogConfig,
}
impl SubprocessBackendConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_rlimits(mut self, rlimits: RlimitConfig) -> Self {
self.rlimits = Some(rlimits);
self
}
pub fn with_cgroups(mut self, cgroups: CgroupLimits) -> Self {
self.cgroups = Some(cgroups);
self
}
pub fn with_security(mut self, security: SecurityConfig) -> Self {
self.security = Some(security);
self
}
pub fn with_logger(mut self, config: LogConfig) -> Self {
self.logger = config;
self
}
pub(crate) fn log_config(&self) -> &LogConfig {
&self.logger
}
pub(crate) fn is_empty(&self) -> bool {
self.rlimits.is_none() && self.cgroups.is_none() && self.security.is_none()
}
pub(crate) fn validate(&self) -> Result<(), crate::ExecError> {
if let Some(cgroups) = &self.cgroups {
if let Some(cpu) = &cgroups.cpu {
if cpu.period == 0 {
return Err(InvalidRunnerConfig(
"cgroups.cpu.period cannot be zero".into(),
));
}
if let Some(q) = cpu.quota
&& q == 0
{
return Err(InvalidRunnerConfig(
"cgroups.cpu.quota cannot be zero (process would get no CPU)".into(),
));
}
if let Some(q) = cpu.quota
&& q > cpu.period
{
return Err(InvalidRunnerConfig(
"cgroups.cpu.quota exceeds period (>100% of one core)".into(),
));
}
}
if let Some(mem) = cgroups.memory
&& mem == 0
{
return Err(InvalidRunnerConfig("cgroups.memory cannot be zero".into()));
}
if let Some(pids) = cgroups.pids
&& pids == 0
{
return Err(InvalidRunnerConfig("cgroups.pids cannot be zero".into()));
}
}
if let Some(rlimits) = &self.rlimits
&& let Some(fsize) = rlimits.max_file_size_bytes
&& fsize == 0
{
return Err(InvalidRunnerConfig(
"rlimits.max_file_size_bytes cannot be zero".into(),
));
}
if self.logger.max_line_length == 0 {
return Err(InvalidRunnerConfig(
"log_config.max_line_length cannot be zero".into(),
));
}
Ok(())
}
pub(crate) fn has_cgroups(&self) -> bool {
self.cgroups.is_some()
}
pub(crate) fn prepare_cgroups(&self, cgroup_name: &str) -> Result<bool, crate::ExecError> {
if let Some(cgroups) = &self.cgroups {
trace!(
"subprocess backend: preparing cgroup: {:?} (group={})",
cgroups, cgroup_name
);
crate::utils::prepare_cgroup(cgroup_name, cgroups)
} else {
Ok(false)
}
}
pub(crate) fn apply_to_command(
&self,
cmd: &mut Command,
cgroup_name: &str,
) -> Result<(), crate::ExecError> {
if self.is_empty() {
trace!("subprocess backend: nothing to apply (empty config)");
return Ok(());
}
if let Some(rlimits) = &self.rlimits {
trace!("subprocess backend: attaching rlimits: {:?}", rlimits);
attach_rlimits(cmd, rlimits);
}
if let Some(cgroups) = &self.cgroups {
trace!(
"subprocess backend: attaching cgroup join hook (group={})",
cgroup_name
);
attach_cgroup(cmd, cgroup_name, cgroups)?;
}
if let Some(security) = &self.security {
trace!(
"subprocess backend: attaching security config: {:?}",
security
);
attach_security(cmd, security);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::CpuMax;
#[test]
fn valid_cpu_config_passes() {
let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
cpu: Some(CpuMax {
quota: Some(50_000),
period: 100_000,
}),
..Default::default()
});
assert!(cfg.validate().is_ok());
}
#[test]
fn cpu_period_zero_rejected() {
let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
cpu: Some(CpuMax {
quota: Some(50_000),
period: 0,
}),
..Default::default()
});
let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("period"), "expected period error, got: {err}");
}
#[test]
fn cpu_quota_zero_rejected() {
let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
cpu: Some(CpuMax {
quota: Some(0),
period: 100_000,
}),
..Default::default()
});
let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains("quota"), "expected quota error, got: {err}");
}
#[test]
fn cpu_quota_exceeds_period_rejected() {
let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
cpu: Some(CpuMax {
quota: Some(200_000),
period: 100_000,
}),
..Default::default()
});
let err = cfg.validate().unwrap_err().to_string();
assert!(
err.contains("exceeds period"),
"expected exceeds error, got: {err}"
);
}
#[test]
fn cpu_unlimited_quota_passes() {
let cfg = SubprocessBackendConfig::new().with_cgroups(CgroupLimits {
cpu: Some(CpuMax {
quota: None,
period: 100_000,
}),
..Default::default()
});
assert!(cfg.validate().is_ok());
}
}