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 action: JobAction,
32}
33
34#[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 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 #[serde(skip_serializing_if = "Option::is_none")]
75 package: Option<PackageSource>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 command: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
84 cli_args: Option<Vec<String>>,
85
86 #[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 #[serde(skip_serializing_if = "Option::is_none")]
103 pub memory: Option<AppConfigCapabilityMemoryV1>,
104
105 #[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 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 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}