1mod healthcheck;
4mod http;
5mod job;
6mod pretty_duration;
7
8pub use self::{healthcheck::*, http::*, job::*};
9
10use anyhow::{bail, Context};
11use bytesize::ByteSize;
12use indexmap::IndexMap;
13use pretty_duration::PrettyDuration;
14
15use crate::package::PackageSource;
16
17#[allow(clippy::declare_interior_mutable_const)]
23pub const HEADER_APP_VERSION_ID: &str = "x-edge-app-version-id";
24
25#[derive(
30 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
31)]
32pub struct AppConfigV1 {
33 pub name: Option<String>,
35
36 #[serde(skip_serializing_if = "Option::is_none")]
44 pub app_id: Option<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
50 pub owner: Option<String>,
51
52 pub package: PackageSource,
54
55 #[serde(skip_serializing_if = "Option::is_none")]
60 pub domains: Option<Vec<String>>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub locality: Option<Locality>,
65
66 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
68 pub env: IndexMap<String, String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
73 pub cli_args: Option<Vec<String>>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub capabilities: Option<AppConfigCapabilityMapV1>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub scheduled_tasks: Option<Vec<AppScheduledTask>>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub volumes: Option<Vec<AppVolume>>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub health_checks: Option<Vec<HealthCheckV1>>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub debug: Option<bool>,
90
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub scaling: Option<AppScalingConfigV1>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub redirect: Option<Redirect>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub jobs: Option<Vec<Job>>,
99
100 #[serde(flatten)]
102 pub extra: IndexMap<String, serde_json::Value>,
103}
104
105#[derive(
106 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
107)]
108pub struct Locality {
109 pub regions: Vec<String>,
110}
111
112#[derive(
113 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
114)]
115pub struct AppScalingConfigV1 {
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub mode: Option<AppScalingModeV1>,
118}
119
120#[derive(
121 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
122)]
123pub enum AppScalingModeV1 {
124 #[serde(rename = "single_concurrency")]
125 SingleConcurrency,
126}
127
128#[derive(
129 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
130)]
131pub struct AppVolume {
132 pub name: String,
133 pub mount: String,
134}
135
136#[derive(
137 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
138)]
139pub struct AppScheduledTask {
140 pub name: String,
141 }
144
145impl AppConfigV1 {
146 pub const KIND: &'static str = "wasmer.io/App.v0";
147 pub const CANONICAL_FILE_NAME: &'static str = "app.yaml";
148
149 pub fn to_yaml_value(self) -> Result<serde_yaml::Value, serde_yaml::Error> {
150 let obj = match serde_yaml::to_value(self)? {
153 serde_yaml::Value::Mapping(m) => m,
154 _ => unreachable!(),
155 };
156 let mut m = serde_yaml::Mapping::new();
157 m.insert("kind".into(), Self::KIND.into());
158 for (k, v) in obj.into_iter() {
159 m.insert(k, v);
160 }
161 Ok(m.into())
162 }
163
164 pub fn to_yaml(self) -> Result<String, serde_yaml::Error> {
165 serde_yaml::to_string(&self.to_yaml_value()?)
166 }
167
168 pub fn parse_yaml(value: &str) -> Result<Self, anyhow::Error> {
169 let raw = serde_yaml::from_str::<serde_yaml::Value>(value).context("invalid yaml")?;
170 let kind = raw
171 .get("kind")
172 .context("invalid app config: no 'kind' field found")?
173 .as_str()
174 .context("invalid app config: 'kind' field is not a string")?;
175 match kind {
176 Self::KIND => {}
177 other => {
178 bail!(
179 "invalid app config: unspported kind '{}', expected {}",
180 other,
181 Self::KIND
182 );
183 }
184 }
185
186 let data = serde_yaml::from_value(raw).context("could not deserialize app config")?;
187 Ok(data)
188 }
189}
190
191#[derive(
194 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
195)]
196pub struct AppConfigCapabilityMapV1 {
197 #[serde(skip_serializing_if = "Option::is_none")]
199 pub memory: Option<AppConfigCapabilityMemoryV1>,
200
201 #[serde(skip_serializing_if = "Option::is_none")]
203 pub instaboot: Option<AppConfigCapabilityInstaBootV1>,
204
205 #[serde(flatten)]
210 pub other: IndexMap<String, serde_json::Value>,
211}
212
213#[derive(
219 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
220)]
221pub struct AppConfigCapabilityMemoryV1 {
222 #[schemars(with = "Option<String>")]
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub limit: Option<ByteSize>,
228}
229
230#[derive(
242 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
243)]
244pub struct AppConfigCapabilityInstaBootV1 {
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub requests: Vec<HttpRequest>,
252
253 #[serde(skip_serializing_if = "Option::is_none")]
260 pub max_age: Option<PrettyDuration>,
261}
262
263#[derive(
265 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
266)]
267pub struct Redirect {
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub force_https: Option<bool>,
271}
272
273#[cfg(test)]
274mod tests {
275 use pretty_assertions::assert_eq;
276
277 use super::*;
278
279 #[test]
280 fn test_app_config_v1_deser() {
281 let config = r#"
282kind: wasmer.io/App.v0
283name: test
284package: ns/name@0.1.0
285debug: true
286env:
287 e1: v1
288 E2: V2
289cli_args:
290 - arg1
291 - arg2
292locality:
293 regions:
294 - eu-rome
295redirect:
296 force_https: true
297scheduled_tasks:
298 - name: backup
299 schedule: 1day
300 max_retries: 3
301 timeout: 10m
302 invoke:
303 fetch:
304 url: /api/do-backup
305 headers:
306 h1: v1
307 success_status_codes: [200, 201]
308 "#;
309
310 let parsed = AppConfigV1::parse_yaml(config).unwrap();
311
312 assert_eq!(
313 parsed,
314 AppConfigV1 {
315 name: Some("test".to_string()),
316 app_id: None,
317 package: "ns/name@0.1.0".parse().unwrap(),
318 owner: None,
319 domains: None,
320 env: [
321 ("e1".to_string(), "v1".to_string()),
322 ("E2".to_string(), "V2".to_string())
323 ]
324 .into_iter()
325 .collect(),
326 volumes: None,
327 cli_args: Some(vec!["arg1".to_string(), "arg2".to_string()]),
328 capabilities: None,
329 scaling: None,
330 scheduled_tasks: Some(vec![AppScheduledTask {
331 name: "backup".to_string(),
332 }]),
333 health_checks: None,
334 extra: [(
335 "kind".to_string(),
336 serde_json::Value::from("wasmer.io/App.v0")
337 ),]
338 .into_iter()
339 .collect(),
340 debug: Some(true),
341 redirect: Some(Redirect {
342 force_https: Some(true)
343 }),
344 locality: Some(Locality {
345 regions: vec!["eu-rome".to_string()]
346 }),
347 jobs: None,
348 }
349 );
350 }
351
352 #[test]
353 fn test_app_config_v1_volumes() {
354 let config = r#"
355kind: wasmer.io/App.v0
356name: test
357package: ns/name@0.1.0
358volumes:
359 - name: vol1
360 mount: /vol1
361 - name: vol2
362 mount: /vol2
363
364"#;
365
366 let parsed = AppConfigV1::parse_yaml(config).unwrap();
367 let expected_volumes = vec![
368 AppVolume {
369 name: "vol1".to_string(),
370 mount: "/vol1".to_string(),
371 },
372 AppVolume {
373 name: "vol2".to_string(),
374 mount: "/vol2".to_string(),
375 },
376 ];
377 if let Some(actual_volumes) = parsed.volumes {
378 assert_eq!(actual_volumes, expected_volumes);
379 } else {
380 panic!("Parsed volumes are None, expected Some({expected_volumes:?})");
381 }
382 }
383}