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#[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
41 pub jitter_percent_max: Option<u8>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
55 pub jitter_percent_min: Option<u8>,
56
57 action: JobAction,
58
59 #[serde(flatten)]
63 pub other: IndexMap<String, serde_json::Value>,
64}
65
66#[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 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 #[serde(skip_serializing_if = "Option::is_none")]
107 package: Option<PackageSource>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
111 command: Option<String>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
116 cli_args: Option<Vec<String>>,
117
118 #[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 #[serde(skip_serializing_if = "Option::is_none")]
135 pub memory: Option<AppConfigCapabilityMemoryV1>,
136
137 #[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 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 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}