use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::process;
pub const GATEWAY: &str = "gateway";
pub const LOG_DIR_ENV: &str = "TURNOUT_LOG_DIR";
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Entry {
pub work: Work,
pub pid: u32,
pub birth: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<i32>,
pub started: u64,
#[serde(default)]
pub detached: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready: Option<Ready>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ended: Option<Ended>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signals: Vec<crate::signal::Signal>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Work {
Command { app: String, command: String },
Gateway {
ports: BTreeMap<u16, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
front_port: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
control: Option<Control>,
},
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
pub struct Control {
pub port: u16,
pub token: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Ready {
pub at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Ended {
pub at: u64,
pub code: Option<i32>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Status {
Running,
Exited(Option<i32>),
Gone,
}
impl Entry {
pub fn own(work: Work, detached: bool, log: Option<PathBuf>) -> Self {
Self {
work,
pid: std::process::id(),
birth: process::own_birth(),
group: process::own_group(),
started: now(),
detached,
log,
ready: None,
ended: None,
signals: Vec::new(),
}
}
pub fn key(&self) -> String {
match &self.work {
Work::Command { app, command } => command_key(app, command),
Work::Gateway { .. } => GATEWAY.to_string(),
}
}
pub fn status(&self) -> Status {
if let Some(ended) = &self.ended {
return Status::Exited(ended.code);
}
if process::is_alive(self.pid, self.birth) {
Status::Running
} else {
Status::Gone
}
}
pub fn is_running(&self) -> bool {
self.status() == Status::Running
}
pub fn app(&self) -> Option<&str> {
match &self.work {
Work::Command { app, .. } => Some(app),
Work::Gateway { .. } => None,
}
}
pub fn title(&self) -> String {
match &self.work {
Work::Command { app, command } => format!("{app} {command}"),
Work::Gateway { .. } => GATEWAY.to_string(),
}
}
}
pub fn command_key(app: &str, command: &str) -> String {
format!("{}.{}", escape(app), escape(command))
}
fn escape(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for byte in name.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
if out.is_empty() {
return "%".to_string();
}
let lower = out.to_ascii_lowercase();
let reserved = matches!(lower.as_str(), "con" | "prn" | "aux" | "nul")
|| (lower.len() == 4 && (lower.starts_with("com") || lower.starts_with("lpt")) && lower.as_bytes()[3].is_ascii_digit());
if reserved {
out = format!("%{:02X}{}", out.as_bytes()[0], &out[1..]);
}
out
}
pub fn jobs_dir() -> Result<PathBuf> {
Ok(crate::paths::data_dir()?.join("jobs"))
}
pub fn logs_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os(LOG_DIR_ENV).filter(|dir| !dir.is_empty()) {
return Ok(PathBuf::from(dir));
}
Ok(crate::paths::data_dir()?.join("logs"))
}
pub fn log_path(key: &str) -> Result<PathBuf> {
Ok(logs_dir()?.join(format!("{key}.log")))
}
pub fn failed_log(log: &Path) -> PathBuf {
let name = log.file_name().map(std::ffi::OsStr::to_os_string).unwrap_or_default();
log.parent().unwrap_or(Path::new("")).join("failed").join(name)
}
fn record_path(dir: &Path, key: &str) -> PathBuf {
dir.join(format!("{key}.json"))
}
pub fn load(key: &str) -> Result<Option<Entry>> {
load_in(&jobs_dir()?, key)
}
fn load_in(dir: &Path, key: &str) -> Result<Option<Entry>> {
let path = record_path(dir, key);
match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str(&text) {
Ok(entry) => Ok(Some(entry)),
Err(err) => {
crate::progress::warn(&format!("ignoring the unreadable job record {}: {err}", path.display()));
Ok(None)
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("cannot read {}", path.display())),
}
}
pub fn list() -> Result<Vec<Entry>> {
let dir = jobs_dir()?;
let reader = match std::fs::read_dir(&dir) {
Ok(reader) => reader,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err).with_context(|| format!("cannot read {}", dir.display())),
};
let mut keys: Vec<String> = reader
.filter_map(|entry| entry.ok())
.filter_map(|entry| entry.file_name().to_str().and_then(|name| name.strip_suffix(".json")).map(str::to_string))
.collect();
keys.sort();
let mut entries = Vec::new();
for key in keys {
if let Some(entry) = load_in(&dir, &key)? {
entries.push(entry);
}
}
Ok(entries)
}
pub fn save(entry: &Entry) -> Result<()> {
let dir = jobs_dir()?;
std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
let key = entry.key();
let path = record_path(&dir, &key);
let aside = dir.join(format!("{key}.json.{}.tmp", std::process::id()));
std::fs::write(&aside, serde_json::to_string_pretty(entry)?).with_context(|| format!("cannot write {}", aside.display()))?;
std::fs::rename(&aside, &path).with_context(|| format!("cannot write {}", path.display()))
}
pub fn remove(key: &str) -> Result<()> {
let path = record_path(&jobs_dir()?, key);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("cannot remove {}", path.display())),
}
}
pub fn update_own(key: &str, change: impl FnOnce(&mut Entry)) -> Result<()> {
let Some(mut entry) = load(key)? else { return Ok(()) };
if entry.pid != std::process::id() || entry.birth != process::own_birth() {
return Ok(());
}
change(&mut entry);
save(&entry)
}
pub fn remove_own(key: &str) -> Result<()> {
match load(key)? {
Some(entry) if entry.pid == std::process::id() && entry.birth == process::own_birth() => remove(key),
_ => Ok(()),
}
}
pub fn gateway() -> Result<Option<crate::model::Gateway>> {
Ok(load(GATEWAY)?.filter(Entry::is_running).and_then(as_gateway))
}
pub fn as_gateway(entry: Entry) -> Option<crate::model::Gateway> {
match entry.work {
Work::Gateway { ports, front_port, control } => Some(crate::model::Gateway {
pid: entry.pid,
started: entry.started,
ports,
front_port,
control,
signals: entry.signals,
}),
Work::Command { .. } => None,
}
}
pub fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_file_names_and_never_collide() {
assert_eq!(command_key("myapp", "dev"), "myapp.dev");
assert_eq!(command_key("myapp", "test:e2e"), "myapp.test%3Ae2e");
assert_ne!(command_key("myapp", "test:e2e"), command_key("myapp", "test-e2e"));
assert_ne!(command_key("a", "b-c"), command_key("a-b", "c"));
assert_eq!(command_key("myapp", "../etc/passwd"), "myapp...%2Fetc%2Fpasswd");
assert_eq!(command_key("myapp", "a b"), "myapp.a%20b");
assert_eq!(command_key("myapp", ""), "myapp.%");
assert_eq!(escape("nul"), "%6Eul");
assert_eq!(escape("COM1"), "%43OM1");
assert_eq!(escape("com"), "com");
assert_eq!(escape("console"), "console");
assert!(!command_key("gateway", "x").eq(GATEWAY));
}
#[test]
fn records_read_back_as_written() {
let gateway = Work::Gateway {
ports: BTreeMap::from([(7100, "web".to_string()), (7101, "api".to_string())]),
front_port: Some(80),
control: Some(Control {
port: 51234,
token: "0f".repeat(32),
}),
};
let command = Work::Command {
app: "myapp".into(),
command: "test:e2e".into(),
};
for work in [gateway, command] {
let mut entry = Entry::own(work.clone(), true, Some(PathBuf::from("x.log")));
entry.ready = Some(Ready {
at: 5,
url: Some("http://a.localhost".into()),
});
entry.ended = Some(Ended { at: 9, code: None });
entry.signals = vec![crate::signal::Signal {
kind: crate::signal::Kind::StandDown,
app: "web".into(),
server: "prod".into(),
status: None,
since: 7,
}];
let text = serde_json::to_string(&entry).unwrap();
let back: Entry = serde_json::from_str(&text).unwrap_or_else(|err| panic!("{err}: {text}"));
assert_eq!(back.work, work);
assert_eq!((back.pid, back.birth, back.detached), (entry.pid, entry.birth, true));
assert_eq!(back.ready.unwrap().url.as_deref(), Some("http://a.localhost"));
assert_eq!(back.ended.unwrap().code, None);
assert_eq!(back.signals, entry.signals);
}
}
#[test]
fn a_record_knows_whether_its_process_is_the_one_it_names() {
let work = Work::Command {
app: "myapp".into(),
command: "dev".into(),
};
let mut entry = Entry::own(work, false, None);
assert_eq!(entry.status(), Status::Running);
entry.birth = entry.birth.wrapping_add(1);
assert_eq!(entry.status(), Status::Gone);
entry.ended = Some(Ended { at: now(), code: Some(3) });
assert_eq!(entry.status(), Status::Exited(Some(3)));
}
}