wasmer_config/app/
job.rs

1use std::{borrow::Cow, fmt::Display, str::FromStr};
2
3use serde::{de::Error, Deserialize, Serialize};
4
5use indexmap::IndexMap;
6
7use crate::package::PackageSource;
8
9use super::{pretty_duration::PrettyDuration, AppConfigCapabilityMemoryV1, AppVolume, HttpRequest};
10
11/// Job configuration.
12#[derive(
13    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
14)]
15pub struct Job {
16    name: String,
17    trigger: JobTrigger,
18
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub timeout: Option<PrettyDuration>,
21
22    /// Don't start job if past the due time by this amount,
23    /// instead opting to wait for the next instance of it
24    /// to be triggered.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub max_schedule_drift: Option<PrettyDuration>,
27
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub retries: Option<u32>,
30
31    action: JobAction,
32}
33
34// We need this wrapper struct to enable this formatting:
35// job:
36//   action:
37//     execute: ...
38#[derive(
39    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
40)]
41pub struct JobAction {
42    #[serde(flatten)]
43    action: JobActionCase,
44}
45
46#[derive(
47    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
48)]
49#[serde(rename_all = "lowercase")]
50pub enum JobActionCase {
51    Fetch(HttpRequest),
52    Execute(ExecutableJob),
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct CronExpression {
57    pub cron: saffron::parse::CronExpr,
58    // Keep the original string form around for serialization purposes.
59    pub parsed_from: String,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum JobTrigger {
64    PreDeployment,
65    PostDeployment,
66    Cron(CronExpression),
67}
68
69#[derive(
70    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
71)]
72pub struct ExecutableJob {
73    /// The package that contains the command to run. Defaults to the app config's package.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    package: Option<PackageSource>,
76
77    /// The command to run. Defaults to the package's entrypoint.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    command: Option<String>,
80
81    /// CLI arguments passed to the runner.
82    /// Only applicable for runners that accept CLI arguments.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    cli_args: Option<Vec<String>>,
85
86    /// Environment variables.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub env: Option<IndexMap<String, String>>,
89
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub capabilities: Option<ExecutableJobCompatibilityMapV1>,
92
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub volumes: Option<Vec<AppVolume>>,
95}
96
97#[derive(
98    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
99)]
100pub struct ExecutableJobCompatibilityMapV1 {
101    /// Instance memory settings.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub memory: Option<AppConfigCapabilityMemoryV1>,
104
105    /// Additional unknown capabilities.
106    ///
107    /// This provides a small bit of forwards compatibility for newly added
108    /// capabilities.
109    #[serde(flatten)]
110    pub other: IndexMap<String, serde_json::Value>,
111}
112
113impl Serialize for JobTrigger {
114    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
115    where
116        S: serde::Serializer,
117    {
118        self.to_string().serialize(serializer)
119    }
120}
121
122impl<'de> Deserialize<'de> for JobTrigger {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: serde::Deserializer<'de>,
126    {
127        let repr: Cow<'de, str> = Cow::deserialize(deserializer)?;
128        repr.parse().map_err(D::Error::custom)
129    }
130}
131
132impl Display for JobTrigger {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::PreDeployment => write!(f, "pre-deployment"),
136            Self::PostDeployment => write!(f, "post-deployment"),
137            Self::Cron(cron) => write!(f, "{}", cron.parsed_from),
138        }
139    }
140}
141
142impl FromStr for JobTrigger {
143    type Err = Box<dyn std::error::Error + Send + Sync>;
144
145    fn from_str(s: &str) -> Result<Self, Self::Err> {
146        if s == "pre-deployment" {
147            Ok(Self::PreDeployment)
148        } else if s == "post-deployment" {
149            Ok(Self::PostDeployment)
150        } else {
151            let expr = s.parse::<CronExpression>()?;
152            Ok(Self::Cron(expr))
153        }
154    }
155}
156
157impl FromStr for CronExpression {
158    type Err = Box<dyn std::error::Error + Send + Sync>;
159
160    fn from_str(s: &str) -> Result<Self, Self::Err> {
161        if let Some(predefined_sched) = s.strip_prefix('@') {
162            match predefined_sched {
163                "hourly" => Ok(Self {
164                    cron: "0 * * * *".parse().unwrap(),
165                    parsed_from: s.to_owned(),
166                }),
167                "daily" => Ok(Self {
168                    cron: "0 0 * * *".parse().unwrap(),
169                    parsed_from: s.to_owned(),
170                }),
171                "weekly" => Ok(Self {
172                    cron: "0 0 * * 1".parse().unwrap(),
173                    parsed_from: s.to_owned(),
174                }),
175                "monthly" => Ok(Self {
176                    cron: "0 0 1 * *".parse().unwrap(),
177                    parsed_from: s.to_owned(),
178                }),
179                "yearly" => Ok(Self {
180                    cron: "0 0 1 1 *".parse().unwrap(),
181                    parsed_from: s.to_owned(),
182                }),
183                _ => Err(format!("Invalid cron expression {s}").into()),
184            }
185        } else {
186            // Let's make sure the input string is valid...
187            match s.parse() {
188                Ok(expr) => Ok(Self {
189                    cron: expr,
190                    parsed_from: s.to_owned(),
191                }),
192                Err(_) => Err(format!("Invalid cron expression {s}").into()),
193            }
194        }
195    }
196}
197
198impl schemars::JsonSchema for JobTrigger {
199    fn schema_name() -> String {
200        "JobTrigger".to_owned()
201    }
202
203    fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
204        String::json_schema(gen)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    pub fn job_trigger_serialization_roundtrip() {
214        fn assert_roundtrip(serialized: &str, description: Option<&str>) {
215            let parsed = serialized.parse::<JobTrigger>().unwrap();
216            assert_eq!(&parsed.to_string(), serialized);
217
218            if let JobTrigger::Cron(expr) = parsed {
219                assert_eq!(
220                    &expr
221                        .cron
222                        .describe(saffron::parse::English::default())
223                        .to_string(),
224                    description.unwrap()
225                );
226            } else {
227                assert!(description.is_none())
228            }
229        }
230
231        assert_roundtrip("pre-deployment", None);
232        assert_roundtrip("post-deployment", None);
233
234        assert_roundtrip("@hourly", Some("Every hour"));
235        assert_roundtrip("@daily", Some("At 12:00 AM"));
236        assert_roundtrip("@weekly", Some("At 12:00 AM on Sunday"));
237        assert_roundtrip("@monthly", Some("At 12:00 AM on the 1st of every month"));
238        assert_roundtrip("@yearly", Some("At 12:00 AM on the 1st of January"));
239
240        // Note: the parsing code should keep the formatting of the source string.
241        // This is tested in assert_roundtrip.
242        assert_roundtrip("0/2 12 * JAN-APR 2", Some("At every 2nd minute from 0 through 59 minutes past the hour, \
243                                                    between 12:00 PM and 12:59 PM on Monday of January to April"));
244    }
245
246    #[test]
247    pub fn job_serialization_roundtrip() {
248        fn parse_cron(expr: &str) -> CronExpression {
249            CronExpression {
250                cron: expr.parse().unwrap(),
251                parsed_from: expr.to_owned(),
252            }
253        }
254
255        let job = Job {
256            name: "my-job".to_owned(),
257            trigger: JobTrigger::Cron(parse_cron("0/2 12 * JAN-APR 2")),
258            timeout: Some("1m".parse().unwrap()),
259            max_schedule_drift: Some("2h".parse().unwrap()),
260            retries: None,
261            action: JobAction {
262                action: JobActionCase::Execute(super::ExecutableJob {
263                    package: Some(crate::package::PackageSource::Ident(
264                        crate::package::PackageIdent::Named(crate::package::NamedPackageIdent {
265                            registry: None,
266                            namespace: Some("ns".to_owned()),
267                            name: "pkg".to_owned(),
268                            tag: None,
269                        }),
270                    )),
271                    command: Some("cmd".to_owned()),
272                    cli_args: Some(vec!["arg-1".to_owned(), "arg-2".to_owned()]),
273                    env: Some([("VAR1".to_owned(), "Value".to_owned())].into()),
274                    capabilities: Some(super::ExecutableJobCompatibilityMapV1 {
275                        memory: Some(crate::app::AppConfigCapabilityMemoryV1 {
276                            limit: Some(bytesize::ByteSize::gb(1)),
277                        }),
278                        other: Default::default(),
279                    }),
280                    volumes: Some(vec![crate::app::AppVolume {
281                        name: "vol".to_owned(),
282                        mount: "/path/to/volume".to_owned(),
283                    }]),
284                }),
285            },
286        };
287
288        let serialized = r#"
289name: my-job
290trigger: '0/2 12 * JAN-APR 2'
291timeout: '1m'
292max_schedule_drift: '2h'
293action:
294  execute:
295    package: ns/pkg
296    command: cmd
297    cli_args:
298    - arg-1
299    - arg-2
300    env:
301      VAR1: Value
302    capabilities:
303      memory:
304        limit: '1000.0 MB'
305    volumes:
306    - name: vol
307      mount: /path/to/volume"#;
308
309        assert_eq!(
310            serialized.trim(),
311            serde_yaml::to_string(&job).unwrap().trim()
312        );
313        assert_eq!(job, serde_yaml::from_str(serialized).unwrap());
314    }
315}