Source commit: 6756e5208c8b79e83d55610251430bc1baef53a3 Public tree identity: sha256:2f58837c3759b4f466cbc274571ee71bc0d24c7552dc009c186394e99123da87
1058 lines
34 KiB
Rust
1058 lines
34 KiB
Rust
use serde::Serialize;
|
|
|
|
pub use clusterflux_core as core;
|
|
pub use clusterflux_macros::{main, task, TaskArg};
|
|
|
|
mod sdk_runtime;
|
|
mod task_args;
|
|
pub use task_args::{
|
|
reject_host_only_task_arg, validate_task_arg, Artifact, Blob, EnvRef, SourceSnapshot, TaskArg,
|
|
TaskArgBudget, TaskArgError, TaskArgKind, TaskArgValidation, Vfs,
|
|
};
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
pub struct EntrypointDescriptor {
|
|
pub name: &'static str,
|
|
pub function: &'static str,
|
|
pub export: &'static str,
|
|
pub stable_id: &'static str,
|
|
pub argument_schema: &'static str,
|
|
pub result_schema: &'static str,
|
|
pub abi_version: u32,
|
|
pub bundle_manifest_entry: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
|
pub struct TaskDescriptor {
|
|
pub name: &'static str,
|
|
pub function: &'static str,
|
|
pub export: &'static str,
|
|
pub stable_id: &'static str,
|
|
pub argument_schema: &'static str,
|
|
pub result_schema: &'static str,
|
|
pub required_capabilities: &'static [&'static str],
|
|
pub restart_compatibility_hash: &'static str,
|
|
pub abi_version: u32,
|
|
pub source_file: &'static str,
|
|
pub source_line: u32,
|
|
pub probe_symbol: &'static str,
|
|
pub bundle_manifest_entry: bool,
|
|
pub remotely_startable: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct RegisteredProgram {
|
|
pub entrypoints: &'static [EntrypointDescriptor],
|
|
pub tasks: &'static [TaskDescriptor],
|
|
}
|
|
|
|
impl RegisteredProgram {
|
|
pub fn select_entrypoint(&self, name: &str) -> Option<EntrypointDescriptor> {
|
|
self.entrypoints
|
|
.iter()
|
|
.copied()
|
|
.find(|entrypoint| entrypoint.name == name)
|
|
}
|
|
|
|
pub fn task(&self, name: &str) -> Option<TaskDescriptor> {
|
|
self.tasks.iter().copied().find(|task| task.name == name)
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub mod __private;
|
|
|
|
pub mod spawn {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
pub use clusterflux_core::TaskFailurePolicy;
|
|
use serde::de::DeserializeOwned;
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
use crate::sdk_runtime::start_guest_host_task;
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
use crate::sdk_runtime::start_remote_task;
|
|
use crate::sdk_runtime::{join_remote_task, RemoteTaskHandle};
|
|
pub use crate::sdk_runtime::{ProductRuntimeConfig, TaskRuntimeError};
|
|
use crate::{validate_task_arg, EnvRef, TaskArg, TaskArgBudget};
|
|
|
|
static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1);
|
|
static RUNTIME_THREADS: OnceLock<Mutex<Vec<RuntimeSpawnEvent>>> = OnceLock::new();
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RuntimeSpawnEvent {
|
|
pub virtual_thread_id: u64,
|
|
pub name: &'static str,
|
|
pub env: Option<EnvRef>,
|
|
pub debugger_visible: bool,
|
|
}
|
|
|
|
fn runtime_threads() -> &'static Mutex<Vec<RuntimeSpawnEvent>> {
|
|
RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new()))
|
|
}
|
|
|
|
fn register_runtime_thread(
|
|
virtual_thread_id: u64,
|
|
name: &'static str,
|
|
env: Option<EnvRef>,
|
|
) -> RuntimeSpawnEvent {
|
|
let event = RuntimeSpawnEvent {
|
|
virtual_thread_id,
|
|
name,
|
|
env,
|
|
debugger_visible: true,
|
|
};
|
|
runtime_threads().lock().unwrap().push(event.clone());
|
|
event
|
|
}
|
|
|
|
pub fn drain_runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
|
|
runtime_threads().lock().unwrap().drain(..).collect()
|
|
}
|
|
|
|
pub fn runtime_spawn_events() -> Vec<RuntimeSpawnEvent> {
|
|
runtime_threads().lock().unwrap().clone()
|
|
}
|
|
|
|
pub fn task<F, R>(entry: F) -> TaskBuilder<F, R>
|
|
where
|
|
F: FnOnce() -> R,
|
|
R: TaskArg,
|
|
{
|
|
TaskBuilder {
|
|
entry: Some(entry),
|
|
env: None,
|
|
name: "task",
|
|
task_id: None,
|
|
failure_policy: TaskFailurePolicy::FailFast,
|
|
}
|
|
}
|
|
|
|
pub fn task_with_arg<A, F, R>(arg: A, entry: F) -> TaskWithArgBuilder<A, F, R>
|
|
where
|
|
A: TaskArg,
|
|
F: FnOnce(A) -> R,
|
|
R: TaskArg,
|
|
{
|
|
TaskWithArgBuilder {
|
|
arg: Some(arg),
|
|
entry: Some(entry),
|
|
env: None,
|
|
name: "task",
|
|
task_id: None,
|
|
arg_budget: TaskArgBudget::default(),
|
|
failure_policy: TaskFailurePolicy::FailFast,
|
|
}
|
|
}
|
|
|
|
pub fn async_task<F, Fut, R>(entry: F) -> AsyncTaskBuilder<F, Fut, R>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = R>,
|
|
R: TaskArg,
|
|
{
|
|
AsyncTaskBuilder {
|
|
entry: Some(entry),
|
|
env: None,
|
|
name: "task",
|
|
task_id: None,
|
|
failure_policy: TaskFailurePolicy::FailFast,
|
|
marker: std::marker::PhantomData,
|
|
}
|
|
}
|
|
|
|
pub fn async_task_with_arg<A, F, Fut, R>(
|
|
arg: A,
|
|
entry: F,
|
|
) -> AsyncTaskWithArgBuilder<A, F, Fut, R>
|
|
where
|
|
A: TaskArg,
|
|
F: FnOnce(A) -> Fut,
|
|
Fut: std::future::Future<Output = R>,
|
|
R: TaskArg,
|
|
{
|
|
AsyncTaskWithArgBuilder {
|
|
arg: Some(arg),
|
|
entry: Some(entry),
|
|
env: None,
|
|
name: "task",
|
|
task_id: None,
|
|
arg_budget: TaskArgBudget::default(),
|
|
failure_policy: TaskFailurePolicy::FailFast,
|
|
marker: std::marker::PhantomData,
|
|
}
|
|
}
|
|
|
|
pub struct AsyncTaskBuilder<F, Fut, R>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = R>,
|
|
R: TaskArg,
|
|
{
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
entry: Option<F>,
|
|
env: Option<EnvRef>,
|
|
name: &'static str,
|
|
task_id: Option<String>,
|
|
failure_policy: TaskFailurePolicy,
|
|
marker: std::marker::PhantomData<fn() -> (Fut, R)>,
|
|
}
|
|
|
|
impl<F, Fut, R> AsyncTaskBuilder<F, Fut, R>
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: std::future::Future<Output = R>,
|
|
R: TaskArg + DeserializeOwned,
|
|
{
|
|
pub fn env(mut self, env: EnvRef) -> Self {
|
|
self.env = Some(env);
|
|
self
|
|
}
|
|
|
|
pub fn name(mut self, name: &'static str) -> Self {
|
|
self.name = name;
|
|
self
|
|
}
|
|
|
|
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
|
|
self.task_id = Some(task_id.into());
|
|
self
|
|
}
|
|
|
|
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
|
|
self.failure_policy = failure_policy;
|
|
self
|
|
}
|
|
|
|
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
|
|
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
|
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let remote = start_guest_host_task(
|
|
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
|
|
self.env,
|
|
Vec::new(),
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
if let Some(config) = ProductRuntimeConfig::from_env()? {
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let remote = start_remote_task(
|
|
config,
|
|
task_id,
|
|
self.env,
|
|
Vec::new(),
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
let entry = self.entry.expect("task entry used once");
|
|
Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: Some(entry().await),
|
|
remote: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct AsyncTaskWithArgBuilder<A, F, Fut, R>
|
|
where
|
|
A: TaskArg,
|
|
F: FnOnce(A) -> Fut,
|
|
Fut: std::future::Future<Output = R>,
|
|
R: TaskArg,
|
|
{
|
|
arg: Option<A>,
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
entry: Option<F>,
|
|
env: Option<EnvRef>,
|
|
name: &'static str,
|
|
task_id: Option<String>,
|
|
arg_budget: TaskArgBudget,
|
|
failure_policy: TaskFailurePolicy,
|
|
marker: std::marker::PhantomData<fn() -> (Fut, R)>,
|
|
}
|
|
|
|
impl<A, F, Fut, R> AsyncTaskWithArgBuilder<A, F, Fut, R>
|
|
where
|
|
A: TaskArg,
|
|
F: FnOnce(A) -> Fut,
|
|
Fut: std::future::Future<Output = R>,
|
|
R: TaskArg + DeserializeOwned,
|
|
{
|
|
pub fn env(mut self, env: EnvRef) -> Self {
|
|
self.env = Some(env);
|
|
self
|
|
}
|
|
|
|
pub fn name(mut self, name: &'static str) -> Self {
|
|
self.name = name;
|
|
self
|
|
}
|
|
|
|
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
|
|
self.task_id = Some(task_id.into());
|
|
self
|
|
}
|
|
|
|
pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self {
|
|
self.arg_budget = arg_budget;
|
|
self
|
|
}
|
|
|
|
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
|
|
self.failure_policy = failure_policy;
|
|
self
|
|
}
|
|
|
|
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
|
|
let arg = self.arg.expect("task argument used once");
|
|
validate_task_arg(&arg, self.arg_budget)
|
|
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
|
|
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
|
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let boundary = arg
|
|
.task_boundary_value()
|
|
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
|
|
let remote = start_guest_host_task(
|
|
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
|
|
self.env,
|
|
vec![boundary],
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
if let Some(config) = ProductRuntimeConfig::from_env()? {
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let boundary = arg
|
|
.task_boundary_value()
|
|
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
|
|
let remote = start_remote_task(
|
|
config,
|
|
task_id,
|
|
self.env,
|
|
vec![boundary],
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
let entry = self.entry.expect("task entry used once");
|
|
Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: Some(entry(arg).await),
|
|
remote: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct TaskBuilder<F, R>
|
|
where
|
|
F: FnOnce() -> R,
|
|
R: TaskArg,
|
|
{
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
entry: Option<F>,
|
|
env: Option<EnvRef>,
|
|
name: &'static str,
|
|
task_id: Option<String>,
|
|
failure_policy: TaskFailurePolicy,
|
|
}
|
|
|
|
impl<F, R> TaskBuilder<F, R>
|
|
where
|
|
F: FnOnce() -> R,
|
|
R: TaskArg + DeserializeOwned,
|
|
{
|
|
pub fn env(mut self, env: EnvRef) -> Self {
|
|
self.env = Some(env);
|
|
self
|
|
}
|
|
|
|
pub fn name(mut self, name: &'static str) -> Self {
|
|
self.name = name;
|
|
self
|
|
}
|
|
|
|
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
|
|
self.task_id = Some(task_id.into());
|
|
self
|
|
}
|
|
|
|
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
|
|
self.failure_policy = failure_policy;
|
|
self
|
|
}
|
|
|
|
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
|
|
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
|
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let remote = start_guest_host_task(
|
|
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
|
|
self.env,
|
|
Vec::new(),
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
if let Some(config) = ProductRuntimeConfig::from_env()? {
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let remote = start_remote_task(
|
|
config,
|
|
task_id,
|
|
self.env,
|
|
Vec::new(),
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
let entry = self.entry.expect("task entry used once");
|
|
Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: Some(entry()),
|
|
remote: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct TaskWithArgBuilder<A, F, R>
|
|
where
|
|
A: TaskArg,
|
|
F: FnOnce(A) -> R,
|
|
R: TaskArg,
|
|
{
|
|
arg: Option<A>,
|
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
|
entry: Option<F>,
|
|
env: Option<EnvRef>,
|
|
name: &'static str,
|
|
task_id: Option<String>,
|
|
arg_budget: TaskArgBudget,
|
|
failure_policy: TaskFailurePolicy,
|
|
}
|
|
|
|
impl<A, F, R> TaskWithArgBuilder<A, F, R>
|
|
where
|
|
A: TaskArg,
|
|
F: FnOnce(A) -> R,
|
|
R: TaskArg + DeserializeOwned,
|
|
{
|
|
pub fn env(mut self, env: EnvRef) -> Self {
|
|
self.env = Some(env);
|
|
self
|
|
}
|
|
|
|
pub fn name(mut self, name: &'static str) -> Self {
|
|
self.name = name;
|
|
self
|
|
}
|
|
|
|
pub fn task_id(mut self, task_id: impl Into<String>) -> Self {
|
|
self.task_id = Some(task_id.into());
|
|
self
|
|
}
|
|
|
|
pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self {
|
|
self.arg_budget = arg_budget;
|
|
self
|
|
}
|
|
|
|
pub fn failure_policy(mut self, failure_policy: TaskFailurePolicy) -> Self {
|
|
self.failure_policy = failure_policy;
|
|
self
|
|
}
|
|
|
|
pub async fn start(self) -> Result<TaskHandle<R>, TaskRuntimeError> {
|
|
let arg = self.arg.expect("task argument used once");
|
|
validate_task_arg(&arg, self.arg_budget)
|
|
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
|
|
let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst);
|
|
let runtime_event = register_runtime_thread(id, self.name, self.env);
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let boundary = arg
|
|
.task_boundary_value()
|
|
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
|
|
let remote = start_guest_host_task(
|
|
clusterflux_core::TaskDefinitionId::from(task_id.as_str()),
|
|
self.env,
|
|
vec![boundary],
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
if let Some(config) = ProductRuntimeConfig::from_env()? {
|
|
let task_id = product_task_id::<F>(self.task_id)?;
|
|
let boundary = arg
|
|
.task_boundary_value()
|
|
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
|
|
let remote = start_remote_task(
|
|
config,
|
|
task_id,
|
|
self.env,
|
|
vec![boundary],
|
|
self.failure_policy,
|
|
)?;
|
|
return Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: None,
|
|
remote: Some(remote),
|
|
});
|
|
}
|
|
let entry = self.entry.expect("task entry used once");
|
|
Ok(TaskHandle {
|
|
virtual_thread_id: id,
|
|
name: self.name,
|
|
env: self.env,
|
|
debugger_visible: runtime_event.debugger_visible,
|
|
result: Some(entry(arg)),
|
|
remote: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct TaskHandle<R> {
|
|
virtual_thread_id: u64,
|
|
name: &'static str,
|
|
env: Option<EnvRef>,
|
|
debugger_visible: bool,
|
|
result: Option<R>,
|
|
remote: Option<RemoteTaskHandle>,
|
|
}
|
|
|
|
impl<R> TaskHandle<R>
|
|
where
|
|
R: TaskArg + DeserializeOwned,
|
|
{
|
|
pub fn virtual_thread_id(&self) -> u64 {
|
|
self.virtual_thread_id
|
|
}
|
|
|
|
pub fn name(&self) -> &'static str {
|
|
self.name
|
|
}
|
|
|
|
pub fn env(&self) -> Option<EnvRef> {
|
|
self.env
|
|
}
|
|
|
|
pub fn debugger_visible(&self) -> bool {
|
|
self.debugger_visible
|
|
}
|
|
|
|
pub fn task_spec(&self) -> Option<&clusterflux_core::TaskSpec> {
|
|
self.remote.as_ref().map(|remote| &remote.spec)
|
|
}
|
|
|
|
pub async fn join(mut self) -> Result<R, TaskRuntimeError> {
|
|
if let Some(remote) = self.remote.take() {
|
|
return join_remote_task(remote);
|
|
}
|
|
self.result.take().ok_or_else(|| {
|
|
TaskRuntimeError::Protocol("task handle was already joined".to_owned())
|
|
})
|
|
}
|
|
}
|
|
|
|
fn product_task_id<F>(explicit: Option<String>) -> Result<String, TaskRuntimeError> {
|
|
if let Some(explicit) = explicit {
|
|
return Ok(explicit);
|
|
}
|
|
let inferred = std::any::type_name::<F>()
|
|
.rsplit("::")
|
|
.next()
|
|
.unwrap_or_default();
|
|
if inferred.is_empty() || inferred.contains("closure") {
|
|
return Err(TaskRuntimeError::Configuration(
|
|
"product-mode spawn from a closure requires .task_id(\"registered-export\")"
|
|
.to_owned(),
|
|
));
|
|
}
|
|
Ok(inferred.to_owned())
|
|
}
|
|
}
|
|
|
|
pub mod command {
|
|
use std::collections::BTreeMap;
|
|
use std::time::Duration;
|
|
|
|
pub use crate::sdk_runtime::TaskRuntimeError;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Output {
|
|
pub status_code: Option<i32>,
|
|
pub stdout: String,
|
|
pub stderr: String,
|
|
pub stdout_truncated: bool,
|
|
pub stderr_truncated: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Command {
|
|
program: String,
|
|
args: Vec<String>,
|
|
working_directory: String,
|
|
environment_variables: BTreeMap<String, String>,
|
|
timeout: Duration,
|
|
network: clusterflux_core::CommandNetworkPolicy,
|
|
}
|
|
|
|
impl Command {
|
|
pub fn new(program: impl Into<String>) -> Self {
|
|
Self {
|
|
program: program.into(),
|
|
args: Vec::new(),
|
|
working_directory: "/workspace".to_owned(),
|
|
environment_variables: BTreeMap::new(),
|
|
timeout: Duration::from_secs(15 * 60),
|
|
network: clusterflux_core::CommandNetworkPolicy::Disabled,
|
|
}
|
|
}
|
|
|
|
pub fn arg(mut self, arg: impl Into<String>) -> Self {
|
|
self.args.push(arg.into());
|
|
self
|
|
}
|
|
|
|
pub fn args<I, S>(mut self, args: I) -> Self
|
|
where
|
|
I: IntoIterator<Item = S>,
|
|
S: Into<String>,
|
|
{
|
|
self.args.extend(args.into_iter().map(Into::into));
|
|
self
|
|
}
|
|
|
|
pub fn current_dir(mut self, directory: impl Into<String>) -> Self {
|
|
self.working_directory = directory.into();
|
|
self
|
|
}
|
|
|
|
pub fn env(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
|
self.environment_variables.insert(name.into(), value.into());
|
|
self
|
|
}
|
|
|
|
pub fn timeout(mut self, timeout: Duration) -> Self {
|
|
self.timeout = timeout;
|
|
self
|
|
}
|
|
|
|
pub fn network_disabled(mut self) -> Self {
|
|
self.network = clusterflux_core::CommandNetworkPolicy::Disabled;
|
|
self
|
|
}
|
|
|
|
pub fn network_enabled(mut self) -> Self {
|
|
self.network = clusterflux_core::CommandNetworkPolicy::Enabled;
|
|
self
|
|
}
|
|
|
|
pub async fn output(self) -> Result<Output, TaskRuntimeError> {
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let timeout_ms = u64::try_from(self.timeout.as_millis()).map_err(|_| {
|
|
TaskRuntimeError::Argument(
|
|
"command timeout does not fit the task ABI".to_owned(),
|
|
)
|
|
})?;
|
|
let output = crate::sdk_runtime::run_guest_host_command(
|
|
self.program,
|
|
self.args,
|
|
self.working_directory,
|
|
self.environment_variables,
|
|
timeout_ms,
|
|
self.network,
|
|
)?;
|
|
return Ok(Output {
|
|
status_code: output.status_code,
|
|
stdout: output.stdout,
|
|
stderr: output.stderr,
|
|
stdout_truncated: output.stdout_truncated,
|
|
stderr_truncated: output.stderr_truncated,
|
|
});
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
Err(TaskRuntimeError::Configuration(
|
|
"Clusterflux commands execute only as a granted host capability of a running Wasm task"
|
|
.to_owned(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub mod source {
|
|
use crate::{spawn::TaskRuntimeError, SourceSnapshot};
|
|
|
|
/// Snapshots the node-local project checkout without sending its source bytes
|
|
/// through the coordinator.
|
|
pub async fn snapshot() -> Result<SourceSnapshot, TaskRuntimeError> {
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let result = crate::sdk_runtime::guest_source_snapshot()?;
|
|
return Ok(SourceSnapshot {
|
|
digest: result.snapshot.as_str().to_owned(),
|
|
});
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
Err(TaskRuntimeError::Configuration(
|
|
"source snapshots are produced only by a source-capable Clusterflux node".to_owned(),
|
|
))
|
|
}
|
|
}
|
|
|
|
pub mod fs {
|
|
use crate::{spawn::TaskRuntimeError, Artifact};
|
|
|
|
pub const OUTPUT_ROOT: &str = "/clusterflux/output";
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct OutputPath {
|
|
relative: String,
|
|
container_path: String,
|
|
}
|
|
|
|
impl OutputPath {
|
|
pub fn relative(&self) -> &str {
|
|
&self.relative
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.container_path
|
|
}
|
|
}
|
|
|
|
pub fn output_path(relative: impl Into<String>) -> Result<OutputPath, TaskRuntimeError> {
|
|
let relative = relative.into();
|
|
validate_relative_path(&relative)?;
|
|
Ok(OutputPath {
|
|
container_path: format!("{OUTPUT_ROOT}/{relative}"),
|
|
relative,
|
|
})
|
|
}
|
|
|
|
pub async fn flush(path: &OutputPath) -> Result<Artifact, TaskRuntimeError> {
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let retained = crate::sdk_runtime::guest_vfs_operation(
|
|
clusterflux_core::WasmHostVfsOperation::FlushOutput {
|
|
relative_path: path.relative.clone(),
|
|
},
|
|
)?;
|
|
return Ok(Artifact {
|
|
id: retained.artifact.id.as_str().to_owned(),
|
|
digest: retained.artifact.digest.as_str().to_owned(),
|
|
size_bytes: retained.artifact.size_bytes,
|
|
});
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let _ = path;
|
|
Err(TaskRuntimeError::Configuration(
|
|
"Clusterflux output files are flushed only by a running Wasm task on a retaining node"
|
|
.to_owned(),
|
|
))
|
|
}
|
|
}
|
|
|
|
pub async fn materialize(
|
|
artifact: &Artifact,
|
|
relative: impl Into<String>,
|
|
) -> Result<OutputPath, TaskRuntimeError> {
|
|
let output = output_path(relative)?;
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let digest = crate::task_args::parse_handle_digest(&artifact.digest)
|
|
.map_err(|error| TaskRuntimeError::Argument(error.to_string()))?;
|
|
crate::sdk_runtime::guest_vfs_operation(
|
|
clusterflux_core::WasmHostVfsOperation::MaterializeArtifact {
|
|
artifact: clusterflux_core::ArtifactHandle {
|
|
id: clusterflux_core::ArtifactId::from(artifact.id.as_str()),
|
|
digest,
|
|
size_bytes: artifact.size_bytes,
|
|
},
|
|
relative_path: output.relative.clone(),
|
|
},
|
|
)?;
|
|
return Ok(output);
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let _ = (&output, artifact);
|
|
Err(TaskRuntimeError::Configuration(
|
|
"Clusterflux artifacts are materialized only by a running Wasm task on a retaining node"
|
|
.to_owned(),
|
|
))
|
|
}
|
|
}
|
|
|
|
fn validate_relative_path(path: &str) -> Result<(), TaskRuntimeError> {
|
|
if path.is_empty()
|
|
|| path.len() > 240
|
|
|| path.starts_with('/')
|
|
|| path.starts_with('\\')
|
|
|| path.split('/').any(|component| {
|
|
component.is_empty()
|
|
|| component == "."
|
|
|| component == ".."
|
|
|| !component.chars().all(|character| {
|
|
character.is_ascii_alphanumeric() || "._-".contains(character)
|
|
})
|
|
})
|
|
{
|
|
return Err(TaskRuntimeError::Argument(
|
|
"output path must be a safe task-output-relative path".to_owned(),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub mod process {
|
|
pub use crate::sdk_runtime::TaskRuntimeError;
|
|
|
|
pub fn cancellation_requested() -> Result<bool, TaskRuntimeError> {
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
return crate::sdk_runtime::guest_cancellation_requested();
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub mod debug {
|
|
pub use crate::sdk_runtime::TaskRuntimeError;
|
|
|
|
pub fn probe(symbol: impl Into<String>) -> Result<(), TaskRuntimeError> {
|
|
#[cfg(target_arch = "wasm32")]
|
|
{
|
|
let _ = crate::sdk_runtime::guest_debug_probe(symbol.into())?;
|
|
return Ok(());
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
{
|
|
let _ = symbol.into();
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use futures_executor::block_on;
|
|
|
|
#[test]
|
|
fn env_macro_creates_logical_environment_reference() {
|
|
let env = crate::env!("linux");
|
|
|
|
assert_eq!(env.name, "linux");
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_task_start_join_returns_small_result() {
|
|
let result = block_on(async {
|
|
let handle = crate::spawn::task(|| 42)
|
|
.name("compile linux")
|
|
.env(crate::env!("linux"))
|
|
.start()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(handle.name(), "compile linux");
|
|
assert_eq!(handle.env().unwrap().name, "linux");
|
|
assert!(handle.debugger_visible());
|
|
handle.join().await.unwrap()
|
|
});
|
|
|
|
assert_eq!(result, 42);
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_task_start_registers_debugger_visible_runtime_thread() {
|
|
let handle = block_on(async {
|
|
crate::spawn::task(|| 7_u32)
|
|
.name("sdk-runtime-thread-test")
|
|
.env(crate::env!("linux"))
|
|
.start()
|
|
.await
|
|
.unwrap()
|
|
});
|
|
let events = crate::spawn::runtime_spawn_events();
|
|
let event = events
|
|
.iter()
|
|
.find(|event| event.virtual_thread_id == handle.virtual_thread_id())
|
|
.expect("spawn runtime event should exist for task handle");
|
|
|
|
assert!(handle.debugger_visible());
|
|
assert!(event.debugger_visible);
|
|
assert_eq!(event.name, "sdk-runtime-thread-test");
|
|
assert_eq!(event.env.unwrap().name, "linux");
|
|
assert_eq!(block_on(handle.join()).unwrap(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() {
|
|
let artifact = crate::Artifact {
|
|
id: "artifact://build/app".to_owned(),
|
|
digest: clusterflux_core::Digest::sha256("app").as_str().to_owned(),
|
|
size_bytes: 3,
|
|
};
|
|
let artifact_validation = crate::validate_task_arg(
|
|
&artifact,
|
|
crate::TaskArgBudget {
|
|
max_inline_bytes: 4,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(artifact_validation.kind, crate::TaskArgKind::Handle);
|
|
|
|
let bytes = vec![1_u8, 2, 3, 4, 5];
|
|
let error = crate::validate_task_arg(
|
|
&bytes,
|
|
crate::TaskArgBudget {
|
|
max_inline_bytes: 4,
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert!(matches!(error, crate::TaskArgError::TooLarge { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_task_with_arg_rejects_large_inline_argument_before_dispatch() {
|
|
let dispatched = std::cell::Cell::new(false);
|
|
let result = block_on(async {
|
|
crate::spawn::task_with_arg(vec![1_u8, 2, 3, 4, 5], |_| {
|
|
dispatched.set(true);
|
|
42_u32
|
|
})
|
|
.arg_budget(crate::TaskArgBudget {
|
|
max_inline_bytes: 4,
|
|
})
|
|
.start()
|
|
.await
|
|
});
|
|
|
|
assert!(matches!(
|
|
result,
|
|
Err(crate::spawn::TaskRuntimeError::Argument(_))
|
|
));
|
|
assert!(!dispatched.get());
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_task_with_arg_allows_runtime_handles_under_inline_budget() {
|
|
let artifact = crate::Artifact {
|
|
id: "artifact://build/app".to_owned(),
|
|
digest: clusterflux_core::Digest::sha256("app").as_str().to_owned(),
|
|
size_bytes: 3,
|
|
};
|
|
let result = block_on(async {
|
|
let handle = crate::spawn::task_with_arg(artifact, |artifact| artifact.id)
|
|
.name("publish artifact")
|
|
.env(crate::env!("linux"))
|
|
.arg_budget(crate::TaskArgBudget {
|
|
max_inline_bytes: 4,
|
|
})
|
|
.start()
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(handle.name(), "publish artifact");
|
|
handle.join().await.unwrap()
|
|
});
|
|
|
|
assert_eq!(result, "artifact://build/app");
|
|
}
|
|
|
|
#[test]
|
|
fn host_only_task_arg_error_names_rejected_type() {
|
|
let error = crate::reject_host_only_task_arg::<*const u8>();
|
|
|
|
assert!(error.to_string().contains("*const u8"));
|
|
assert!(error.to_string().contains("host-only"));
|
|
}
|
|
}
|