use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::workspace::WorkspaceHead;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComputeKind {
Host,
Machine,
Vfs,
Container,
Managed,
}
impl ComputeKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Host => "host",
Self::Machine => "machine",
Self::Vfs => "vfs",
Self::Container => "container",
Self::Managed => "managed",
}
}
}
impl fmt::Display for ComputeKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComputeCapabilities {
pub native_processes: bool,
pub packages: bool,
pub pty: bool,
pub ports: bool,
pub portable_checkpoint: bool,
pub network_enforced: bool,
}
impl ComputeCapabilities {
pub const fn full_machine() -> Self {
Self {
native_processes: true,
packages: true,
pty: true,
ports: true,
portable_checkpoint: false,
network_enforced: false,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Durability {
Checkpointed,
ProviderSnapshot,
None,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContainmentLevel {
None,
Native,
Isolated,
}
impl ContainmentLevel {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Native => "native",
Self::Isolated => "isolated",
}
}
}
impl fmt::Display for ContainmentLevel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "mode", content = "allowed_hosts")]
pub enum NetworkPolicy {
Deny,
Allowlist(Vec<String>),
Allow,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Containment {
pub level: ContainmentLevel,
pub network: NetworkPolicy,
#[serde(default)]
pub writable_roots: Vec<String>,
}
impl Containment {
pub fn none() -> Self {
Self {
level: ContainmentLevel::None,
network: NetworkPolicy::Allow,
writable_roots: Vec::new(),
}
}
pub fn native() -> Self {
Self {
level: ContainmentLevel::Native,
network: NetworkPolicy::Deny,
writable_roots: Vec::new(),
}
}
pub fn isolated() -> Self {
Self {
level: ContainmentLevel::Isolated,
network: NetworkPolicy::Deny,
writable_roots: Vec::new(),
}
}
pub fn network(mut self, policy: NetworkPolicy) -> Self {
self.network = policy;
self
}
pub fn writable_root(mut self, root: impl Into<String>) -> Self {
self.writable_roots.push(root.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecRequest {
pub command: String,
pub cwd: Option<String>,
pub timeout_secs: Option<u64>,
}
impl ExecRequest {
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
cwd: None,
timeout_secs: None,
}
}
pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn timeout_secs(mut self, seconds: u64) -> Self {
self.timeout_secs = Some(seconds);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExecResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
impl ExecResult {
pub fn success(&self) -> bool {
self.exit_code == 0
}
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum ComputeError {
#[error("compute target is unavailable: {0}")]
Unavailable(String),
#[error("command could not be started: {0}")]
Launch(String),
#[error("command exceeded its timeout")]
Timeout,
#[error("operation is not supported by this compute target: {0}")]
Unsupported(&'static str),
}
#[async_trait]
pub trait Compute: Send + Sync {
fn id(&self) -> &str;
fn kind(&self) -> ComputeKind;
fn capabilities(&self) -> ComputeCapabilities;
fn enforced_containment(&self) -> ContainmentLevel;
fn durability(&self) -> Durability;
async fn connect(&self, head: &WorkspaceHead) -> Result<Arc<dyn ComputeSession>, ComputeError>;
}
#[async_trait]
pub trait ComputeSession: Send + Sync {
async fn exec(&self, request: ExecRequest) -> Result<ExecResult, ComputeError>;
async fn cancel(&self, _execution_id: &str) -> Result<(), ComputeError> {
Err(ComputeError::Unsupported("cancel"))
}
}
#[cfg(feature = "process")]
pub use host_compute::{HostCompute, HostComputeSession};
#[cfg(feature = "process")]
mod host_compute {
use super::*;
use std::path::PathBuf;
pub struct HostCompute {
root: PathBuf,
default_timeout_secs: u64,
}
impl HostCompute {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
default_timeout_secs: 120,
}
}
pub fn default_timeout_secs(mut self, seconds: u64) -> Self {
self.default_timeout_secs = seconds;
self
}
}
#[async_trait]
impl Compute for HostCompute {
fn id(&self) -> &str {
"host"
}
fn kind(&self) -> ComputeKind {
ComputeKind::Host
}
fn capabilities(&self) -> ComputeCapabilities {
ComputeCapabilities::full_machine()
}
fn enforced_containment(&self) -> ContainmentLevel {
ContainmentLevel::None
}
fn durability(&self) -> Durability {
Durability::None
}
async fn connect(
&self,
_head: &WorkspaceHead,
) -> Result<Arc<dyn ComputeSession>, ComputeError> {
if !self.root.is_dir() {
return Err(ComputeError::Unavailable(format!(
"{} is not a directory",
self.root.display()
)));
}
Ok(Arc::new(HostComputeSession {
root: self.root.clone(),
default_timeout_secs: self.default_timeout_secs,
}))
}
}
pub struct HostComputeSession {
pub(super) root: PathBuf,
pub(super) default_timeout_secs: u64,
}
#[async_trait]
impl ComputeSession for HostComputeSession {
async fn exec(&self, request: ExecRequest) -> Result<ExecResult, ComputeError> {
let cwd = match &request.cwd {
Some(relative) => self.root.join(relative),
None => self.root.clone(),
};
let mut command = tokio::process::Command::new("bash");
command
.arg("-lc")
.arg(&request.command)
.current_dir(&cwd)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let child = command
.spawn()
.map_err(|error| ComputeError::Launch(error.to_string()))?;
let seconds = request.timeout_secs.unwrap_or(self.default_timeout_secs);
let output = tokio::time::timeout(
std::time::Duration::from_secs(seconds),
child.wait_with_output(),
)
.await
.map_err(|_| ComputeError::Timeout)?
.map_err(|error| ComputeError::Launch(error.to_string()))?;
Ok(ExecResult {
exit_code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
}
#[cfg(all(test, feature = "process"))]
mod host_compute_tests {
use super::*;
use std::path::PathBuf;
fn session(root: PathBuf) -> HostComputeSession {
HostComputeSession {
root,
default_timeout_secs: 30,
}
}
#[tokio::test]
async fn a_command_runs_in_the_configured_root() {
let directory = tempfile::tempdir().expect("temp dir");
let session = session(directory.path().to_path_buf());
let result = session
.exec(ExecRequest::new("pwd && echo marker > witness.txt"))
.await
.expect("command runs");
assert!(result.success(), "stderr: {}", result.stderr);
assert!(
directory.path().join("witness.txt").exists(),
"the command wrote into the root it was given"
);
}
#[tokio::test]
async fn a_failing_command_reports_its_status_rather_than_an_error() {
let directory = tempfile::tempdir().expect("temp dir");
let result = session(directory.path().to_path_buf())
.exec(ExecRequest::new("exit 3"))
.await
.expect("a non-zero exit is a result, not a transport failure");
assert_eq!(result.exit_code, 3);
assert!(!result.success());
}
#[tokio::test]
async fn a_command_that_outlives_its_timeout_is_a_timeout() {
let directory = tempfile::tempdir().expect("temp dir");
let error = session(directory.path().to_path_buf())
.exec(ExecRequest::new("sleep 5").timeout_secs(1))
.await
.expect_err("the wait is bounded");
assert_eq!(error, ComputeError::Timeout);
}
#[tokio::test]
async fn connecting_to_a_missing_root_fails_before_any_command_runs() {
let compute = HostCompute::new("/nonexistent/everruns/host/compute/root");
assert_eq!(compute.kind(), ComputeKind::Host);
assert_eq!(compute.enforced_containment(), ContainmentLevel::None);
assert_eq!(compute.durability(), Durability::None);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn containment_levels_order_from_open_to_closed() {
assert!(ContainmentLevel::None < ContainmentLevel::Native);
assert!(ContainmentLevel::Native < ContainmentLevel::Isolated);
}
#[test]
fn network_policy_round_trips_with_its_mode_tag() {
let allowlist = NetworkPolicy::Allowlist(vec!["crates.io".to_string()]);
let json = serde_json::to_string(&allowlist).expect("serializable");
assert_eq!(
json,
r#"{"mode":"allowlist","allowed_hosts":["crates.io"]}"#
);
assert_eq!(
serde_json::from_str::<NetworkPolicy>(&json).expect("deserializable"),
allowlist
);
assert_eq!(
serde_json::to_string(&NetworkPolicy::Deny).expect("serializable"),
r#"{"mode":"deny"}"#
);
}
#[test]
fn a_full_machine_withholds_the_two_things_everruns_does_not_own() {
let capabilities = ComputeCapabilities::full_machine();
assert!(capabilities.native_processes);
assert!(!capabilities.portable_checkpoint);
assert!(!capabilities.network_enforced);
}
}