Skip to main content

solti_model/domain/kind/
task.rs

1//! # Task execution backends.
2//!
3//! [`TaskKind`] defines what a task actually runs: subprocess, WASM, container, or embedded code.
4
5use std::path::PathBuf;
6
7use serde::{Deserialize, Serialize};
8
9use crate::{Flag, SubprocessMode, TaskEnv};
10
11/// Execution backend for a task.
12///
13/// | Variant      | Backend                        | Routable |
14/// |--------------|--------------------------------|----------|
15/// | `Subprocess` | OS process (`command`, `args`) | yes      |
16/// | `Container`  | OCI container image            | yes      |
17/// | `Embedded`   | In-process `TaskRef`           | no       |
18/// | `Wasm`       | WASI module (`.wasm`)          | yes      |
19///
20/// Routable variants go through `RunnerRouter::pick()`.
21///
22/// ## Also
23///
24/// - [`TaskSpec`](crate::TaskSpec) - embeds `TaskKind` as its execution backend.
25/// - `solti_runner::RunnerRouter`  - picks a runner based on kind and selector.
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28#[non_exhaustive]
29pub enum TaskKind {
30    /// Execute a subprocess on the host.
31    Subprocess(SubprocessSpec),
32
33    /// Execute a WebAssembly module via a WASI-compatible runtime.
34    Wasm(WasmSpec),
35
36    /// Run a task inside an OCI-compatible container.
37    Container(ContainerSpec),
38
39    /// Built-in / code-defined task that does not require a runner.
40    ///
41    /// Used only with `SupervisorApi::submit_with_task()`.
42    /// Any attempt to submit this via `submit()` (which builds via runners) must be rejected.
43    Embedded,
44}
45
46impl TaskKind {
47    /// Returns a short symbolic identifier for the runtime kind.
48    ///
49    /// This is primarily intended for logging, metrics and routing:
50    /// - `"subprocess"`
51    /// - `"container"`
52    /// - `"embedded"`
53    /// - `"wasm"`
54    #[inline]
55    pub fn kind(&self) -> &'static str {
56        match self {
57            TaskKind::Subprocess(_) => "subprocess",
58            TaskKind::Container(_) => "container",
59            TaskKind::Embedded => "embedded",
60            TaskKind::Wasm(_) => "wasm",
61        }
62    }
63
64    /// Validate kind-specific constraints.
65    pub fn validate(&self) -> crate::error::ModelResult<()> {
66        match self {
67            TaskKind::Subprocess(spec) => spec.mode.validate(),
68            TaskKind::Wasm(spec) => spec.validate(),
69            TaskKind::Container(spec) => spec.validate(),
70            TaskKind::Embedded => Ok(()),
71        }
72    }
73}
74
75impl WasmSpec {
76    /// Validate structural constraints.
77    pub fn validate(&self) -> crate::error::ModelResult<()> {
78        if self.module.as_os_str().is_empty() {
79            return Err(crate::error::ModelError::Invalid(
80                "wasm module path cannot be empty".into(),
81            ));
82        }
83        Ok(())
84    }
85}
86
87impl ContainerSpec {
88    /// Validate structural constraints.
89    pub fn validate(&self) -> crate::error::ModelResult<()> {
90        if self.image.trim().is_empty() {
91            return Err(crate::error::ModelError::Invalid(
92                "container image cannot be empty".into(),
93            ));
94        }
95        Ok(())
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use std::path::PathBuf;
103
104    #[test]
105    fn task_kind_validate_rejects_empty_container_image() {
106        let kind = TaskKind::Container(ContainerSpec {
107            image: "".into(),
108            command: None,
109            args: vec![],
110            env: Default::default(),
111        });
112        let err = kind.validate().unwrap_err();
113        assert!(err.to_string().contains("container image"));
114    }
115
116    #[test]
117    fn task_kind_validate_rejects_whitespace_container_image() {
118        let kind = TaskKind::Container(ContainerSpec {
119            image: "  \t".into(),
120            command: None,
121            args: vec![],
122            env: Default::default(),
123        });
124        assert!(kind.validate().is_err());
125    }
126
127    #[test]
128    fn task_kind_validate_rejects_empty_wasm_module() {
129        let kind = TaskKind::Wasm(WasmSpec {
130            module: PathBuf::new(),
131            args: vec![],
132            env: Default::default(),
133        });
134        let err = kind.validate().unwrap_err();
135        assert!(err.to_string().contains("wasm module"));
136    }
137
138    #[test]
139    fn task_kind_validate_accepts_valid_container() {
140        let kind = TaskKind::Container(ContainerSpec {
141            image: "nginx:latest".into(),
142            command: None,
143            args: vec![],
144            env: Default::default(),
145        });
146        assert!(kind.validate().is_ok());
147    }
148
149    #[test]
150    fn task_kind_validate_accepts_embedded() {
151        assert!(TaskKind::Embedded.validate().is_ok());
152    }
153}
154
155/// Specification for subprocess execution on the host.
156///
157/// Supports two execution strategies via [`SubprocessMode`]:
158/// - **Command** — direct binary execution (`execve(command, args)`)
159/// - **Script** — script interpreted by a [`Runtime`](crate::Runtime) (`execve(runtime, [flag, body, ...args])`)
160///
161/// Common fields (`env`, `cwd`, `fail_on_non_zero`) apply to both modes.
162#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase")]
164pub struct SubprocessSpec {
165    /// Execution strategy (command or script).
166    pub mode: SubprocessMode,
167    /// Environment variables for the process.
168    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
169    pub env: TaskEnv,
170    /// Working directory.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub cwd: Option<PathBuf>,
173    /// Whether to treat non-zero exit codes as task failure.
174    ///
175    /// When enabled (default), any non-zero exit code will be reported as a failure.
176    #[serde(default)]
177    pub fail_on_non_zero: Flag,
178}
179
180/// Specification for WebAssembly module execution via a WASI-compatible runtime.
181#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct WasmSpec {
184    /// Path to the `.wasm` module.
185    pub module: PathBuf,
186    /// Arguments passed to the WASI main entrypoint.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub args: Vec<String>,
189    /// Environment variables exposed to the WASI module.
190    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
191    pub env: TaskEnv,
192}
193
194/// Specification for OCI-compatible container execution.
195#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct ContainerSpec {
198    /// Container image (e.g. `"nginx:latest"`, `"docker.io/library/redis:7"`).
199    pub image: String,
200    /// Override container entrypoint.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub command: Option<Vec<String>>,
203    /// Arguments passed to the container entrypoint.
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub args: Vec<String>,
206    /// Environment variables for the container.
207    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
208    pub env: TaskEnv,
209}