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::{Context, bail};
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: unsupported kind '{other}', expected {}",
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(skip_serializing_if = "Option::is_none")]
210 pub ssh: Option<CapabilitySshServerV1>,
211
212 #[serde(skip_serializing_if = "Option::is_none")]
214 pub cdn_cache: Option<AppConfigCapabilityCdnCacheV1>,
215
216 #[serde(flatten)]
221 pub other: IndexMap<String, serde_json::Value>,
222}
223
224#[derive(
229 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
230)]
231pub struct AppConfigCapabilityMemoryV1 {
232 #[schemars(with = "Option<String>")]
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub limit: Option<ByteSize>,
238}
239
240#[derive(
242 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
243)]
244pub struct AppConfigCapabilityRuntimeV1 {
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub engine: Option<String>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub async_threads: Option<bool>,
251}
252
253#[derive(
265 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
266)]
267pub struct AppConfigCapabilityInstaBootV1 {
268 #[serde(default)]
270 pub mode: Option<InstabootSnapshotModeV1>,
271
272 #[serde(default, skip_serializing_if = "Vec::is_empty")]
278 pub requests: Vec<HttpRequest>,
279
280 #[serde(skip_serializing_if = "Option::is_none")]
287 pub max_age: Option<PrettyDuration>,
288}
289
290#[derive(
292 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
293)]
294pub struct AppConfigCapabilityCdnCacheV1 {
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub enabled: Option<bool>,
298
299 #[serde(flatten)]
302 pub other: IndexMap<String, serde_json::Value>,
303}
304
305#[derive(
307 serde::Serialize,
308 serde::Deserialize,
309 PartialEq,
310 Eq,
311 Hash,
312 Clone,
313 Debug,
314 schemars::JsonSchema,
315 Default,
316)]
317#[serde(rename_all = "snake_case")]
318pub enum InstabootSnapshotModeV1 {
319 #[default]
323 Bootstrap,
324
325 Triggers(Vec<SnapshotTrigger>),
330}
331
332#[derive(
334 serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
335)]
336pub struct Redirect {
337 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub force_https: Option<bool>,
340}
341
342#[cfg(test)]
343mod tests {
344 use pretty_assertions::assert_eq;
345
346 use super::*;
347
348 #[test]
349 fn test_app_config_v1_deser() {
350 let config = r#"
351kind: wasmer.io/App.v0
352name: test
353package: ns/name@0.1.0
354debug: true
355env:
356 e1: v1
357 E2: V2
358cli_args:
359 - arg1
360 - arg2
361locality:
362 regions:
363 - eu-rome
364redirect:
365 force_https: true
366scheduled_tasks:
367 - name: backup
368 schedule: 1day
369 max_retries: 3
370 timeout: 10m
371 invoke:
372 fetch:
373 url: /api/do-backup
374 headers:
375 h1: v1
376 success_status_codes: [200, 201]
377 "#;
378
379 let parsed = AppConfigV1::parse_yaml(config).unwrap();
380
381 assert_eq!(
382 parsed,
383 AppConfigV1 {
384 name: Some("test".to_string()),
385 app_id: None,
386 package: "ns/name@0.1.0".parse().unwrap(),
387 owner: None,
388 domains: None,
389 env: [
390 ("e1".to_string(), "v1".to_string()),
391 ("E2".to_string(), "V2".to_string())
392 ]
393 .into_iter()
394 .collect(),
395 volumes: None,
396 cli_args: Some(vec!["arg1".to_string(), "arg2".to_string()]),
397 capabilities: None,
398 scaling: None,
399 scheduled_tasks: Some(vec![AppScheduledTask {
400 name: "backup".to_string(),
401 }]),
402 health_checks: None,
403 extra: [(
404 "kind".to_string(),
405 serde_json::Value::from("wasmer.io/App.v0")
406 ),]
407 .into_iter()
408 .collect(),
409 debug: Some(true),
410 redirect: Some(Redirect {
411 force_https: Some(true)
412 }),
413 locality: Some(Locality {
414 regions: vec!["eu-rome".to_string()]
415 }),
416 jobs: None,
417 }
418 );
419 }
420
421 #[test]
422 fn test_app_config_v1_volumes() {
423 let config = r#"
424kind: wasmer.io/App.v0
425name: test
426package: ns/name@0.1.0
427volumes:
428 - name: vol1
429 mount: /vol1
430 - name: vol2
431 mount: /vol2
432
433"#;
434
435 let parsed = AppConfigV1::parse_yaml(config).unwrap();
436 let expected_volumes = vec![
437 AppVolume {
438 name: "vol1".to_string(),
439 mount: "/vol1".to_string(),
440 },
441 AppVolume {
442 name: "vol2".to_string(),
443 mount: "/vol2".to_string(),
444 },
445 ];
446 if let Some(actual_volumes) = parsed.volumes {
447 assert_eq!(actual_volumes, expected_volumes);
448 } else {
449 panic!("Parsed volumes are None, expected Some({expected_volumes:?})");
450 }
451 }
452}