1use std::path::PathBuf;
17
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use serde_json::Value;
20
21use crate::{Flag, ModelError, ModelResult, SubprocessMode, TaskEnv, validation};
22
23pub const WORKLOAD_API_VERSION: &str = "solti.io/v1";
25
26#[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 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 #[inline]
75 pub fn api_version(&self) -> &str {
76 &self.api_version
77 }
78
79 #[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#[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 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 #[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#[derive(Clone, Debug, Eq, PartialEq)]
185#[non_exhaustive]
186pub enum TaskWorkload {
187 Subprocess(SubprocessSpec),
189
190 Wasm(WasmSpec),
192
193 Container(ContainerSpec),
195
196 Embedded(EmbeddedSpec),
200
201 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#[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 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 #[inline]
305 pub fn api_version(&self) -> &str {
306 &self.api_version
307 }
308
309 #[inline]
311 pub fn kind(&self) -> &str {
312 &self.kind
313 }
314
315 #[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 #[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 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 #[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 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 pub fn new(module: PathBuf, args: Vec<String>, env: TaskEnv) -> Self {
527 Self { module, args, env }
528 }
529
530 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 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 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#[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 pub mode: SubprocessMode,
822 #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
824 pub env: TaskEnv,
825 #[serde(skip_serializing_if = "Option::is_none")]
827 pub cwd: Option<PathBuf>,
828 #[serde(default)]
832 pub fail_on_non_zero: Flag,
833}
834
835impl SubprocessSpec {
836 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#[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 #[cfg_attr(
882 feature = "schema",
883 schemars(schema_with = "crate::schema::path_string")
884 )]
885 pub module: PathBuf,
886 #[serde(default, skip_serializing_if = "Vec::is_empty")]
888 pub args: Vec<String>,
889 #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
891 pub env: TaskEnv,
892}
893
894#[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 #[cfg_attr(
902 feature = "schema",
903 schemars(schema_with = "crate::schema::non_empty_string")
904 )]
905 pub image: String,
906 #[serde(skip_serializing_if = "Option::is_none")]
908 pub command: Option<Vec<String>>,
909 #[serde(default, skip_serializing_if = "Vec::is_empty")]
911 pub args: Vec<String>,
912 #[serde(default, skip_serializing_if = "TaskEnv::is_empty")]
914 pub env: TaskEnv,
915}