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    ///
66    /// Every variant's spec is checked here so the model is self-consistent
67    /// without relying on transport-layer validators. If a new kind is
68    /// added, extend this match — a missing branch is a compile error
69    /// thanks to exhaustive matching.
70    pub fn validate(&self) -> crate::error::ModelResult<()> {
71        match self {
72            TaskKind::Subprocess(spec) => spec.mode.validate(),
73            TaskKind::Wasm(spec) => spec.validate(),
74            TaskKind::Container(spec) => spec.validate(),
75            TaskKind::Embedded => Ok(()),
76        }
77    }
78}
79
80impl WasmSpec {
81    /// Validate structural constraints.
82    pub fn validate(&self) -> crate::error::ModelResult<()> {
83        if self.module.as_os_str().is_empty() {
84            return Err(crate::error::ModelError::Invalid(
85                "wasm module path cannot be empty".into(),
86            ));
87        }
88        Ok(())
89    }
90}
91
92impl ContainerSpec {
93    /// Validate structural constraints.
94    pub fn validate(&self) -> crate::error::ModelResult<()> {
95        if self.image.trim().is_empty() {
96            return Err(crate::error::ModelError::Invalid(
97                "container image cannot be empty".into(),
98            ));
99        }
100        Ok(())
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use std::path::PathBuf;
108
109    #[test]
110    fn task_kind_validate_rejects_empty_container_image() {
111        let kind = TaskKind::Container(ContainerSpec {
112            image: "".into(),
113            command: None,
114            args: vec![],
115            env: Default::default(),
116        });
117        let err = kind.validate().unwrap_err();
118        assert!(err.to_string().contains("container image"));
119    }
120
121    #[test]
122    fn task_kind_validate_rejects_whitespace_container_image() {
123        let kind = TaskKind::Container(ContainerSpec {
124            image: "  \t".into(),
125            command: None,
126            args: vec![],
127            env: Default::default(),
128        });
129        assert!(kind.validate().is_err());
130    }
131
132    #[test]
133    fn task_kind_validate_rejects_empty_wasm_module() {
134        let kind = TaskKind::Wasm(WasmSpec {
135            module: PathBuf::new(),
136            args: vec![],
137            env: Default::default(),
138        });
139        let err = kind.validate().unwrap_err();
140        assert!(err.to_string().contains("wasm module"));
141    }
142
143    #[test]
144    fn task_kind_validate_accepts_valid_container() {
145        let kind = TaskKind::Container(ContainerSpec {
146            image: "nginx:latest".into(),
147            command: None,
148            args: vec![],
149            env: Default::default(),
150        });
151        assert!(kind.validate().is_ok());
152    }
153
154    #[test]
155    fn task_kind_validate_accepts_embedded() {
156        assert!(TaskKind::Embedded.validate().is_ok());
157    }
158}
159
160/// Specification for subprocess execution on the host.
161///
162/// Supports two execution strategies via [`SubprocessMode`]:
163/// - **Command** — direct binary execution (`execve(command, args)`)
164/// - **Script** — script interpreted by a [`Runtime`](crate::Runtime) (`execve(runtime, [flag, body, ...args])`)
165///
166/// Common fields (`env`, `cwd`, `fail_on_non_zero`) apply to both modes.
167#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct SubprocessSpec {
170    /// Execution strategy (command or script).
171    pub mode: SubprocessMode,
172    /// Environment variables for the process.
173    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
174    pub env: TaskEnv,
175    /// Working directory.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub cwd: Option<PathBuf>,
178    /// Whether to treat non-zero exit codes as task failure.
179    ///
180    /// When enabled (default), any non-zero exit code will be reported as a failure.
181    #[serde(default)]
182    pub fail_on_non_zero: Flag,
183}
184
185/// Specification for WebAssembly module execution via a WASI-compatible runtime.
186#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct WasmSpec {
189    /// Path to the `.wasm` module.
190    pub module: PathBuf,
191    /// Arguments passed to the WASI main entrypoint.
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub args: Vec<String>,
194    /// Environment variables exposed to the WASI module.
195    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
196    pub env: TaskEnv,
197}
198
199/// Specification for OCI-compatible container execution.
200#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct ContainerSpec {
203    /// Container image (e.g. `"nginx:latest"`, `"docker.io/library/redis:7"`).
204    pub image: String,
205    /// Override container entrypoint.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub command: Option<Vec<String>>,
208    /// Arguments passed to the container entrypoint.
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub args: Vec<String>,
211    /// Environment variables for the container.
212    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
213    pub env: TaskEnv,
214}