edge_schema/schema/
app_config.rs

1//! User-facing app.yaml file config: [`AppConfigV1`].
2
3use std::collections::HashMap;
4
5use anyhow::{bail, Context};
6use bytesize::ByteSize;
7
8use super::{CronJobSpecV1, StringWebcIdent};
9
10/// User-facing app.yaml config file for apps.
11///
12/// NOTE: only used by the backend, Edge itself does not use this format, and
13/// uses [`super::AppVersionV1Spec`] instead.
14#[derive(
15    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
16)]
17pub struct AppConfigV1 {
18    /// Name of the app.
19    pub name: String,
20
21    /// App id assigned by the backend.
22    ///
23    /// This will get populated once the app has been deployed.
24    ///
25    /// This id is also used to map to the existing app during deployments.
26    // #[serde(skip_serializing_if = "Option::is_none")]
27    // pub description: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub app_id: Option<String>,
30
31    /// Owner of the app.
32    ///
33    /// This is either a username or a namespace.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub owner: Option<String>,
36
37    /// The package to execute.
38    pub package: StringWebcIdent,
39
40    /// Domains for the app.
41    ///
42    /// This can include both provider-supplied
43    /// alias domains and custom domains.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub domains: Option<Vec<String>>,
46
47    /// Environment variables.
48    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
49    pub env: HashMap<String, String>,
50
51    // CLI arguments passed to the runner.
52    /// Only applicable for runners that accept CLI arguments.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub cli_args: Option<Vec<String>>,
55
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub capabilities: Option<AppConfigCapabilityMapV1>,
58
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub scheduled_tasks: Option<Vec<AppScheduledTask>>,
61
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub volumes: Option<Vec<AppVolume>>,
64
65    /// Enable debug mode, which will show detailed error pages in the web gateway.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub debug: Option<bool>,
68
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub scaling: Option<AppScalingConfigV1>,
71
72    /// Capture extra fields for forwards compatibility.
73    #[serde(flatten)]
74    pub extra: HashMap<String, serde_json::Value>,
75}
76
77#[derive(
78    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
79)]
80pub struct AppScalingConfigV1 {
81    /// Maximum amount of concurrent requests allowed per instance.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub concurrent_requests: Option<u64>,
84
85    /// Maximum number of instances that can be spawned on each Edge node.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub max_instances_per_node: Option<u64>,
88}
89
90#[derive(
91    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
92)]
93pub struct AppVolume {
94    pub name: String,
95    pub mounts: Vec<AppVolumeMount>,
96}
97
98#[derive(
99    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
100)]
101pub struct AppVolumeMount {
102    /// Path to mount the volume at.
103    pub mount_path: String,
104    /// Sub-path within the volume to mount.
105    pub sub_path: Option<String>,
106}
107
108#[derive(
109    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
110)]
111pub struct AppScheduledTask {
112    pub name: String,
113    #[serde(flatten)]
114    pub spec: CronJobSpecV1,
115}
116
117impl AppConfigV1 {
118    pub const KIND: &'static str = "wasmer.io/App.v0";
119    pub const CANONICAL_FILE_NAME: &'static str = "app.yaml";
120
121    pub fn to_yaml_value(self) -> Result<serde_yaml::Value, serde_yaml::Error> {
122        // Need to do an annoying type dance to both insert the kind field
123        // and also insert kind at the top.
124        let obj = match serde_yaml::to_value(self)? {
125            serde_yaml::Value::Mapping(m) => m,
126            _ => unreachable!(),
127        };
128        let mut m = serde_yaml::Mapping::new();
129        m.insert("kind".into(), Self::KIND.into());
130        for (k, v) in obj.into_iter() {
131            m.insert(k, v);
132        }
133        Ok(m.into())
134    }
135
136    pub fn to_yaml(self) -> Result<String, serde_yaml::Error> {
137        serde_yaml::to_string(&self.to_yaml_value()?)
138    }
139
140    pub fn parse_yaml(value: &str) -> Result<Self, anyhow::Error> {
141        let raw = serde_yaml::from_str::<serde_yaml::Value>(value).context("invalid yaml")?;
142        let kind = raw
143            .get("kind")
144            .context("invalid app config: no 'kind' field found")?
145            .as_str()
146            .context("invalid app config: 'kind' field is not a string")?;
147        match kind {
148            Self::KIND => {}
149            other => {
150                bail!(
151                    "invalid app config: unspported kind '{}', expected {}",
152                    other,
153                    Self::KIND
154                );
155            }
156        }
157
158        let data = serde_yaml::from_value(raw).context("could not deserialize app config")?;
159        Ok(data)
160    }
161}
162
163/// Restricted version of [`super::CapabilityMapV1`], with only a select subset
164/// of settings.
165#[derive(
166    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
167)]
168pub struct AppConfigCapabilityMapV1 {
169    /// Instance memory settings.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub memory: Option<AppConfigCapabilityMemoryV1>,
172}
173
174/// Memory capability settings.
175///
176/// NOTE: this is kept separate from the [`super::CapabilityMemoryV1`] struct
177/// to have separation between the high-level app.yaml and the more internal
178/// App entity.
179#[derive(
180    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
181)]
182pub struct AppConfigCapabilityMemoryV1 {
183    /// Memory limit for an instance.
184    ///
185    /// Format: [digit][unit], where unit is Mb/Gb/MiB/GiB,...
186    #[schemars(with = "Option<String>")]
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub limit: Option<ByteSize>,
189}
190
191#[cfg(test)]
192mod tests {
193    use pretty_assertions::assert_eq;
194
195    use super::*;
196
197    #[test]
198    fn test_app_config_v1_deser() {
199        let config = r#"
200kind: wasmer.io/App.v0
201name: test
202package: ns/name@0.1.0
203debug: true
204env:
205  e1: v1
206  E2: V2
207cli_args:
208  - arg1
209  - arg2
210scheduled_tasks:
211  - name: backup
212    schedule: 1day
213    max_retries: 3
214    timeout: 10m
215    invoke:
216      fetch:
217        url: /api/do-backup
218        headers:
219          h1: v1
220        success_status_codes: [200, 201]
221        "#;
222
223        let parsed = AppConfigV1::parse_yaml(config).unwrap();
224
225        assert_eq!(
226            parsed,
227            AppConfigV1 {
228                name: "test".to_string(),
229                app_id: None,
230                package: "ns/name@0.1.0".parse().unwrap(),
231                owner: None,
232                domains: None,
233                env: [
234                    ("e1".to_string(), "v1".to_string()),
235                    ("E2".to_string(), "V2".to_string())
236                ]
237                .into_iter()
238                .collect(),
239                volumes: None,
240                cli_args: Some(vec!["arg1".to_string(), "arg2".to_string()]),
241                capabilities: None,
242                scaling: None,
243                scheduled_tasks: Some(vec![AppScheduledTask {
244                    name: "backup".to_string(),
245                    spec: CronJobSpecV1 {
246                        schedule: "1day".to_string(),
247                        max_schedule_drift: None,
248                        job: crate::schema::JobDefinition {
249                            max_retries: Some(3),
250                            timeout: Some(std::time::Duration::from_secs(10 * 60).into()),
251                            invoke: crate::schema::JobInvoke::Fetch(
252                                crate::schema::JobInvokeFetch {
253                                    url: "/api/do-backup".parse().unwrap(),
254                                    headers: Some(
255                                        [("h1".to_string(), "v1".to_string())]
256                                            .into_iter()
257                                            .collect()
258                                    ),
259                                    success_status_codes: Some(vec![200, 201]),
260                                    method: None,
261                                }
262                            )
263                        },
264                    }
265                }]),
266                extra: [(
267                    "kind".to_string(),
268                    serde_json::Value::from("wasmer.io/App.v0")
269                ),]
270                .into_iter()
271                .collect(),
272                debug: Some(true),
273            }
274        );
275    }
276}