wasmer_config/app/
mod.rs

1//! User-facing app.yaml file config: [`AppConfigV1`].
2
3mod healthcheck;
4mod http;
5mod job;
6mod pretty_duration;
7
8pub use self::{healthcheck::*, http::*, job::*};
9
10use anyhow::{bail, Context};
11use bytesize::ByteSize;
12use indexmap::IndexMap;
13use pretty_duration::PrettyDuration;
14
15use crate::package::PackageSource;
16
17/// Header added to Edge app HTTP responses.
18/// The value contains the app version ID that generated the response.
19///
20// This is used by the CLI to determine when a new version was successfully
21// released.
22#[allow(clippy::declare_interior_mutable_const)]
23pub const HEADER_APP_VERSION_ID: &str = "x-edge-app-version-id";
24
25/// User-facing app.yaml config file for apps.
26///
27/// NOTE: only used by the backend, Edge itself does not use this format, and
28/// uses [`super::AppVersionV1Spec`] instead.
29#[derive(
30    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
31)]
32pub struct AppConfigV1 {
33    /// Name of the app.
34    pub name: Option<String>,
35
36    /// App id assigned by the backend.
37    ///
38    /// This will get populated once the app has been deployed.
39    ///
40    /// This id is also used to map to the existing app during deployments.
41    // #[serde(skip_serializing_if = "Option::is_none")]
42    // pub description: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub app_id: Option<String>,
45
46    /// Owner of the app.
47    ///
48    /// This is either a username or a namespace.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub owner: Option<String>,
51
52    /// The package to execute.
53    pub package: PackageSource,
54
55    /// Domains for the app.
56    ///
57    /// This can include both provider-supplied
58    /// alias domains and custom domains.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub domains: Option<Vec<String>>,
61
62    /// Location-related configuration for the app.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub locality: Option<Locality>,
65
66    /// Environment variables.
67    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
68    pub env: IndexMap<String, String>,
69
70    // CLI arguments passed to the runner.
71    /// Only applicable for runners that accept CLI arguments.
72    #[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    /// Enable debug mode, which will show detailed error pages in the web gateway.
88    #[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    /// Capture extra fields for forwards compatibility.
101    #[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    // #[serde(flatten)]
142    // pub spec: CronJobSpecV1,
143}
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        // Need to do an annoying type dance to both insert the kind field
151        // and also insert kind at the top.
152        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/// Restricted version of [`super::CapabilityMapV1`], with only a select subset
192/// of settings.
193#[derive(
194    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
195)]
196pub struct AppConfigCapabilityMapV1 {
197    /// Instance memory settings.
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub memory: Option<AppConfigCapabilityMemoryV1>,
200
201    /// Enables app bootstrapping with startup snapshots.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub instaboot: Option<AppConfigCapabilityInstaBootV1>,
204
205    /// Additional unknown capabilities.
206    ///
207    /// This provides a small bit of forwards compatibility for newly added
208    /// capabilities.
209    #[serde(flatten)]
210    pub other: IndexMap<String, serde_json::Value>,
211}
212
213/// Memory capability settings.
214///
215/// NOTE: this is kept separate from the [`super::CapabilityMemoryV1`] struct
216/// to have separation between the high-level app.yaml and the more internal
217/// App entity.
218#[derive(
219    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
220)]
221pub struct AppConfigCapabilityMemoryV1 {
222    /// Memory limit for an instance.
223    ///
224    /// Format: [digit][unit], where unit is Mb/Gb/MiB/GiB,...
225    #[schemars(with = "Option<String>")]
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub limit: Option<ByteSize>,
228}
229
230/// Enables accelerated instance boot times with startup snapshots.
231///
232/// How it works:
233/// The Edge runtime will create a pre-initialized snapshot of apps that is
234/// ready to serve requests
235/// Your app will then restore from the generated snapshot, which has the
236/// potential to significantly speed up cold starts.
237///
238/// To drive the initialization, multiple http requests can be specified.
239/// All the specified requests will be sent to the app before the snapshot is
240/// created, allowing the app to pre-load files, pre initialize caches, ...
241#[derive(
242    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
243)]
244pub struct AppConfigCapabilityInstaBootV1 {
245    /// HTTP requests to perform during startup snapshot creation.
246    /// Apps can perform all the appropriate warmup logic in these requests.
247    ///
248    /// NOTE: if no requests are configured, then a single HTTP
249    /// request to '/' will be performed instead.
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub requests: Vec<HttpRequest>,
252
253    /// Maximum age of snapshots.
254    ///
255    /// Format: 5m, 1h, 2d, ...
256    ///
257    /// After the specified time new snapshots will be created, and the old
258    /// ones discarded.
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub max_age: Option<PrettyDuration>,
261}
262
263/// App redirect configuration.
264#[derive(
265    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
266)]
267pub struct Redirect {
268    /// Force https by redirecting http requests to https automatically.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub force_https: Option<bool>,
271}
272
273#[cfg(test)]
274mod tests {
275    use pretty_assertions::assert_eq;
276
277    use super::*;
278
279    #[test]
280    fn test_app_config_v1_deser() {
281        let config = r#"
282kind: wasmer.io/App.v0
283name: test
284package: ns/name@0.1.0
285debug: true
286env:
287  e1: v1
288  E2: V2
289cli_args:
290  - arg1
291  - arg2
292locality: 
293  regions: 
294    - eu-rome
295redirect:
296  force_https: true
297scheduled_tasks:
298  - name: backup
299    schedule: 1day
300    max_retries: 3
301    timeout: 10m
302    invoke:
303      fetch:
304        url: /api/do-backup
305        headers:
306          h1: v1
307        success_status_codes: [200, 201]
308        "#;
309
310        let parsed = AppConfigV1::parse_yaml(config).unwrap();
311
312        assert_eq!(
313            parsed,
314            AppConfigV1 {
315                name: Some("test".to_string()),
316                app_id: None,
317                package: "ns/name@0.1.0".parse().unwrap(),
318                owner: None,
319                domains: None,
320                env: [
321                    ("e1".to_string(), "v1".to_string()),
322                    ("E2".to_string(), "V2".to_string())
323                ]
324                .into_iter()
325                .collect(),
326                volumes: None,
327                cli_args: Some(vec!["arg1".to_string(), "arg2".to_string()]),
328                capabilities: None,
329                scaling: None,
330                scheduled_tasks: Some(vec![AppScheduledTask {
331                    name: "backup".to_string(),
332                }]),
333                health_checks: None,
334                extra: [(
335                    "kind".to_string(),
336                    serde_json::Value::from("wasmer.io/App.v0")
337                ),]
338                .into_iter()
339                .collect(),
340                debug: Some(true),
341                redirect: Some(Redirect {
342                    force_https: Some(true)
343                }),
344                locality: Some(Locality {
345                    regions: vec!["eu-rome".to_string()]
346                }),
347                jobs: None,
348            }
349        );
350    }
351
352    #[test]
353    fn test_app_config_v1_volumes() {
354        let config = r#"
355kind: wasmer.io/App.v0
356name: test
357package: ns/name@0.1.0
358volumes:
359  - name: vol1
360    mount: /vol1
361  - name: vol2
362    mount: /vol2
363
364"#;
365
366        let parsed = AppConfigV1::parse_yaml(config).unwrap();
367        let expected_volumes = vec![
368            AppVolume {
369                name: "vol1".to_string(),
370                mount: "/vol1".to_string(),
371            },
372            AppVolume {
373                name: "vol2".to_string(),
374                mount: "/vol2".to_string(),
375            },
376        ];
377        if let Some(actual_volumes) = parsed.volumes {
378            assert_eq!(actual_volumes, expected_volumes);
379        } else {
380            panic!("Parsed volumes are None, expected Some({expected_volumes:?})");
381        }
382    }
383}