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