use std::collections::BTreeMap;
use std::io::{Read, Seek, SeekFrom, Write};
use std::num::NonZeroU64;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::{json, Value};
use crate::cli::DEFAULT_HOOK_TIMEOUT_SECONDS;
use crate::error::Result;
use crate::graph::NodeStatus;
use crate::journal::{self, Journal, PipelineKind};
use crate::ledger::{self, LaunchRecord, RunPaths};
use crate::projection::RunState;
use crate::report;
use crate::sys;
use crate::views::{self, RunView};
const DOCUMENT_VERSION: u32 = 1;
const RESULTS_OUTPUT_LINES: usize = 20;
const MAX_TAIL_BYTES: u64 = 64 * 1024;
pub(crate) const POLL: Duration = Duration::from_millis(50);
pub(crate) const HOOK_ENV: &str = "ONEPIPELINE_HOOK";
pub(crate) const RUN_ID_ENV: &str = "ONEPIPELINE_RUN_ID";
pub(crate) const RUN_ROOT_ENV: &str = "ONEPIPELINE_RUN_ROOT";
pub(crate) const PAUSED: &str = "awaiting-planner";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Hook {
Success,
Failure,
}
impl Hook {
fn as_str(self) -> &'static str {
match self {
Self::Success => "success",
Self::Failure => "failure",
}
}
fn parse(word: &str) -> Option<Self> {
[Self::Success, Self::Failure]
.into_iter()
.find(|hook| hook.as_str() == word)
}
fn command(self, record: &LaunchRecord) -> Option<&str> {
match self {
Self::Success => record.success_hook(),
Self::Failure => record.failure_hook(),
}
}
}
impl std::fmt::Display for Hook {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ReasonKind {
Nodes,
Unfinished,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct Unsettled {
id: String,
status: &'static str,
outcome: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Reason {
kind: ReasonKind,
nodes: Vec<Unsettled>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Firing {
Success,
Failure(Reason),
}
impl Firing {
fn hook(&self) -> Hook {
match self {
Self::Success => Hook::Success,
Self::Failure(_) => Hook::Failure,
}
}
fn reason(&self) -> Option<&Reason> {
match self {
Self::Success => None,
Self::Failure(reason) => Some(reason),
}
}
}
struct Document<'a> {
run_id: &'a str,
run_root: String,
firing: &'a Firing,
}
impl Serialize for Document<'_> {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut document = serializer.serialize_struct("Document", 5)?;
document.serialize_field("version", &DOCUMENT_VERSION)?;
document.serialize_field("hook", &self.firing.hook())?;
document.serialize_field("run_id", self.run_id)?;
document.serialize_field("run_root", &self.run_root)?;
document.serialize_field("reason", &self.firing.reason())?;
document.end()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
enum Ending {
Succeeded,
Failed,
CouldNotStart,
TimedOut,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Judged {
Fire(Firing),
Withhold,
NotEnded,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Relay {
Stderr,
Quiet,
}
pub(crate) fn judge(state: &RunState, paths: &RunPaths) -> Judged {
let statuses = state.statuses();
if statuses.is_empty() {
return Judged::NotEnded;
}
if statuses.values().all(|status| *status == NodeStatus::Done) {
return Judged::Fire(Firing::Success);
}
if views::decision_outstanding(state, paths) {
return Judged::Withhold;
}
if statuses
.values()
.any(|status| *status == NodeStatus::CompleteDraft)
{
return Judged::NotEnded;
}
let kind = if statuses
.values()
.any(|status| matches!(status, NodeStatus::Failed | NodeStatus::Skipped))
{
ReasonKind::Nodes
} else {
ReasonKind::Unfinished
};
Judged::Fire(Firing::Failure(Reason {
kind,
nodes: unsettled(state, &statuses),
}))
}
fn unsettled(state: &RunState, statuses: &BTreeMap<String, NodeStatus>) -> Vec<Unsettled> {
state
.graph
.iter()
.filter_map(|node| {
let status = statuses
.get(&node.id)
.copied()
.unwrap_or(NodeStatus::Pending);
(status != NodeStatus::Done).then(|| Unsettled {
id: node.id.clone(),
status: status.as_str(),
outcome: state.outcomes.get(&node.id).cloned(),
})
})
.collect()
}
pub(crate) fn at_let_go(paths: &RunPaths, relay: Relay) {
let Some(view) = judged_view(paths) else {
return;
};
match judge(&view.state, paths) {
Judged::Fire(firing) => fire(paths, &view.launch, &firing, relay),
Judged::Withhold => withhold(paths),
Judged::NotEnded => {}
}
}
pub(crate) fn at_stop(paths: &RunPaths) {
let Some(view) = judged_view(paths) else {
return;
};
let statuses = view.state.statuses();
let firing = Firing::Failure(Reason {
kind: ReasonKind::Stopped,
nodes: unsettled(&view.state, &statuses),
});
fire(paths, &view.launch, &firing, Relay::Quiet);
}
fn judged_view(paths: &RunPaths) -> Option<RunView> {
let unjudged = |error: &dyn std::fmt::Display| {
eprintln!(
"onepipeline: whether run '{}' fires a run-end hook could not be judged: {error}",
paths.run
);
};
let record: LaunchRecord = match ledger::read_json(&paths.launch()) {
Ok(record) => record,
Err(error) => {
unjudged(&error);
return None;
}
};
if record.success_hook().is_none() && record.failure_hook().is_none() {
return None;
}
match RunView::open(paths) {
Ok(view) => Some(view),
Err(error) => {
unjudged(&error);
None
}
}
}
fn withhold(paths: &RunPaths) {
if fired(paths) {
return;
}
eprintln!(
"onepipeline: run '{}' is paused on a decision ({PAUSED}), so no run-end hook fired; \
the driver that adopts it once the decision is answered fires the hook the run then \
reaches",
paths.run
);
if let Err(error) = Journal::open(paths).emit(
PipelineKind::RunHookWithheld,
journal::labels(&paths.run, None),
journal::payload(&[("settlement", json!(PAUSED))]),
) {
eprintln!(
"onepipeline: the withheld run-end hook of run '{}' could not be recorded: {error}",
paths.run
);
}
}
fn fire(paths: &RunPaths, record: &LaunchRecord, firing: &Firing, relay: Relay) {
let hook = firing.hook();
let Some(command) = hook.command(record) else {
return;
};
match mark(paths, firing, command) {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
eprintln!(
"onepipeline: the {hook} hook of run '{}' was not fired, because its firing \
could not be recorded: {error}",
paths.run
);
return;
}
}
let log = log_path(paths, hook);
let ran = run(paths, record, firing, command, &log, relay);
if let Err(error) = Journal::open(paths).emit(
PipelineKind::RunHookFinished,
journal::labels(&paths.run, None),
journal::payload(&[
("hook", json!(hook)),
("exit", json!(ran.exit())),
("ending", json!(ran.ending())),
("log", json!(log.to_string_lossy())),
]),
) {
eprintln!(
"onepipeline: how the {hook} hook of run '{}' ended could not be recorded: {error}",
paths.run
);
}
}
fn mark(paths: &RunPaths, firing: &Firing, command: &str) -> Result<bool> {
let handover = ledger::Handover::hold(paths)?;
let marked = if fired(paths) {
Ok(false)
} else {
Journal::open(paths)
.emit(
PipelineKind::RunHookFired,
journal::labels(&paths.run, None),
journal::payload(&[
("hook", json!(firing.hook())),
("command", json!(command)),
("reason", json!(firing.reason())),
]),
)
.map(|()| true)
};
drop(handover);
marked
}
fn fired(paths: &RunPaths) -> bool {
let mut state = RunState {
strict: true,
..RunState::default()
};
let mut fired = false;
for event in &journal::read(&paths.journal()) {
let kind = PipelineKind::from_wire(&event.kind);
let edit = kind == Some(PipelineKind::EditCommitted);
let was_live = edit && fired && live(&state);
crate::projection::fold_one(&mut state, event);
if edit && fired && !was_live && state.strict && live(&state) {
fired = false;
} else if kind == Some(PipelineKind::RunHookFired) {
fired = true;
}
}
fired
}
fn live(state: &RunState) -> bool {
state.statuses().values().any(|status| match status {
NodeStatus::Ready | NodeStatus::Running => true,
NodeStatus::Waiting | NodeStatus::CompleteDraft => true,
NodeStatus::Pending
| NodeStatus::Blocked
| NodeStatus::Parked
| NodeStatus::Cancelled
| NodeStatus::Done
| NodeStatus::Failed
| NodeStatus::Skipped => false,
})
}
fn log_path(paths: &RunPaths, hook: Hook) -> PathBuf {
hook_log(paths, &hook.to_string())
}
pub(crate) fn hook_log(paths: &RunPaths, name: &str) -> PathBuf {
run_root(paths).join("hooks").join(format!("{name}.log"))
}
pub(crate) fn run_root(paths: &RunPaths) -> PathBuf {
std::path::absolute(&paths.dir).unwrap_or_else(|_| paths.dir.clone())
}
enum Ran {
Succeeded,
Failed(Option<i32>),
CouldNotStart,
TimedOut,
}
impl Ran {
fn ending(&self) -> Ending {
match self {
Self::Succeeded => Ending::Succeeded,
Self::Failed(_) => Ending::Failed,
Self::CouldNotStart => Ending::CouldNotStart,
Self::TimedOut => Ending::TimedOut,
}
}
fn exit(&self) -> Option<i32> {
match self {
Self::Succeeded => Some(0),
Self::Failed(code) => *code,
Self::CouldNotStart | Self::TimedOut => None,
}
}
}
fn run(
paths: &RunPaths,
record: &LaunchRecord,
firing: &Firing,
command: &str,
log: &Path,
relay: Relay,
) -> Ran {
let hook = firing.hook();
let opened = log
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::File::create(log));
let output = match opened {
Ok(output) => output,
Err(error) => {
eprintln!(
"onepipeline: the {hook} hook of run '{}' could not be started, because its log \
{} could not be opened: {error}",
paths.run,
log.display()
);
return Ran::CouldNotStart;
}
};
let document = Document {
run_id: &paths.run,
run_root: run_root(paths).to_string_lossy().into_owned(),
firing,
};
let mut spawning = std::process::Command::new(command);
if !record.dir.as_os_str().is_empty() {
spawning.current_dir(&record.dir);
}
spawning
.env(HOOK_ENV, hook.as_str())
.env(RUN_ID_ENV, &paths.run)
.env(RUN_ROOT_ENV, run_root(paths))
.stdin(Stdio::piped());
if record.owned_by(&record.session) {
spawning
.env(sys::LAUNCHER_ENV, &record.launcher)
.env(sys::LAUNCHER_SESSION_ENV, &record.session);
} else {
spawning
.env_remove(sys::LAUNCHER_ENV)
.env_remove(sys::LAUNCHER_SESSION_ENV);
}
let spawned = serde_json::to_string(&document)
.map_err(std::io::Error::other)
.and_then(|document| {
spawning
.stdout(output.try_clone()?)
.stderr(output.try_clone()?);
Ok((spawning.spawn()?, document))
});
let (mut child, document) = match spawned {
Ok(spawned) => spawned,
Err(error) => {
let _ = writeln!(
&output,
"onepipeline: the {hook} hook '{command}' could not be started: {error}"
);
return Ran::CouldNotStart;
}
};
if let Some(mut stdin) = child.stdin.take() {
std::thread::spawn(move || {
let _ = stdin.write_all(document.as_bytes());
});
}
let deadline = deadline_after(record.hook_timeout());
let mut relayed = 0;
let ran = loop {
match child.try_wait() {
Ok(Some(status)) => {
break if status.success() {
Ran::Succeeded
} else {
Ran::Failed(status.code())
}
}
Ok(None) if deadline.is_none_or(|deadline| Instant::now() < deadline) => {}
waited => {
let _ = sys::stop(child.id(), sys::Stop::Now);
let _ = child.kill();
let _ = child.wait();
break if waited.is_ok() {
Ran::TimedOut
} else {
Ran::Failed(None)
};
}
}
if relay == Relay::Stderr {
relayed = relay_from(log, relayed);
}
std::thread::sleep(POLL);
};
if relay == Relay::Stderr {
relay_from(log, relayed);
}
ran
}
pub(crate) fn deadline_after(timeout: NonZeroU64) -> Option<Instant> {
Instant::now().checked_add(Duration::from_secs(timeout.get()))
}
fn relay_from(log: &Path, from: u64) -> u64 {
let Ok(mut file) = open_log(log) else {
return from;
};
let mut said = Vec::new();
if file.seek(SeekFrom::Start(from)).is_err() || file.read_to_end(&mut said).is_err() {
return from;
}
let _ = std::io::stderr().write_all(&said);
from + said.len() as u64
}
pub(crate) fn results_lines(view: &RunView) -> String {
let mut out = String::new();
for (at, event) in view.events.iter().enumerate() {
match PipelineKind::from_wire(&event.kind) {
Some(PipelineKind::RunHookFired) => {
let Some(hook) = event
.payload
.get("hook")
.and_then(Value::as_str)
.and_then(Hook::parse)
else {
continue;
};
let reason = event
.payload
.get("reason")
.and_then(|reason| reason.get("kind"))
.and_then(Value::as_str)
.unwrap_or("none");
let log = log_path(&view.paths, hook);
let finished = view.events[at + 1..].iter().find(|later| {
PipelineKind::from_wire(&later.kind) == Some(PipelineKind::RunHookFinished)
&& later.payload.get("hook").and_then(Value::as_str) == Some(hook.as_str())
});
let ended = match finished {
Some(finished) => format!(
"ending: {}; exit: {}",
views::one_line(
finished
.payload
.get("ending")
.and_then(Value::as_str)
.unwrap_or("unrecorded")
),
finished
.payload
.get("exit")
.and_then(Value::as_i64)
.map_or_else(|| "none".to_string(), |code| code.to_string())
),
None => "still running".to_string(),
};
out.push_str(&format!(
" {hook} hook fired — reason: {}; {ended}; log: {}\n",
views::one_line(reason),
log.display()
));
match tail(&log) {
Ok(lines) => {
for line in lines {
out.push_str(&format!(" output: {}\n", views::one_line(&line)));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => out.push_str(&format!(
" log not read: {}\n",
views::one_line(&error.to_string())
)),
}
}
Some(PipelineKind::RunHookWithheld) => out.push_str(&format!(
" run-end hook withheld — the run is paused on a decision ({}), so no hook \
fired\n",
views::one_line(
event
.payload
.get("settlement")
.and_then(Value::as_str)
.unwrap_or(PAUSED)
)
)),
_ => {}
}
}
out
}
fn open_log(log: &Path) -> std::io::Result<std::fs::File> {
let not_plain = || {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{} is not a plain file this run wrote", log.display()),
)
};
if !std::fs::symlink_metadata(log)?.is_file() {
return Err(not_plain());
}
let file = report::open_no_follow(log)?;
if !file.metadata()?.is_file() {
return Err(not_plain());
}
Ok(file)
}
fn tail(log: &Path) -> std::io::Result<Vec<String>> {
let mut file = open_log(log)?;
let length = file.metadata()?.len();
let start = length.saturating_sub(MAX_TAIL_BYTES);
let mut said = Vec::new();
file.seek(SeekFrom::Start(start))?;
file.read_to_end(&mut said)?;
let text = String::from_utf8_lossy(&said);
let mut lines: Vec<&str> = text.lines().collect();
if start > 0 && !lines.is_empty() {
lines.remove(0);
}
let from = lines.len().saturating_sub(RESULTS_OUTPUT_LINES);
Ok(lines[from..]
.iter()
.map(|line| (*line).to_string())
.collect())
}
pub(crate) fn named(flag: Option<&str>, config: Option<&str>) -> Option<String> {
flag.or(config)
.map(str::trim)
.filter(|command| !command.is_empty())
.map(str::to_string)
}
pub(crate) fn refused_zero_timeout(spelling: &str) -> String {
format!(
"{spelling} names a hook timeout of zero seconds, which ends every hook before it has \
begun — give it a positive whole number of seconds, or leave it out to take \
{DEFAULT_HOOK_TIMEOUT_SECONDS} seconds"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::Graph;
use crate::plan::Node;
use crate::projection::Recorded;
fn scratch(name: &str) -> PathBuf {
let root =
std::env::temp_dir().join(format!("onepipeline-hooks-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("a scratch root");
root
}
fn holding(nodes: &[(&str, NodeStatus)]) -> RunState {
let mut graph = Graph::with_concurrency(4);
for (id, _) in nodes {
graph.insert(Node {
id: (*id).to_string(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
..Node::default()
});
}
RunState {
graph,
recorded: nodes
.iter()
.map(|(id, status)| ((*id).to_string(), Recorded::At(*status)))
.collect(),
..RunState::default()
}
}
#[test]
fn a_draft_waiting_on_a_release_has_not_ended_and_an_empty_graph_has_not_begun() {
let root = scratch("not-ended");
let paths = RunPaths::under(&root, "demo");
for beside in [NodeStatus::Done, NodeStatus::Failed, NodeStatus::Parked] {
assert_eq!(
judge(
&holding(&[("build", beside), ("lift", NodeStatus::CompleteDraft)]),
&paths
),
Judged::NotEnded,
"a draft beside a {} node was judged an ending",
beside.as_str()
);
}
assert_eq!(judge(&RunState::default(), &paths), Judged::NotEnded);
assert_eq!(
judge(
&holding(&[("build", NodeStatus::Done), ("lift", NodeStatus::Done)]),
&paths
),
Judged::Fire(Firing::Success)
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_cancelled_or_pending_node_ends_a_run_unfinished_in_plan_order() {
let root = scratch("unfinished");
let paths = RunPaths::under(&root, "demo");
let unfinished = |nodes: &[(&str, &'static str)]| {
Judged::Fire(Firing::Failure(Reason {
kind: ReasonKind::Unfinished,
nodes: nodes
.iter()
.map(|(id, status)| Unsettled {
id: (*id).to_string(),
status,
outcome: None,
})
.collect(),
}))
};
assert_eq!(
judge(
&holding(&[
("stopped", NodeStatus::Cancelled),
("build", NodeStatus::Done),
("waits", NodeStatus::Pending),
]),
&paths
),
unfinished(&[("stopped", "cancelled"), ("waits", "pending")])
);
for alone in [NodeStatus::Cancelled, NodeStatus::Pending] {
assert_eq!(
judge(
&holding(&[("build", NodeStatus::Done), ("left", alone)]),
&paths
),
unfinished(&[("left", alone.as_str())])
);
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_firing_names_its_driver_only_by_the_stream_this_crate_stamps() {
use crate::projection::DriverClaim;
let host = sys::hostname();
let stamped = DriverClaim::of_stream(&format!("{host}-{}", sys::pid()))
.expect("the stream a journal here writes names its driver");
assert!(stamped.is(Some(&host), std::num::NonZeroU32::new(sys::pid())));
assert!(!stamped.is(None, std::num::NonZeroU32::new(sys::pid())));
assert!(!stamped.is(Some(&host), None));
let hyphenated = DriverClaim::of_stream("build-host-01-4242").expect("a claim");
assert!(hyphenated.is(Some("build-host-01"), std::num::NonZeroU32::new(4242)));
assert!(!hyphenated.is(Some("build-host"), std::num::NonZeroU32::new(4242)));
for malformed in ["", "4242", "-4242", "host-", "host-0", "host-pid", "host"] {
assert_eq!(
DriverClaim::of_stream(malformed),
None,
"`{malformed}` was read as naming a driver"
);
}
let written = serde_json::to_value(&hyphenated).expect("a claim serialises");
assert_eq!(
serde_json::from_value::<DriverClaim>(written).expect("it reads back"),
hyphenated
);
for refused in [
json!({"host": "", "pid": 4242}),
json!({"host": "h", "pid": 0}),
json!({"host": "h", "pid": 1, "stream": "h-1"}),
] {
assert!(
serde_json::from_value::<DriverClaim>(refused.clone()).is_err(),
"{refused} was read as a driver claim"
);
}
}
#[test]
fn a_timeout_past_what_the_clock_can_count_to_is_no_deadline_rather_than_a_panic() {
assert_eq!(deadline_after(NonZeroU64::MAX), None);
assert!(deadline_after(DEFAULT_HOOK_TIMEOUT_SECONDS)
.is_some_and(|deadline| deadline > Instant::now()));
}
#[test]
fn the_tail_of_a_long_log_is_its_last_whole_lines() {
let root = scratch("tail");
let log = root.join("failure.log");
let written: String = (1..=10_000).map(|n| format!("said line {n}\n")).collect();
assert!(written.len() as u64 > MAX_TAIL_BYTES);
std::fs::write(&log, &written).expect("the log is written");
let tail = tail(&log).expect("a plain log reads");
assert_eq!(tail.len(), RESULTS_OUTPUT_LINES);
assert_eq!(tail.first().map(String::as_str), Some("said line 9981"));
assert_eq!(tail.last().map(String::as_str), Some("said line 10000"));
assert!(super::tail(&root.join("absent.log"))
.is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_log_that_is_not_a_plain_file_is_refused_rather_than_read() {
let root = scratch("not-plain");
let refused = |path: &Path| {
let error = tail(path).expect_err("a log that is not a plain file was read");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{error}");
assert!(error.to_string().contains("not a plain file"), "{error}");
assert_eq!(relay_from(path, 0), 0, "{} was relayed", path.display());
};
let directory = root.join("directory.log");
std::fs::create_dir_all(&directory).expect("a directory in the log's place");
refused(&directory);
#[cfg(unix)]
{
let elsewhere = root.join("elsewhere.txt");
std::fs::write(&elsewhere, "not the log\n").expect("the linked file");
let link = root.join("link.log");
std::os::unix::fs::symlink(&elsewhere, &link).expect("a link in the log's place");
refused(&link);
let fifo = root.join("fifo.log");
let name = std::ffi::CString::new(fifo.as_os_str().as_encoded_bytes())
.expect("a path with no NUL");
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0, "mkfifo");
refused(&fifo);
}
let _ = std::fs::remove_dir_all(&root);
}
}