mod digest;
mod lines;
mod local;
mod runtime;
mod script;
mod ssh;
mod tar;
use std::future::Future;
use std::path::{Path, PathBuf};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
pub use digest::{
FileDigest, glob_match, local_manifest, manifest_script, parse_manifest, sha256_file,
};
pub use lines::{LineStream, MAX_TAIL_READ, complete_lines};
pub use local::LocalExecutor;
pub use runtime::{JobRuntime, JobSpec, Stages, TOOLS_ENV, shell_path, stage_command};
pub use script::{
GROUP_SIGNAL, cancel_script, claim_script, job_script, parse_status, quote, status_script,
};
pub use ssh::{PodEndpoint, SshDestination, SshError, SshExecutor};
use crate::config::Engine;
pub const JOB_LOG: &str = "job.log";
pub const PID_FILE: &str = "job.pid";
pub const EXIT_FILE: &str = "exit_code";
pub const CANCELLING_FILE: &str = "cancelling";
pub const CANCEL_FILE: &str = "cancelled";
pub const CLAIM_FILE: &str = ".claim";
#[derive(Debug, thiserror::Error)]
pub enum ExecError {
#[error("cannot access {}", path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("cannot run `{program}`")]
Spawn {
program: String,
#[source]
source: std::io::Error,
},
#[error("{action} failed: {message}")]
Command {
action: &'static str,
message: String,
},
#[error("ssh failed")]
Ssh(#[source] SshError),
#[error("the ssh master connection ended right after it started (ssh log: {})", or_empty(.log))]
MasterDied {
log: String,
},
#[error("{0} cannot be passed to the job")]
InvalidSecret(String),
#[error("unexpected answer from the target: {0}")]
Protocol(String),
}
fn or_empty(text: &str) -> &str {
if text.is_empty() { "empty" } else { text }
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Container {
pub engine: Engine,
pub name: String,
}
fn check_job_env(secrets: &[(String, SecretString)]) -> Result<(), ExecError> {
for (name, value) in secrets {
let valid_name = name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& name.chars().next().is_some_and(|c| !c.is_ascii_digit());
if !valid_name || value.expose_secret().contains(['\n', '\r', '\0']) {
return Err(ExecError::InvalidSecret(name.clone()));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct Pid(u32);
impl Pid {
pub fn new(pid: u32) -> Result<Self, ExecError> {
if pid <= 1 {
return Err(ExecError::Protocol(format!("invalid process id {pid}")));
}
Ok(Self(pid))
}
#[must_use]
pub fn get(self) -> u32 {
self.0
}
}
impl<'de> Deserialize<'de> for Pid {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = u32::deserialize(deserializer)?;
Self::new(raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone)]
pub struct JobCommand {
pub dir: String,
pub script: String,
pub secrets: Vec<(String, SecretString)>,
pub container: Option<Container>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobId {
pub dir: String,
pub pid: Pid,
pub container: Option<Container>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
Running,
Exited(i32),
Cancelled,
Lost,
}
impl JobStatus {
#[must_use]
pub fn is_finished(self) -> bool {
self != Self::Running
}
}
pub trait Executor: Send + Sync {
fn workdir(&self) -> &str;
fn claim(&self, dir: &str, owner: &str)
-> impl Future<Output = Result<bool, ExecError>> + Send;
fn upload(
&self,
local: &Path,
remote: &str,
skip: &[String],
) -> impl Future<Output = Result<(), ExecError>> + Send;
fn spawn(&self, job: &JobCommand) -> impl Future<Output = Result<JobId, ExecError>> + Send;
fn read_from(
&self,
path: &str,
offset: u64,
limit: u64,
) -> impl Future<Output = Result<Vec<u8>, ExecError>> + Send;
fn status(&self, job: &JobId) -> impl Future<Output = Result<JobStatus, ExecError>> + Send;
fn cancel(&self, job: &JobId) -> impl Future<Output = Result<(), ExecError>> + Send;
fn download(
&self,
remote: &str,
local: &Path,
entries: &[String],
exclude: &[String],
) -> impl Future<Output = Result<(), ExecError>> + Send;
fn manifest(
&self,
remote: &str,
entries: &[String],
exclude: &[String],
) -> impl Future<Output = Result<Vec<FileDigest>, ExecError>> + Send;
fn probe(&self, script: &str) -> impl Future<Output = Result<Vec<u8>, ExecError>> + Send;
fn put_file(
&self,
path: &str,
content: &str,
) -> impl Future<Output = Result<(), ExecError>> + Send;
fn tail(&self, path: &str, offset: u64) -> LineStream<'_, Self>
where
Self: Sized,
{
LineStream::new(self, path, offset)
}
}
#[derive(Debug)]
pub enum AnyExecutor {
Local(LocalExecutor),
Ssh(SshExecutor),
}
impl Executor for AnyExecutor {
fn workdir(&self) -> &str {
match self {
Self::Local(executor) => executor.workdir(),
Self::Ssh(executor) => executor.workdir(),
}
}
async fn claim(&self, dir: &str, owner: &str) -> Result<bool, ExecError> {
match self {
Self::Local(executor) => executor.claim(dir, owner).await,
Self::Ssh(executor) => executor.claim(dir, owner).await,
}
}
async fn upload(&self, local: &Path, remote: &str, skip: &[String]) -> Result<(), ExecError> {
match self {
Self::Local(executor) => executor.upload(local, remote, skip).await,
Self::Ssh(executor) => executor.upload(local, remote, skip).await,
}
}
async fn spawn(&self, job: &JobCommand) -> Result<JobId, ExecError> {
match self {
Self::Local(executor) => executor.spawn(job).await,
Self::Ssh(executor) => executor.spawn(job).await,
}
}
async fn read_from(&self, path: &str, offset: u64, limit: u64) -> Result<Vec<u8>, ExecError> {
match self {
Self::Local(executor) => executor.read_from(path, offset, limit).await,
Self::Ssh(executor) => executor.read_from(path, offset, limit).await,
}
}
async fn status(&self, job: &JobId) -> Result<JobStatus, ExecError> {
match self {
Self::Local(executor) => executor.status(job).await,
Self::Ssh(executor) => executor.status(job).await,
}
}
async fn cancel(&self, job: &JobId) -> Result<(), ExecError> {
match self {
Self::Local(executor) => executor.cancel(job).await,
Self::Ssh(executor) => executor.cancel(job).await,
}
}
async fn download(
&self,
remote: &str,
local: &Path,
entries: &[String],
exclude: &[String],
) -> Result<(), ExecError> {
match self {
Self::Local(executor) => executor.download(remote, local, entries, exclude).await,
Self::Ssh(executor) => executor.download(remote, local, entries, exclude).await,
}
}
async fn manifest(
&self,
remote: &str,
entries: &[String],
exclude: &[String],
) -> Result<Vec<FileDigest>, ExecError> {
match self {
Self::Local(executor) => executor.manifest(remote, entries, exclude).await,
Self::Ssh(executor) => executor.manifest(remote, entries, exclude).await,
}
}
async fn probe(&self, script: &str) -> Result<Vec<u8>, ExecError> {
match self {
Self::Local(executor) => executor.probe(script).await,
Self::Ssh(executor) => executor.probe(script).await,
}
}
async fn put_file(&self, path: &str, content: &str) -> Result<(), ExecError> {
match self {
Self::Local(executor) => executor.put_file(path, content).await,
Self::Ssh(executor) => executor.put_file(path, content).await,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn any_executor_delegates_with_the_limit() -> Result<(), Box<dyn std::error::Error>> {
let root = tempfile::tempdir()?;
let executor = AnyExecutor::Local(LocalExecutor::new(root.path())?);
let file = Path::new(executor.workdir()).join("data.txt");
std::fs::write(&file, "0123456789")?;
let path = file.to_string_lossy().into_owned();
assert_eq!(executor.read_from(&path, 2, 3).await?, b"234");
let put = Path::new(executor.workdir()).join("r1/.pod/snapshot.request");
let put = put.to_string_lossy().into_owned();
executor.put_file(&put, "deadline").await?;
executor.put_file(&put, "cost").await?;
assert_eq!(std::fs::read_to_string(&put)?, "cost");
Ok(())
}
#[tokio::test]
async fn a_local_probe_samples_this_machine() -> Result<(), Box<dyn std::error::Error>> {
let root = tempfile::tempdir()?;
let executor = AnyExecutor::Local(LocalExecutor::new(root.path())?);
let script = crate::system::probe_script(executor.workdir());
let output = executor.probe(&script).await?;
let sample = crate::system::parse(
&String::from_utf8_lossy(&output),
std::time::SystemTime::now(),
None,
);
let disk = sample.run_disk().ok_or("no disk")?;
assert!(disk.size_bytes > 0, "{sample:?}");
if cfg!(target_os = "linux") {
assert!(sample.memory.is_some(), "{sample:?}");
assert!(
sample.cpu.as_ref().is_some_and(|cpu| cpu.cpus.is_some()),
"{sample:?}"
);
}
let failed = executor.probe("exit 3").await;
assert!(
matches!(
failed,
Err(ExecError::Command {
action: "probe",
..
})
),
"{failed:?}"
);
Ok(())
}
#[test]
fn pid_rejects_zero_and_one() {
assert!(Pid::new(0).is_err());
assert!(Pid::new(1).is_err());
assert!(Pid::new(2).is_ok_and(|pid| pid.get() == 2));
}
#[test]
fn deserializing_a_job_id_rejects_an_invalid_pid() -> Result<(), Box<dyn std::error::Error>> {
let valid: JobId = serde_json::from_str(r#"{"dir":"/w/r1","pid":42,"container":null}"#)?;
assert_eq!(valid.pid.get(), 42);
let invalid = serde_json::from_str::<JobId>(r#"{"dir":"/w/r1","pid":1,"container":null}"#);
assert!(invalid.is_err());
Ok(())
}
}