1mod healthcheck;
4mod http;
5mod job;
6mod pretty_duration;
7mod snapshot_trigger;
8mod ssh;
9
10pub use self::{healthcheck::*, http::*, job::*, pretty_duration::*, snapshot_trigger::*, ssh::*};
11
12use anyhow::{bail, Context};
13use bytesize::ByteSize;
14use indexmap::IndexMap;
15
16use crate::package::PackageSource;
17
18#[allow(clippy::declare_interior_mutable_const)]
24pub const HEADER_APP_VERSION_ID: &str = "x-edge-app-version-id";
25
26#[derive(
31 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
32)]
33pub struct AppConfigV1 {
34 pub name: Option<String>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
45 pub app_id: Option<String>,
46
47 #[serde(skip_serializing_if = "Option::is_none")]
51 pub owner: Option<String>,
52
53 pub package: PackageSource,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
61 pub domains: Option<Vec<String>>,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub locality: Option<Locality>,
66
67 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
69 pub env: IndexMap<String, String>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
74 pub cli_args: Option<Vec<String>>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub capabilities: Option<AppConfigCapabilityMapV1>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub scheduled_tasks: Option<Vec<AppScheduledTask>>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub volumes: Option<Vec<AppVolume>>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub health_checks: Option<Vec<HealthCheckV1>>,
87
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub debug: Option<bool>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub scaling: Option<AppScalingConfigV1>,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub redirect: Option<Redirect>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub jobs: Option<Vec<Job>>,
100
101 #[serde(flatten)]
103 pub extra: IndexMap<String, serde_json::Value>,
104}
105
106#[derive(
107 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
108)]
109pub struct Locality {
110 pub regions: Vec<String>,
111}
112
113#[derive(
114 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
115)]
116pub struct AppScalingConfigV1 {
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub mode: Option<AppScalingModeV1>,
119}
120
121#[derive(
122 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
123)]
124pub enum AppScalingModeV1 {
125 #[serde(rename = "single_concurrency")]
126 SingleConcurrency,
127}
128
129#[derive(
130 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
131)]
132pub struct AppVolume {
133 pub name: String,
134 pub mount: String,
135}
136
137#[derive(
138 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
139)]
140pub struct AppScheduledTask {
141 pub name: String,
142 }
145
146impl AppConfigV1 {
147 pub const KIND: &'static str = "wasmer.io/App.v0";
148 pub const CANONICAL_FILE_NAME: &'static str = "app.yaml";
149
150 pub fn to_yaml_value(self) -> Result<serde_yaml::Value, serde_yaml::Error> {
151 let obj = match serde_yaml::to_value(self)? {
154 serde_yaml::Value::Mapping(m) => m,
155 _ => unreachable!(),
156 };
157 let mut m = serde_yaml::Mapping::new();
158 m.insert("kind".into(), Self::KIND.into());
159 for (k, v) in obj.into_iter() {
160 m.insert(k, v);
161 }
162 Ok(m.into())
163 }
164
165 pub fn to_yaml(self) -> Result<String, serde_yaml::Error> {
166 serde_yaml::to_string(&self.to_yaml_value()?)
167 }
168
169 pub fn parse_yaml(value: &str) -> Result<Self, anyhow::Error> {
170 let raw = serde_yaml::from_str::<serde_yaml::Value>(value).context("invalid yaml")?;
171 let kind = raw
172 .get("kind")
173 .context("invalid app config: no 'kind' field found")?
174 .as_str()
175 .context("invalid app config: 'kind' field is not a string")?;
176 match kind {
177 Self::KIND => {}
178 other => {
179 bail!(
180 "invalid app config: unspported kind '{}', expected {}",
181 other,
182 Self::KIND
183 );
184 }
185 }
186
187 let data = serde_yaml::from_value(raw).context("could not deserialize app config")?;
188 Ok(data)
189 }
190}
191
192#[derive(
195 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
196)]
197pub struct AppConfigCapabilityMapV1 {
198 #[serde(skip_serializing_if = "Option::is_none")]
200 pub memory: Option<AppConfigCapabilityMemoryV1>,
201
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub runtime: Option<AppConfigCapabilityRuntimeV1>,
205
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub instaboot: Option<AppConfigCapabilityInstaBootV1>,
209
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub ssh: Option<CapabilitySshServerV1>,
212
213 #[serde(flatten)]
218 pub other: IndexMap<String, serde_json::Value>,
219}
220
221#[derive(
227 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
228)]
229pub struct AppConfigCapabilityMemoryV1 {
230 #[schemars(with = "Option<String>")]
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub limit: Option<ByteSize>,
236}
237
238#[derive(
240 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
241)]
242pub struct AppConfigCapabilityRuntimeV1 {
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub engine: Option<String>,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 pub async_threads: Option<bool>,
249}
250
251#[derive(
263 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
264)]
265pub struct AppConfigCapabilityInstaBootV1 {
266 #[serde(default)]
268 pub mode: Option<InstabootSnapshotModeV1>,
269
270 #[serde(default, skip_serializing_if = "Vec::is_empty")]
276 pub requests: Vec<HttpRequest>,
277
278 #[serde(skip_serializing_if = "Option::is_none")]
285 pub max_age: Option<PrettyDuration>,
286}
287
288#[derive(
290 serde::Serialize,
291 serde::Deserialize,
292 PartialEq,
293 Eq,
294 Hash,
295 Clone,
296 Debug,
297 schemars::JsonSchema,
298 Default,
299)]
300#[serde(rename_all = "snake_case")]
301pub enum InstabootSnapshotModeV1 {
302 #[default]
306 Bootstrap,
307
308 Triggers(Vec<SnapshotTrigger>),
313}
314
315#[derive(
317 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
318)]
319pub struct Redirect {
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub force_https: Option<bool>,
323}
324
325#[cfg(test)]
326mod tests {
327 use pretty_assertions::assert_eq;
328
329 use super::*;
330
331 #[test]
332 fn test_app_config_v1_deser() {
333 let config = r#"
334kind: wasmer.io/App.v0
335name: test
336package: ns/name@0.1.0
337debug: true
338env:
339 e1: v1
340 E2: V2
341cli_args:
342 - arg1
343 - arg2
344locality:
345 regions:
346 - eu-rome
347redirect:
348 force_https: true
349scheduled_tasks:
350 - name: backup
351 schedule: 1day
352 max_retries: 3
353 timeout: 10m
354 invoke:
355 fetch:
356 url: /api/do-backup
357 headers:
358 h1: v1
359 success_status_codes: [200, 201]
360 "#;
361
362 let parsed = AppConfigV1::parse_yaml(config).unwrap();
363
364 assert_eq!(
365 parsed,
366 AppConfigV1 {
367 name: Some("test".to_string()),
368 app_id: None,
369 package: "ns/name@0.1.0".parse().unwrap(),
370 owner: None,
371 domains: None,
372 env: [
373 ("e1".to_string(), "v1".to_string()),
374 ("E2".to_string(), "V2".to_string())
375 ]
376 .into_iter()
377 .collect(),
378 volumes: None,
379 cli_args: Some(vec!["arg1".to_string(), "arg2".to_string()]),
380 capabilities: None,
381 scaling: None,
382 scheduled_tasks: Some(vec![AppScheduledTask {
383 name: "backup".to_string(),
384 }]),
385 health_checks: None,
386 extra: [(
387 "kind".to_string(),
388 serde_json::Value::from("wasmer.io/App.v0")
389 ),]
390 .into_iter()
391 .collect(),
392 debug: Some(true),
393 redirect: Some(Redirect {
394 force_https: Some(true)
395 }),
396 locality: Some(Locality {
397 regions: vec!["eu-rome".to_string()]
398 }),
399 jobs: None,
400 }
401 );
402 }
403
404 #[test]
405 fn test_app_config_v1_volumes() {
406 let config = r#"
407kind: wasmer.io/App.v0
408name: test
409package: ns/name@0.1.0
410volumes:
411 - name: vol1
412 mount: /vol1
413 - name: vol2
414 mount: /vol2
415
416"#;
417
418 let parsed = AppConfigV1::parse_yaml(config).unwrap();
419 let expected_volumes = vec![
420 AppVolume {
421 name: "vol1".to_string(),
422 mount: "/vol1".to_string(),
423 },
424 AppVolume {
425 name: "vol2".to_string(),
426 mount: "/vol2".to_string(),
427 },
428 ];
429 if let Some(actual_volumes) = parsed.volumes {
430 assert_eq!(actual_volumes, expected_volumes);
431 } else {
432 panic!("Parsed volumes are None, expected Some({expected_volumes:?})");
433 }
434 }
435}