use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::{OlError, OL_4300_PROCESS_SPEC_INVALID};
use crate::generated::{ManifestBinding, ManifestProcess, ManifestProcessRestart};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestartPolicy {
Always,
OnFailure,
Never,
}
impl RestartPolicy {
pub fn should_restart(self, clean_exit: bool) -> bool {
match self {
RestartPolicy::Always => true,
RestartPolicy::OnFailure => !clean_exit,
RestartPolicy::Never => false,
}
}
}
impl From<Option<ManifestProcessRestart>> for RestartPolicy {
fn from(value: Option<ManifestProcessRestart>) -> Self {
match value {
Some(ManifestProcessRestart::Always) | None => RestartPolicy::Always,
Some(ManifestProcessRestart::OnFailure) => RestartPolicy::OnFailure,
Some(ManifestProcessRestart::No) => RestartPolicy::Never,
}
}
}
#[derive(Debug, Clone)]
pub struct HealthCheckSpec {
pub path: String,
pub port: u16,
pub startup_period: Duration,
pub startup_timeout: Duration,
pub liveness_period: Duration,
pub liveness_failure_threshold: u32,
pub liveness_timeout: Duration,
}
#[derive(Debug, Clone, Copy)]
pub struct RestartLimitSpec {
pub max_restarts: u32,
pub window: Duration,
}
#[derive(Debug, Clone)]
pub struct ProcessSpec {
pub binding_id: String,
pub tool_slug: String,
pub command: Vec<String>,
pub cwd: PathBuf,
pub env: HashMap<String, String>,
pub restart: RestartPolicy,
pub start_timeout: Duration,
pub kill_timeout: Duration,
pub health: HealthCheckSpec,
pub restart_limit: RestartLimitSpec,
}
impl ProcessSpec {
pub fn from_manifest(
binding_id: impl Into<String>,
binding: &ManifestBinding,
manifest_dir: &Path,
) -> Result<Self, OlError> {
let tool_slug = binding.tool.to_string();
let p: &ManifestProcess = &binding.process;
let command: Vec<String> = p.command.iter().map(|c| c.to_string()).collect();
if command.is_empty() {
return Err(OlError::new(
OL_4300_PROCESS_SPEC_INVALID,
format!(
"binding `{tool_slug}` has an empty process.command (JSON Schema \
enforces minItems=1; this is a typify shape mismatch — please report)"
),
));
}
if command[0].is_empty() {
return Err(OlError::new(
OL_4300_PROCESS_SPEC_INVALID,
format!("binding `{tool_slug}`: process.command[0] (the program) is empty"),
));
}
let cwd = match &p.cwd {
Some(path) => {
let raw = PathBuf::from(path);
if raw.is_absolute() {
raw
} else {
manifest_dir.join(raw)
}
}
None => manifest_dir.to_path_buf(),
};
let port_u64 = u64::from(p.health_check.http.port);
let port: u16 = port_u64.try_into().map_err(|_| {
OlError::new(
OL_4300_PROCESS_SPEC_INVALID,
format!(
"binding `{tool_slug}`: process.health_check.http.port {port_u64} \
exceeds u16::MAX (65535)"
),
)
})?;
let path = p
.health_check
.http
.path
.as_ref()
.map(|p| p.to_string())
.unwrap_or_else(|| "/healthz".to_string());
let health = HealthCheckSpec {
path,
port,
startup_period: ms_or(p.health_check.http.startup_period_ms, 1_000),
startup_timeout: ms_or(p.health_check.http.startup_timeout_ms, 2_000),
liveness_period: ms_or(p.health_check.http.liveness_period_ms, 10_000),
liveness_failure_threshold: p
.health_check
.http
.liveness_failure_threshold
.map(|n| u64::from(n).min(u32::MAX as u64) as u32)
.unwrap_or(3),
liveness_timeout: ms_or(p.health_check.http.liveness_timeout_ms, 2_000),
};
let restart_limit = RestartLimitSpec {
max_restarts: p
.restart_policy
.as_ref()
.and_then(|rp| rp.max_restarts)
.map(|n| u64::from(n).min(u32::MAX as u64) as u32)
.unwrap_or(5),
window: p
.restart_policy
.as_ref()
.and_then(|rp| rp.window_ms)
.map(ms_to_duration)
.unwrap_or_else(|| Duration::from_secs(60)),
};
Ok(Self {
binding_id: binding_id.into(),
tool_slug,
command,
cwd,
env: p.env.clone(),
restart: p.restart.into(),
start_timeout: ms_or(p.start_timeout_ms, 30_000),
kill_timeout: ms_or(p.kill_timeout_ms, 5_000),
health,
restart_limit,
})
}
pub fn health_url(&self) -> String {
format!("http://127.0.0.1:{}{}", self.health.port, self.health.path)
}
}
fn ms_or(value: Option<i64>, default_ms: u64) -> Duration {
match value {
Some(n) if n > 0 => Duration::from_millis(n as u64),
_ => Duration::from_millis(default_ms),
}
}
fn ms_to_duration(value: i64) -> Duration {
if value <= 0 {
Duration::from_millis(0)
} else {
Duration::from_millis(value as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn restart_policy_default_is_always() {
let p: RestartPolicy = None.into();
assert_eq!(p, RestartPolicy::Always);
assert!(p.should_restart(true));
assert!(p.should_restart(false));
}
#[test]
fn restart_policy_on_failure_respects_clean_exit() {
assert!(!RestartPolicy::OnFailure.should_restart(true));
assert!(RestartPolicy::OnFailure.should_restart(false));
}
#[test]
fn restart_policy_never_does_not_restart() {
assert!(!RestartPolicy::Never.should_restart(true));
assert!(!RestartPolicy::Never.should_restart(false));
}
}