use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::cli::PlanCheckArgs;
use crate::error::{Result, EXIT_QUEUED, EXIT_REFUSED, EXIT_SUCCESS};
use crate::refusal::Refusal;
use crate::taskgraph::{Load, QualifiedId, Store};
pub const SCHEMA_ENV: &str = "ONEPIPELINE_PLAN_CHECK_SCHEMA";
pub const SCHEMA_VERSION: &str = "1";
const ACCEPTED: i32 = EXIT_SUCCESS;
const REFUSED: i32 = EXIT_QUEUED;
const NOT_ANSWERED: i32 = EXIT_REFUSED;
pub const ENGINE: &str = "engine";
#[derive(Debug, Clone, PartialEq, Eq)]
enum Source {
Engine,
Check(String),
}
impl Serialize for Source {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
match self {
Self::Engine => serializer.serialize_str(ENGINE),
Self::Check(path) => serializer.serialize_str(path),
}
}
}
impl std::fmt::Display for Source {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Engine => formatter.write_str(ENGINE),
Self::Check(path) => formatter.write_str(path),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
struct Reported {
source: Source,
node: Option<String>,
field: Option<String>,
reason: Reason,
}
#[derive(Debug, Clone, PartialEq)]
struct Unrunnable {
check: String,
exit_code: Option<i32>,
stderr: String,
}
#[derive(Debug, Serialize)]
struct NotRun<'a> {
check: &'a str,
exit_code: Option<i32>,
stderr: &'a str,
}
const STOPPED_BY_THE_LOADER: &str = "the plan loader refused the project, so there was no loaded \
plan to hand this check; it did not run";
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Answer {
refusals: Vec<AnswerRefusal>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct AnswerRefusal {
node: Option<String>,
field: Option<String>,
reason: Reason,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
struct Reason(String);
impl<'de> Deserialize<'de> for Reason {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
let said = String::deserialize(deserializer)?;
if said.trim().is_empty() {
return Err(serde::de::Error::custom(
"a refusal's reason is the whole of what it says, and this one is blank",
));
}
Ok(Self(said))
}
}
pub(crate) fn check(args: &PlanCheckArgs) -> Result<i32> {
match load_and_check(args) {
Ok(answered) => Ok(report(args, &answered)),
Err(error) => {
eprintln!("onepipeline: {error}");
Ok(report(args, &Answered::Unreadable))
}
}
}
fn load_and_check(args: &PlanCheckArgs) -> Result<Answered> {
let store = Store::resolve()?;
let project: QualifiedId = args.project.parse()?;
let refusal = match store.read_plan(&project) {
Err(Load::Unreadable(error)) => return Err(error),
Err(Load::Refused(refusal)) => refusal,
Ok(read) => match crate::graph::check(&read.plan) {
Err(refusal) => refusal,
Ok(()) => {
let mut refusals = Vec::new();
let mut unrunnable = Vec::new();
match document(&read) {
Ok(document) => {
for path in &args.checks {
match offer(path, &document) {
Ok(answered) => refusals.extend(answered),
Err(why) => unrunnable.push(why),
}
}
}
Err(why) => unrunnable.extend(args.checks.iter().map(|path| Unrunnable {
check: path.display().to_string(),
exit_code: None,
stderr: why.clone(),
})),
}
return Ok(Answered::Checked {
refusals,
unrunnable,
});
}
},
};
Ok(Answered::LoaderRefused {
refusal: engine_refusal(refusal),
stopped: args
.checks
.iter()
.map(|path| path.display().to_string())
.collect(),
})
}
enum Answered {
Unreadable,
LoaderRefused {
refusal: Reported,
stopped: Vec<String>,
},
Checked {
refusals: Vec<Reported>,
unrunnable: Vec<Unrunnable>,
},
}
impl Answered {
fn accepted(&self) -> bool {
matches!(
self,
Self::Checked {
refusals,
unrunnable,
} if refusals.is_empty() && unrunnable.is_empty()
)
}
fn refusals(&self) -> &[Reported] {
match self {
Self::Unreadable => &[],
Self::LoaderRefused { refusal, .. } => std::slice::from_ref(refusal),
Self::Checked { refusals, .. } => refusals,
}
}
fn not_run(&self) -> Vec<NotRun<'_>> {
match self {
Self::Unreadable => Vec::new(),
Self::LoaderRefused { stopped, .. } => stopped
.iter()
.map(|check| NotRun {
check,
exit_code: None,
stderr: STOPPED_BY_THE_LOADER,
})
.collect(),
Self::Checked { unrunnable, .. } => unrunnable
.iter()
.map(|report| NotRun {
check: &report.check,
exit_code: report.exit_code,
stderr: &report.stderr,
})
.collect(),
}
}
fn exit_code(&self) -> i32 {
match self {
Self::Unreadable => NOT_ANSWERED,
Self::LoaderRefused { .. } => REFUSED,
Self::Checked {
refusals,
unrunnable,
} => {
if !unrunnable.is_empty() {
NOT_ANSWERED
} else if refusals.is_empty() {
ACCEPTED
} else {
REFUSED
}
}
}
}
}
fn report(args: &PlanCheckArgs, answered: &Answered) -> i32 {
print(args, answered);
answered.exit_code()
}
fn print(args: &PlanCheckArgs, answered: &Answered) {
let accepted = answered.accepted();
let refusals = answered.refusals();
let unrunnable = answered.not_run();
if args.json {
let answer = json!({
"project": args.project,
"accepted": accepted,
"refusals": refusals,
"unrunnable": unrunnable,
});
println!("{answer}");
return;
}
for refusal in refusals {
println!(
"{}: {}{}{}",
refusal.source,
refusal
.node
.as_ref()
.map(|node| format!("node '{node}': "))
.unwrap_or_default(),
refusal
.field
.as_ref()
.map(|field| format!("`{field}`: "))
.unwrap_or_default(),
refusal.reason.0
);
}
for report in &unrunnable {
eprintln!(
"{}: could not be run ({}): {}",
report.check,
report.exit_code.map_or_else(
|| "no exit status".to_owned(),
|code| format!("exit {code}")
),
report.stderr
);
}
if accepted {
println!("{}: accepted", args.project);
}
}
fn engine_refusal(refusal: Refusal) -> Reported {
Reported {
source: Source::Engine,
node: refusal.node,
field: refusal.field,
reason: Reason(refusal.message),
}
}
fn document(read: &crate::taskgraph::Read) -> std::result::Result<Vec<u8>, String> {
let mut tasks = Vec::with_capacity(read.plan.tasks.len());
for node in &read.plan.tasks {
let mut written = serde_json::to_value(node).map_err(|error| {
format!(
"the loaded node {} could not be written as JSON, so there is no document to \
hand a check: {error}",
node.id
)
})?;
if let Some(map) = written.as_object_mut() {
map.insert(
"metadata".to_owned(),
json!(read.metadata.get(&node.id).cloned().unwrap_or_default()),
);
}
tasks.push(written);
}
serde_json::to_vec(&json!({
"schema_version": read.plan.schema_version,
"name": read.plan.name,
"goal": read.plan.goal,
"concurrency": read.plan.concurrency,
"tasks": tasks,
}))
.map_err(|error| {
format!("the loaded plan could not be written as JSON, so there is no document to hand a check: {error}")
})
}
fn offer(path: &Path, document: &[u8]) -> std::result::Result<Vec<Reported>, Unrunnable> {
let named = path.display().to_string();
let cannot = |exit_code: Option<i32>, stderr: String| Unrunnable {
check: named.clone(),
exit_code,
stderr,
};
let resolved = resolve(path);
let mut child = Command::new(&resolved)
.env(SCHEMA_ENV, SCHEMA_VERSION)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| {
cannot(
None,
format!("{} cannot be run: {error}", resolved.display()),
)
})?;
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(document);
}
drop(child.stdin.take());
let mut out = child.stdout.take();
let mut err = child.stderr.take();
let reading = std::thread::Builder::new()
.spawn(move || err.as_mut().map(bounded).unwrap_or_default())
.map_err(|error| {
let _ = child.kill();
let _ = child.wait();
cannot(
None,
format!(
"{named} ran, and this process could not start the thread that reads its \
stderr, so nothing here can read what it answered: {error}"
),
)
})?;
let stdout = out.as_mut().map(bounded).unwrap_or_default();
drop(out);
let stderr_bytes = match reading.join() {
Ok(read) => read,
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return Err(cannot(
None,
format!(
"{named} ran, and the thread reading its stderr panicked, so nothing here \
can read what it answered"
),
));
}
};
let status = child
.wait()
.map_err(|error| cannot(None, format!("{named} could not be waited for: {error}")))?;
let stderr = match String::from_utf8_lossy(&stderr_bytes.said).trim() {
said if stderr_bytes.past_the_bound => {
format!("{said} […truncated at the {MAX_ANSWER_BYTES} bytes this build reads]")
}
said => said.to_owned(),
};
if !status.success() {
return Err(cannot(status.code(), stderr));
}
if stdout.past_the_bound {
return Err(cannot(
status.code(),
format!("answered with more than the {MAX_ANSWER_BYTES} bytes this build reads"),
));
}
let answered: Value = serde_json::from_slice(&stdout.said).map_err(|error| {
cannot(
status.code(),
format!(
"answered with something this build cannot read: {error}; it said {:?}{}",
String::from_utf8_lossy(&stdout.said).trim(),
if stderr.is_empty() {
String::new()
} else {
format!(" (stderr: {stderr})")
}
),
)
})?;
if let Some(key) = absent_key(&answered) {
return Err(cannot(
status.code(),
format!("answered with no `{key}`, which a check's answer always carries"),
));
}
let answer: Answer = serde_json::from_value(answered).map_err(|error| {
cannot(
status.code(),
format!(
"answered with something this build cannot read: {error}; it said {:?}{}",
String::from_utf8_lossy(&stdout.said).trim(),
if stderr.is_empty() {
String::new()
} else {
format!(" (stderr: {stderr})")
}
),
)
})?;
Ok(answer
.refusals
.into_iter()
.map(|refusal| Reported {
source: Source::Check(named.clone()),
node: refusal.node,
field: refusal.field,
reason: refusal.reason,
})
.collect())
}
fn absent_key(answered: &Value) -> Option<String> {
let object = answered.as_object()?;
if !object.contains_key("refusals") {
return Some("refusals".to_owned());
}
let refusals = object.get("refusals")?.as_array()?;
for refusal in refusals {
let stated = refusal.as_object()?;
for key in ["node", "field", "reason"] {
if !stated.contains_key(key) {
return Some(format!("refusals[].{key}"));
}
}
}
None
}
const MAX_ANSWER_BYTES: u64 = 1 << 20;
#[derive(Default)]
struct Bounded {
said: Vec<u8>,
past_the_bound: bool,
}
const DRAIN_BYTES: u64 = 8 * MAX_ANSWER_BYTES;
fn bounded(stream: &mut impl std::io::Read) -> Bounded {
let mut said = Vec::new();
let read = stream.take(MAX_ANSWER_BYTES + 1).read_to_end(&mut said);
let past_the_bound = read.is_ok() && said.len() as u64 > MAX_ANSWER_BYTES;
said.truncate(usize::try_from(MAX_ANSWER_BYTES).unwrap_or(usize::MAX));
if past_the_bound {
let _ = std::io::copy(&mut stream.take(DRAIN_BYTES), &mut std::io::sink());
}
Bounded {
said,
past_the_bound,
}
}
fn resolve(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
Path::new(".").join(path)
}