Skip to main content

solti_model/domain/kind/
task.rs

1//! # Task workloads
2//!
3//! [`TaskWorkload`] contains built-in and extension workload desired state.
4//!
5//! Every workload uses a Kubernetes-style envelope:
6//!
7//! ```text
8//! apiVersion
9//! kind
10//! spec
11//! ```
12//!
13//! Built-in workload specs reject unknown fields.
14//! Extension specs preserve application-owned JSON object fields.
15
16use std::path::PathBuf;
17
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use serde_json::Value;
20
21use crate::{Flag, ModelError, ModelResult, SubprocessMode, TaskEnv, validation};
22
23/// API group and version of built-in Solti workloads.
24pub const WORKLOAD_API_VERSION: &str = "solti.io/v1";
25
26/// Group/version and kind of one workload schema.
27#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
30#[serde(rename_all = "camelCase")]
31pub struct WorkloadTypeMeta {
32    #[cfg_attr(
33        feature = "schema",
34        schemars(schema_with = "crate::schema::crd_api_version")
35    )]
36    api_version: String,
37    #[cfg_attr(feature = "schema", schemars(schema_with = "crate::schema::crd_kind"))]
38    kind: String,
39}
40
41impl<'de> Deserialize<'de> for WorkloadTypeMeta {
42    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
43    where
44        D: Deserializer<'de>,
45    {
46        #[derive(Deserialize)]
47        #[serde(rename_all = "camelCase", deny_unknown_fields)]
48        struct RawWorkloadTypeMeta {
49            api_version: String,
50            kind: String,
51        }
52
53        let raw = RawWorkloadTypeMeta::deserialize(deserializer)?;
54        Self::new(raw.api_version, raw.kind).map_err(serde::de::Error::custom)
55    }
56}
57
58impl WorkloadTypeMeta {
59    /// Creates validated workload type metadata.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`ModelError::Invalid`] for an invalid CRD group/version or kind.
64    pub fn new(api_version: impl Into<String>, kind: impl Into<String>) -> ModelResult<Self> {
65        let type_meta = Self {
66            api_version: api_version.into(),
67            kind: kind.into(),
68        };
69        type_meta.validate()?;
70        Ok(type_meta)
71    }
72
73    /// Workload API group and version.
74    #[inline]
75    pub fn api_version(&self) -> &str {
76        &self.api_version
77    }
78
79    /// Workload resource kind.
80    #[inline]
81    pub fn kind(&self) -> &str {
82        &self.kind
83    }
84
85    fn validate(&self) -> ModelResult<()> {
86        validation::validate_crd_api_version("workload apiVersion", &self.api_version)?;
87        validation::validate_crd_kind("workload kind", &self.kind)
88    }
89}
90
91/// Desired state of an embedded task implementation.
92///
93/// The revision participates in desired-state comparison.
94/// The runtime task handle is supplied separately by a higher layer.
95#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
98#[serde(rename_all = "camelCase")]
99pub struct EmbeddedSpec {
100    #[cfg_attr(
101        feature = "schema",
102        schemars(schema_with = "crate::schema::non_empty_string")
103    )]
104    revision: String,
105}
106
107impl<'de> Deserialize<'de> for EmbeddedSpec {
108    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
109    where
110        D: Deserializer<'de>,
111    {
112        #[derive(Deserialize)]
113        #[serde(deny_unknown_fields)]
114        struct RawEmbeddedSpec {
115            revision: String,
116        }
117
118        let raw = RawEmbeddedSpec::deserialize(deserializer)?;
119        Self::new(raw.revision).map_err(serde::de::Error::custom)
120    }
121}
122
123impl EmbeddedSpec {
124    /// Creates an embedded workload spec.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`ModelError::Invalid`] when `revision` is empty.
129    pub fn new(revision: impl Into<String>) -> ModelResult<Self> {
130        let spec = Self {
131            revision: revision.into(),
132        };
133        spec.validate()?;
134        Ok(spec)
135    }
136
137    /// Caller-owned implementation revision.
138    #[inline]
139    pub fn revision(&self) -> &str {
140        &self.revision
141    }
142
143    fn validate(&self) -> ModelResult<()> {
144        if self.revision.trim().is_empty() {
145            return Err(ModelError::Invalid(
146                "embedded workload revision must not be empty".into(),
147            ));
148        }
149        Ok(())
150    }
151}
152
153/// Executable desired state nested in a [`TaskSpec`](crate::TaskSpec).
154///
155/// | Variant      | Backend                        | Routable |
156/// |--------------|--------------------------------|----------|
157/// | `Subprocess` | OS process (`command`, `args`) | yes      |
158/// | `Container`  | OCI container image            | yes      |
159/// | `Embedded`   | In-process implementation      | no       |
160/// | `Wasm`       | WASI module (`.wasm`)          | yes      |
161/// | `Extension`  | Application-provided runner    | yes      |
162///
163/// Routable variants are selected by `solti-runner`.
164/// `Embedded` carries no runtime task handle.
165///
166/// ## Example
167///
168/// ```
169/// use solti_model::{Flag, SubprocessMode, SubprocessSpec, TaskEnv, TaskWorkload};
170///
171/// let workload = TaskWorkload::Subprocess(SubprocessSpec::new(
172///     SubprocessMode::Command {
173///         command: "echo".into(),
174///         args: vec!["hello".into()],
175///     },
176///     TaskEnv::default(),
177///     None,
178///     Flag::enabled(),
179/// ));
180///
181/// assert_eq!(workload.kind(), "Subprocess");
182/// workload.validate().unwrap();
183/// ```
184#[derive(Clone, Debug, Eq, PartialEq)]
185#[non_exhaustive]
186pub enum TaskWorkload {
187    /// Execute a subprocess on the host.
188    Subprocess(SubprocessSpec),
189
190    /// Execute a WebAssembly module via a WASI-compatible runtime.
191    Wasm(WasmSpec),
192
193    /// Run a task inside an OCI-compatible container.
194    Container(ContainerSpec),
195
196    /// Code-defined task that bypasses runner routing.
197    ///
198    /// A higher layer binds the desired revision to an in-process task.
199    Embedded(EmbeddedSpec),
200
201    /// Workload implemented by an application-provided runner.
202    Extension(ExtensionWorkload),
203}
204
205#[cfg(feature = "schema")]
206impl schemars::JsonSchema for TaskWorkload {
207    fn schema_name() -> std::borrow::Cow<'static, str> {
208        "TaskWorkload".into()
209    }
210
211    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
212        let subprocess =
213            workload_envelope_schema("Subprocess", generator.subschema_for::<SubprocessSpec>());
214        let wasm = workload_envelope_schema("Wasm", generator.subschema_for::<WasmSpec>());
215        let container =
216            workload_envelope_schema("Container", generator.subschema_for::<ContainerSpec>());
217        let embedded =
218            workload_envelope_schema("Embedded", generator.subschema_for::<EmbeddedSpec>());
219        let extension = generator.subschema_for::<ExtensionWorkload>();
220
221        schemars::json_schema!({
222            "description": "Kubernetes-style workload GVK and desired state.",
223            "oneOf": [subprocess, wasm, container, embedded, extension]
224        })
225    }
226}
227
228#[cfg(feature = "schema")]
229fn workload_envelope_schema(kind: &'static str, spec: schemars::Schema) -> schemars::Schema {
230    schemars::json_schema!({
231        "type": "object",
232        "additionalProperties": false,
233        "required": ["apiVersion", "kind", "spec"],
234        "properties": {
235            "apiVersion": {
236                "type": "string",
237                "const": WORKLOAD_API_VERSION
238            },
239            "kind": {
240                "type": "string",
241                "const": kind
242            },
243            "spec": spec
244        }
245    })
246}
247
248/// GVK envelope for an application-provided workload.
249///
250/// `spec` must be a JSON object.
251/// Its fields are owned by the application.
252#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
253#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
254#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
255#[serde(rename_all = "camelCase")]
256pub struct ExtensionWorkload {
257    #[cfg_attr(
258        feature = "schema",
259        schemars(schema_with = "crate::schema::extension_api_version")
260    )]
261    api_version: String,
262    #[cfg_attr(feature = "schema", schemars(schema_with = "crate::schema::crd_kind"))]
263    kind: String,
264    #[cfg_attr(
265        feature = "schema",
266        schemars(schema_with = "crate::schema::json_object")
267    )]
268    spec: Value,
269}
270
271impl<'de> Deserialize<'de> for ExtensionWorkload {
272    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
273    where
274        D: Deserializer<'de>,
275    {
276        let raw = RawWorkloadEnvelope::deserialize(deserializer)?;
277        Self::new(raw.api_version, raw.kind, raw.spec).map_err(serde::de::Error::custom)
278    }
279}
280
281impl ExtensionWorkload {
282    /// Creates an extension workload envelope.
283    ///
284    /// The `solti.io` API group is reserved for built-in Solti workloads.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`ModelError::Invalid`] for an invalid GVK, a reserved API group, or a non-object `spec`.
289    pub fn new(
290        api_version: impl Into<String>,
291        kind: impl Into<String>,
292        spec: Value,
293    ) -> ModelResult<Self> {
294        let workload = Self {
295            api_version: api_version.into(),
296            kind: kind.into(),
297            spec,
298        };
299        workload.validate()?;
300        Ok(workload)
301    }
302
303    /// Workload API group and version.
304    #[inline]
305    pub fn api_version(&self) -> &str {
306        &self.api_version
307    }
308
309    /// Workload resource kind.
310    #[inline]
311    pub fn kind(&self) -> &str {
312        &self.kind
313    }
314
315    /// Application-owned desired state.
316    #[inline]
317    pub fn spec(&self) -> &Value {
318        &self.spec
319    }
320
321    fn validate(&self) -> ModelResult<()> {
322        let group = validation::validate_crd_api_version(
323            "extension workload apiVersion",
324            &self.api_version,
325        )?;
326        validation::validate_crd_kind("extension workload kind", &self.kind)?;
327        if group == "solti.io" {
328            return Err(ModelError::Invalid(
329                format!(
330                    "extension workload GVK {}/{} uses the reserved solti.io API group",
331                    self.api_version, self.kind
332                )
333                .into(),
334            ));
335        }
336        if !self.spec.is_object() {
337            return Err(ModelError::Invalid(
338                "extension workload spec must be a JSON object".into(),
339            ));
340        }
341        Ok(())
342    }
343}
344
345impl TaskWorkload {
346    /// Returns the workload API group and version.
347    #[inline]
348    pub fn api_version(&self) -> &str {
349        match self {
350            Self::Extension(workload) => workload.api_version(),
351            _ => WORKLOAD_API_VERSION,
352        }
353    }
354
355    /// Returns owned workload type metadata.
356    pub fn type_meta(&self) -> WorkloadTypeMeta {
357        WorkloadTypeMeta {
358            api_version: self.api_version().to_owned(),
359            kind: self.kind().to_owned(),
360        }
361    }
362
363    /// Returns the workload resource kind.
364    ///
365    /// ## Example
366    ///
367    /// ```
368    /// use solti_model::TaskWorkload;
369    ///
370    /// let embedded = TaskWorkload::Embedded(solti_model::EmbeddedSpec::new("v1").unwrap());
371    /// assert_eq!(embedded.kind(), "Embedded");
372    /// ```
373    #[inline]
374    pub fn kind(&self) -> &str {
375        match self {
376            Self::Subprocess(_) => "Subprocess",
377            Self::Container(_) => "Container",
378            Self::Embedded(_) => "Embedded",
379            Self::Wasm(_) => "Wasm",
380            Self::Extension(workload) => workload.kind(),
381        }
382    }
383
384    /// Validates kind-specific constraints.
385    ///
386    /// Delegates to the inner workload spec.
387    /// `Embedded` requires a non-empty implementation revision.
388    ///
389    /// # Errors
390    ///
391    /// Returns [`ModelError::Invalid`] when the selected spec is invalid.
392    ///
393    /// ## Example
394    ///
395    /// ```
396    /// use solti_model::{ContainerSpec, TaskEnv, TaskWorkload};
397    ///
398    /// let workload = TaskWorkload::Container(ContainerSpec::new(
399    ///     "redis:7".into(),
400    ///     None,
401    ///     vec![],
402    ///     TaskEnv::default(),
403    /// ));
404    ///
405    /// workload.validate().unwrap();
406    /// ```
407    pub fn validate(&self) -> ModelResult<()> {
408        match self {
409            Self::Subprocess(spec) => spec.mode.validate(),
410            Self::Container(spec) => spec.validate(),
411            Self::Wasm(spec) => spec.validate(),
412            Self::Embedded(spec) => spec.validate(),
413            Self::Extension(workload) => workload.validate(),
414        }
415    }
416}
417
418#[derive(Serialize)]
419#[serde(rename_all = "camelCase")]
420struct WorkloadEnvelope<'a, T> {
421    api_version: &'a str,
422    kind: &'a str,
423    spec: T,
424}
425
426impl Serialize for TaskWorkload {
427    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
428    where
429        S: Serializer,
430    {
431        match self {
432            Self::Subprocess(spec) => WorkloadEnvelope {
433                api_version: self.api_version(),
434                kind: self.kind(),
435                spec,
436            }
437            .serialize(serializer),
438            Self::Wasm(spec) => WorkloadEnvelope {
439                api_version: self.api_version(),
440                kind: self.kind(),
441                spec,
442            }
443            .serialize(serializer),
444            Self::Container(spec) => WorkloadEnvelope {
445                api_version: self.api_version(),
446                kind: self.kind(),
447                spec,
448            }
449            .serialize(serializer),
450            Self::Embedded(spec) => WorkloadEnvelope {
451                api_version: self.api_version(),
452                kind: self.kind(),
453                spec,
454            }
455            .serialize(serializer),
456            Self::Extension(workload) => WorkloadEnvelope {
457                api_version: workload.api_version(),
458                kind: workload.kind(),
459                spec: workload.spec(),
460            }
461            .serialize(serializer),
462        }
463    }
464}
465
466#[derive(Deserialize)]
467#[serde(rename_all = "camelCase", deny_unknown_fields)]
468struct RawWorkloadEnvelope {
469    api_version: String,
470    kind: String,
471    spec: Value,
472}
473
474impl<'de> Deserialize<'de> for TaskWorkload {
475    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
476    where
477        D: Deserializer<'de>,
478    {
479        let raw = RawWorkloadEnvelope::deserialize(deserializer)?;
480        let workload = if raw.api_version == WORKLOAD_API_VERSION {
481            match raw.kind.as_str() {
482                "Subprocess" => Self::Subprocess(
483                    serde_json::from_value(raw.spec).map_err(serde::de::Error::custom)?,
484                ),
485                "Wasm" => {
486                    Self::Wasm(serde_json::from_value(raw.spec).map_err(serde::de::Error::custom)?)
487                }
488                "Container" => Self::Container(
489                    serde_json::from_value(raw.spec).map_err(serde::de::Error::custom)?,
490                ),
491                "Embedded" => Self::Embedded(
492                    serde_json::from_value(raw.spec).map_err(serde::de::Error::custom)?,
493                ),
494                _ => Self::Extension(
495                    ExtensionWorkload::new(raw.api_version, raw.kind, raw.spec)
496                        .map_err(serde::de::Error::custom)?,
497                ),
498            }
499        } else {
500            Self::Extension(
501                ExtensionWorkload::new(raw.api_version, raw.kind, raw.spec)
502                    .map_err(serde::de::Error::custom)?,
503            )
504        };
505        workload.validate().map_err(serde::de::Error::custom)?;
506        Ok(workload)
507    }
508}
509
510impl WasmSpec {
511    /// Creates a WASM spec.
512    ///
513    /// `WasmSpec` is `#[non_exhaustive]`.
514    /// Use this constructor outside the crate.
515    /// Validation occurs when the workload enters a [`crate::TaskSpec`].
516    ///
517    /// ## Example
518    ///
519    /// ```
520    /// use std::path::PathBuf;
521    /// use solti_model::{TaskEnv, WasmSpec};
522    ///
523    /// let spec = WasmSpec::new(PathBuf::from("job.wasm"), vec!["--help".into()], TaskEnv::default());
524    /// assert_eq!(spec.module, PathBuf::from("job.wasm"));
525    /// ```
526    pub fn new(module: PathBuf, args: Vec<String>, env: TaskEnv) -> Self {
527        Self { module, args, env }
528    }
529
530    /// Validates structural constraints.
531    ///
532    /// # Errors
533    ///
534    /// Returns [`ModelError::Invalid`] when the module path is empty.
535    ///
536    /// ## Example
537    ///
538    /// ```
539    /// use std::path::PathBuf;
540    /// use solti_model::{TaskEnv, WasmSpec};
541    ///
542    /// let spec = WasmSpec::new(PathBuf::from("job.wasm"), vec![], TaskEnv::default());
543    /// spec.validate().unwrap();
544    /// ```
545    pub fn validate(&self) -> ModelResult<()> {
546        if self.module.as_os_str().is_empty() {
547            return Err(ModelError::Invalid(
548                "wasm module path cannot be empty".into(),
549            ));
550        }
551        Ok(())
552    }
553}
554
555impl ContainerSpec {
556    /// Creates a container spec.
557    ///
558    /// `ContainerSpec` is `#[non_exhaustive]`.
559    /// Use this constructor outside the crate.
560    /// Validation occurs when the workload enters a [`crate::TaskSpec`].
561    ///
562    /// ## Example
563    ///
564    /// ```
565    /// use solti_model::{ContainerSpec, TaskEnv};
566    ///
567    /// let spec = ContainerSpec::new(
568    ///     "docker.io/library/redis:7".into(),
569    ///     None,
570    ///     vec![],
571    ///     TaskEnv::default(),
572    /// );
573    ///
574    /// assert_eq!(spec.image, "docker.io/library/redis:7");
575    /// ```
576    pub fn new(
577        image: String,
578        command: Option<Vec<String>>,
579        args: Vec<String>,
580        env: TaskEnv,
581    ) -> Self {
582        Self {
583            image,
584            command,
585            args,
586            env,
587        }
588    }
589
590    /// Validates structural constraints.
591    ///
592    /// # Errors
593    ///
594    /// Returns [`ModelError::Invalid`] when the image is empty.
595    ///
596    /// ## Example
597    ///
598    /// ```
599    /// use solti_model::{ContainerSpec, TaskEnv};
600    ///
601    /// let spec = ContainerSpec::new("redis:7".into(), None, vec![], TaskEnv::default());
602    /// spec.validate().unwrap();
603    /// ```
604    pub fn validate(&self) -> ModelResult<()> {
605        if self.image.trim().is_empty() {
606            return Err(ModelError::Invalid(
607                "container image cannot be empty".into(),
608            ));
609        }
610        Ok(())
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use std::path::PathBuf;
618
619    #[test]
620    fn built_in_validation_accepts_valid_values_and_rejects_empty_fields() {
621        TaskWorkload::Container(ContainerSpec {
622            image: "nginx:latest".into(),
623            command: None,
624            args: vec![],
625            env: Default::default(),
626        })
627        .validate()
628        .unwrap();
629        TaskWorkload::Embedded(EmbeddedSpec::new("v1").unwrap())
630            .validate()
631            .unwrap();
632
633        for image in ["", "  \t"] {
634            let workload = TaskWorkload::Container(ContainerSpec {
635                image: image.into(),
636                command: None,
637                args: vec![],
638                env: Default::default(),
639            });
640            let error = workload.validate().unwrap_err();
641            assert!(error.to_string().contains("container image"));
642        }
643
644        let workload = TaskWorkload::Wasm(WasmSpec {
645            module: PathBuf::new(),
646            args: vec![],
647            env: Default::default(),
648        });
649        let error = workload.validate().unwrap_err();
650        assert!(error.to_string().contains("wasm module"));
651        assert!(EmbeddedSpec::new("  ").is_err());
652    }
653
654    #[test]
655    fn built_in_envelope_has_stable_gvk_and_rejects_unknown_fields() {
656        let workload = TaskWorkload::Embedded(EmbeddedSpec::new("build-42").unwrap());
657        let json = serde_json::to_value(&workload).unwrap();
658
659        assert_eq!(json["apiVersion"], "solti.io/v1");
660        assert_eq!(json["kind"], "Embedded");
661        assert_eq!(json["spec"], serde_json::json!({"revision": "build-42"}));
662
663        let mut envelope = serde_json::to_value(&workload).unwrap();
664        envelope["unexpected"] = serde_json::json!(true);
665        assert!(serde_json::from_value::<TaskWorkload>(envelope).is_err());
666
667        let mut spec = serde_json::to_value(workload).unwrap();
668        spec["spec"]["unexpected"] = serde_json::json!(true);
669        assert!(serde_json::from_value::<TaskWorkload>(spec).is_err());
670    }
671
672    #[test]
673    fn extension_roundtrips_gvk_and_application_owned_fields() {
674        let workload = TaskWorkload::Extension(
675            ExtensionWorkload::new(
676                "tasks.example.io/v1alpha1",
677                "ImageResize",
678                serde_json::json!({
679                    "width": 1280,
680                    "format": "webp",
681                    "unexpectedToSolti": true,
682                    "nested": { "applicationField": [1, 2, 3] }
683                }),
684            )
685            .unwrap(),
686        );
687
688        let json = serde_json::to_string(&workload).unwrap();
689        let back: TaskWorkload = serde_json::from_str(&json).unwrap();
690
691        assert_eq!(back, workload);
692        assert_eq!(back.api_version(), "tasks.example.io/v1alpha1");
693        assert_eq!(back.kind(), "ImageResize");
694
695        let extension = ExtensionWorkload::new(
696            "tasks.example.io/v1",
697            "Report",
698            serde_json::json!({ "format": "json" }),
699        )
700        .unwrap();
701        let json = serde_json::to_string(&extension).unwrap();
702        assert_eq!(
703            serde_json::from_str::<ExtensionWorkload>(&json).unwrap(),
704            extension
705        );
706    }
707
708    #[test]
709    fn extension_workload_rejects_reserved_solti_api_group() {
710        for api_version in [WORKLOAD_API_VERSION, "solti.io/v2"] {
711            let error =
712                ExtensionWorkload::new(api_version, "Custom", serde_json::json!({})).unwrap_err();
713
714            assert!(
715                error.to_string().contains("reserved"),
716                "apiVersion={api_version}"
717            );
718        }
719    }
720
721    #[test]
722    fn extension_workload_allows_builtin_kind_in_another_api_version() {
723        for kind in ["Subprocess", "Wasm", "Container", "Embedded"] {
724            let workload = TaskWorkload::Extension(
725                ExtensionWorkload::new(
726                    "tasks.example.io/v1",
727                    kind,
728                    serde_json::json!({ "custom": true }),
729                )
730                .unwrap(),
731            );
732
733            let json = serde_json::to_string(&workload).unwrap();
734            let back: TaskWorkload = serde_json::from_str(&json).unwrap();
735            assert_eq!(back, workload, "kind={kind}");
736        }
737    }
738
739    #[test]
740    fn workload_gvk_uses_kubernetes_crd_validation() {
741        WorkloadTypeMeta::new("tasks.example.io/v1alpha1", "ImageResize").unwrap();
742        ExtensionWorkload::new("tasks.example.io/v1", "custom-kind", serde_json::json!({}))
743            .unwrap();
744
745        for api_version in [
746            "",
747            " solti.io/v1",
748            "bad/version/extra",
749            "example/v1",
750            "tasks.example.io/1v",
751        ] {
752            assert!(
753                ExtensionWorkload::new(api_version, "Example", serde_json::json!({})).is_err(),
754                "apiVersion={api_version}"
755            );
756        }
757        for kind in ["", "1Example", "Bad Kind", "_Example"] {
758            assert!(
759                ExtensionWorkload::new("tasks.example.io/v1", kind, serde_json::json!({})).is_err(),
760                "kind={kind}"
761            );
762        }
763    }
764
765    #[test]
766    fn extension_workload_requires_object_spec() {
767        let error =
768            ExtensionWorkload::new("example.io/v1", "Example", serde_json::json!(42)).unwrap_err();
769
770        assert!(error.to_string().contains("JSON object"));
771    }
772
773    #[test]
774    fn constructors_build_specs_with_expected_fields() {
775        use crate::{Flag, SubprocessMode, TaskEnv};
776
777        let sub = SubprocessSpec::new(
778            SubprocessMode::Command {
779                command: "ls".into(),
780                args: vec!["-l".into()],
781            },
782            TaskEnv::default(),
783            Some(PathBuf::from("/tmp")),
784            Flag::enabled(),
785        );
786        assert!(matches!(sub.mode, SubprocessMode::Command { .. }));
787        assert_eq!(sub.cwd, Some(PathBuf::from("/tmp")));
788
789        let wasm = WasmSpec::new(
790            PathBuf::from("/m.wasm"),
791            vec!["--x".into()],
792            TaskEnv::default(),
793        );
794        assert_eq!(wasm.module, PathBuf::from("/m.wasm"));
795        assert_eq!(wasm.args, vec!["--x".to_string()]);
796
797        let cont = ContainerSpec::new(
798            "img:1".into(),
799            Some(vec!["sh".into()]),
800            vec!["-c".into()],
801            TaskEnv::default(),
802        );
803        assert_eq!(cont.image, "img:1");
804        assert_eq!(cont.command, Some(vec!["sh".to_string()]));
805    }
806}
807
808/// Specification for subprocess execution on the host.
809///
810/// Supports two execution strategies via [`SubprocessMode`]:
811/// - command: direct binary execution;
812/// - script: script body passed to an explicit interpreter.
813///
814/// Common fields (`env`, `cwd`, `fail_on_non_zero`) apply to both modes.
815#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
816#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
817#[serde(rename_all = "camelCase", deny_unknown_fields)]
818#[non_exhaustive]
819pub struct SubprocessSpec {
820    /// Execution strategy (command or script).
821    pub mode: SubprocessMode,
822    /// Environment variables for the process.
823    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
824    pub env: TaskEnv,
825    /// Working directory.
826    #[serde(skip_serializing_if = "Option::is_none")]
827    pub cwd: Option<PathBuf>,
828    /// Whether to treat non-zero exit codes as task failure.
829    ///
830    /// When enabled (default), any non-zero exit code will be reported as a failure.
831    #[serde(default)]
832    pub fail_on_non_zero: Flag,
833}
834
835impl SubprocessSpec {
836    /// Creates a subprocess spec.
837    ///
838    /// `SubprocessSpec` is `#[non_exhaustive]`.
839    /// Use this constructor outside the crate.
840    /// Validation occurs when the workload enters a [`crate::TaskSpec`].
841    ///
842    /// ## Example
843    ///
844    /// ```
845    /// use solti_model::{Flag, SubprocessMode, SubprocessSpec, TaskEnv};
846    ///
847    /// let spec = SubprocessSpec::new(
848    ///     SubprocessMode::Command {
849    ///         command: "echo".into(),
850    ///         args: vec!["hello".into()],
851    ///     },
852    ///     TaskEnv::default(),
853    ///     None,
854    ///     Flag::enabled(),
855    /// );
856    ///
857    /// assert!(spec.fail_on_non_zero.is_enabled());
858    /// ```
859    pub fn new(
860        mode: SubprocessMode,
861        env: TaskEnv,
862        cwd: Option<PathBuf>,
863        fail_on_non_zero: Flag,
864    ) -> Self {
865        Self {
866            mode,
867            env,
868            cwd,
869            fail_on_non_zero,
870        }
871    }
872}
873
874/// Specification for WebAssembly module execution via a WASI-compatible runtime.
875#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
876#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
877#[serde(rename_all = "camelCase", deny_unknown_fields)]
878#[non_exhaustive]
879pub struct WasmSpec {
880    /// Path to the `.wasm` module.
881    #[cfg_attr(
882        feature = "schema",
883        schemars(schema_with = "crate::schema::path_string")
884    )]
885    pub module: PathBuf,
886    /// Arguments passed to the WASI main entrypoint.
887    #[serde(default, skip_serializing_if = "Vec::is_empty")]
888    pub args: Vec<String>,
889    /// Environment variables exposed to the WASI module.
890    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
891    pub env: TaskEnv,
892}
893
894/// Specification for OCI-compatible container execution.
895#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
896#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
897#[serde(rename_all = "camelCase", deny_unknown_fields)]
898#[non_exhaustive]
899pub struct ContainerSpec {
900    /// Container image (e.g. `"nginx:latest"`, `"docker.io/library/redis:7"`).
901    #[cfg_attr(
902        feature = "schema",
903        schemars(schema_with = "crate::schema::non_empty_string")
904    )]
905    pub image: String,
906    /// Override container entrypoint.
907    #[serde(skip_serializing_if = "Option::is_none")]
908    pub command: Option<Vec<String>>,
909    /// Arguments passed to the container entrypoint.
910    #[serde(default, skip_serializing_if = "Vec::is_empty")]
911    pub args: Vec<String>,
912    /// Environment variables for the container.
913    #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
914    pub env: TaskEnv,
915}