1mod healthcheck;
4mod http;
5mod job;
6mod pretty_duration;
7mod snapshot_trigger;
8
9pub use self::{healthcheck::*, http::*, job::*, pretty_duration::*, snapshot_trigger::*};
10
11use anyhow::{bail, Context};
12use bytesize::ByteSize;
13use indexmap::IndexMap;
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 runtime: Option<AppConfigCapabilityRuntimeV1>,
204
205 #[serde(skip_serializing_if = "Option::is_none")]
207 pub instaboot: Option<AppConfigCapabilityInstaBootV1>,
208
209 #[serde(flatten)]
214 pub other: IndexMap<String, serde_json::Value>,
215}
216
217#[derive(
223 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
224)]
225pub struct AppConfigCapabilityMemoryV1 {
226 #[schemars(with = "Option<String>")]
230 #[serde(skip_serializing_if = "Option::is_none")]
231 pub limit: Option<ByteSize>,
232}
233
234#[derive(
236 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
237)]
238pub struct AppConfigCapabilityRuntimeV1 {
239 #[serde(skip_serializing_if = "Option::is_none")]
241 pub engine: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub async_threads: Option<bool>,
245}
246
247#[derive(
259 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
260)]
261pub struct AppConfigCapabilityInstaBootV1 {
262 #[serde(default)]
264 pub mode: Option<InstabootSnapshotModeV1>,
265
266 #[serde(default, skip_serializing_if = "Vec::is_empty")]
272 pub requests: Vec<HttpRequest>,
273
274 #[serde(skip_serializing_if = "Option::is_none")]
281 pub max_age: Option<PrettyDuration>,
282}
283
284#[derive(
286 serde::Serialize,
287 serde::Deserialize,
288 PartialEq,
289 Eq,
290 Hash,
291 Clone,
292 Debug,
293 schemars::JsonSchema,
294 Default,
295)]
296#[serde(rename_all = "snake_case")]
297pub enum InstabootSnapshotModeV1 {
298 #[default]
302 Bootstrap,
303
304 Triggers(Vec<SnapshotTrigger>),
309}
310
311#[derive(
313 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
314)]
315pub struct Redirect {
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub force_https: Option<bool>,
319}
320
321#[cfg(test)]
322mod tests {
323 use pretty_assertions::assert_eq;
324
325 use super::*;
326
327 #[test]
328 fn test_app_config_v1_deser() {
329 let config = r#"
330kind: wasmer.io/App.v0
331name: test
332package: ns/name@0.1.0
333debug: true
334env:
335 e1: v1
336 E2: V2
337cli_args:
338 - arg1
339 - arg2
340locality:
341 regions:
342 - eu-rome
343redirect:
344 force_https: true
345scheduled_tasks:
346 - name: backup
347 schedule: 1day
348 max_retries: 3
349 timeout: 10m
350 invoke:
351 fetch:
352 url: /api/do-backup
353 headers:
354 h1: v1
355 success_status_codes: [200, 201]
356 "#;
357
358 let parsed = AppConfigV1::parse_yaml(config).unwrap();
359
360 assert_eq!(
361 parsed,
362 AppConfigV1 {
363 name: Some("test".to_string()),
364 app_id: None,
365 package: "ns/name@0.1.0".parse().unwrap(),
366 owner: None,
367 domains: None,
368 env: [
369 ("e1".to_string(), "v1".to_string()),
370 ("E2".to_string(), "V2".to_string())
371 ]
372 .into_iter()
373 .collect(),
374 volumes: None,
375 cli_args: Some(vec!["arg1".to_string(), "arg2".to_string()]),
376 capabilities: None,
377 scaling: None,
378 scheduled_tasks: Some(vec![AppScheduledTask {
379 name: "backup".to_string(),
380 }]),
381 health_checks: None,
382 extra: [(
383 "kind".to_string(),
384 serde_json::Value::from("wasmer.io/App.v0")
385 ),]
386 .into_iter()
387 .collect(),
388 debug: Some(true),
389 redirect: Some(Redirect {
390 force_https: Some(true)
391 }),
392 locality: Some(Locality {
393 regions: vec!["eu-rome".to_string()]
394 }),
395 jobs: None,
396 }
397 );
398 }
399
400 #[test]
401 fn test_app_config_v1_volumes() {
402 let config = r#"
403kind: wasmer.io/App.v0
404name: test
405package: ns/name@0.1.0
406volumes:
407 - name: vol1
408 mount: /vol1
409 - name: vol2
410 mount: /vol2
411
412"#;
413
414 let parsed = AppConfigV1::parse_yaml(config).unwrap();
415 let expected_volumes = vec![
416 AppVolume {
417 name: "vol1".to_string(),
418 mount: "/vol1".to_string(),
419 },
420 AppVolume {
421 name: "vol2".to_string(),
422 mount: "/vol2".to_string(),
423 },
424 ];
425 if let Some(actual_volumes) = parsed.volumes {
426 assert_eq!(actual_volumes, expected_volumes);
427 } else {
428 panic!("Parsed volumes are None, expected Some({expected_volumes:?})");
429 }
430 }
431}