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