wasmer_config/app/
mod.rs

1//! User-facing app.yaml file config: [`AppConfigV1`].
2
3mod healthcheck;
4mod http;
5mod job;
6mod pretty_duration;
7mod snapshot_trigger;
8
9pub use self::{healthcheck::*, http::*, job::*, pretty_duration::*, snapshot_trigger::*};
10
11use anyhow::{bail, Context};
12use bytesize::ByteSize;
13use indexmap::IndexMap;
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    /// Runtime settings.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub runtime: Option<AppConfigCapabilityRuntimeV1>,
204
205    /// Enables app bootstrapping with startup snapshots.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub instaboot: Option<AppConfigCapabilityInstaBootV1>,
208
209    /// Additional unknown capabilities.
210    ///
211    /// This provides a small bit of forwards compatibility for newly added
212    /// capabilities.
213    #[serde(flatten)]
214    pub other: IndexMap<String, serde_json::Value>,
215}
216
217/// Memory capability settings.
218///
219/// NOTE: this is kept separate from the [`super::CapabilityMemoryV1`] struct
220/// to have separation between the high-level app.yaml and the more internal
221/// App entity.
222#[derive(
223    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
224)]
225pub struct AppConfigCapabilityMemoryV1 {
226    /// Memory limit for an instance.
227    ///
228    /// Format: [digit][unit], where unit is Mb/Gb/MiB/GiB,...
229    #[schemars(with = "Option<String>")]
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub limit: Option<ByteSize>,
232}
233
234/// Runtime capability settings.
235#[derive(
236    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
237)]
238pub struct AppConfigCapabilityRuntimeV1 {
239    /// Engine to use for an instance, e.g. wasmer_cranelift, wasmer_llvm, etc.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub engine: Option<String>,
242    /// Whether to enable asynchronous threads/deep sleeping.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub async_threads: Option<bool>,
245}
246
247/// Enables accelerated instance boot times with startup snapshots.
248///
249/// How it works:
250/// The Edge runtime will create a pre-initialized snapshot of apps that is
251/// ready to serve requests
252/// Your app will then restore from the generated snapshot, which has the
253/// potential to significantly speed up cold starts.
254///
255/// To drive the initialization, multiple http requests can be specified.
256/// All the specified requests will be sent to the app before the snapshot is
257/// created, allowing the app to pre-load files, pre initialize caches, ...
258#[derive(
259    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
260)]
261pub struct AppConfigCapabilityInstaBootV1 {
262    /// The method to use to generate the instaboot snapshot for the instance.
263    #[serde(default)]
264    pub mode: Option<InstabootSnapshotModeV1>,
265
266    /// HTTP requests to perform during startup snapshot creation.
267    /// Apps can perform all the appropriate warmup logic in these requests.
268    ///
269    /// NOTE: if no requests are configured, then a single HTTP
270    /// request to '/' will be performed instead.
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub requests: Vec<HttpRequest>,
273
274    /// Maximum age of snapshots.
275    ///
276    /// Format: 5m, 1h, 2d, ...
277    ///
278    /// After the specified time new snapshots will be created, and the old
279    /// ones discarded.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub max_age: Option<PrettyDuration>,
282}
283
284/// How will an instance be bootstrapped?
285#[derive(
286    serde::Serialize,
287    serde::Deserialize,
288    PartialEq,
289    Eq,
290    Hash,
291    Clone,
292    Debug,
293    schemars::JsonSchema,
294    Default,
295)]
296#[serde(rename_all = "snake_case")]
297pub enum InstabootSnapshotModeV1 {
298    /// Start the instance without any snapshot triggers. Once the requests are done,
299    /// use [`snapshot_and_stop`](wasmer_wasix::WasiProcess::snapshot_and_stop) to
300    /// capture a snapshot and shut the instance down.
301    #[default]
302    Bootstrap,
303
304    /// Explicitly enable the given snapshot triggers before starting the instance.
305    /// The instance's process will have its stop_running_after_checkpoint flag set,
306    /// so the first snapshot will cause the instance to shut down.
307    // FIXME: make this strongly typed
308    Triggers(Vec<SnapshotTrigger>),
309}
310
311/// App redirect configuration.
312#[derive(
313    serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
314)]
315pub struct Redirect {
316    /// Force https by redirecting http requests to https automatically.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub force_https: Option<bool>,
319}
320
321#[cfg(test)]
322mod tests {
323    use pretty_assertions::assert_eq;
324
325    use super::*;
326
327    #[test]
328    fn test_app_config_v1_deser() {
329        let config = r#"
330kind: wasmer.io/App.v0
331name: test
332package: ns/name@0.1.0
333debug: true
334env:
335  e1: v1
336  E2: V2
337cli_args:
338  - arg1
339  - arg2
340locality: 
341  regions: 
342    - eu-rome
343redirect:
344  force_https: true
345scheduled_tasks:
346  - name: backup
347    schedule: 1day
348    max_retries: 3
349    timeout: 10m
350    invoke:
351      fetch:
352        url: /api/do-backup
353        headers:
354          h1: v1
355        success_status_codes: [200, 201]
356        "#;
357
358        let parsed = AppConfigV1::parse_yaml(config).unwrap();
359
360        assert_eq!(
361            parsed,
362            AppConfigV1 {
363                name: Some("test".to_string()),
364                app_id: None,
365                package: "ns/name@0.1.0".parse().unwrap(),
366                owner: None,
367                domains: None,
368                env: [
369                    ("e1".to_string(), "v1".to_string()),
370                    ("E2".to_string(), "V2".to_string())
371                ]
372                .into_iter()
373                .collect(),
374                volumes: None,
375                cli_args: Some(vec!["arg1".to_string(), "arg2".to_string()]),
376                capabilities: None,
377                scaling: None,
378                scheduled_tasks: Some(vec![AppScheduledTask {
379                    name: "backup".to_string(),
380                }]),
381                health_checks: None,
382                extra: [(
383                    "kind".to_string(),
384                    serde_json::Value::from("wasmer.io/App.v0")
385                ),]
386                .into_iter()
387                .collect(),
388                debug: Some(true),
389                redirect: Some(Redirect {
390                    force_https: Some(true)
391                }),
392                locality: Some(Locality {
393                    regions: vec!["eu-rome".to_string()]
394                }),
395                jobs: None,
396            }
397        );
398    }
399
400    #[test]
401    fn test_app_config_v1_volumes() {
402        let config = r#"
403kind: wasmer.io/App.v0
404name: test
405package: ns/name@0.1.0
406volumes:
407  - name: vol1
408    mount: /vol1
409  - name: vol2
410    mount: /vol2
411
412"#;
413
414        let parsed = AppConfigV1::parse_yaml(config).unwrap();
415        let expected_volumes = vec![
416            AppVolume {
417                name: "vol1".to_string(),
418                mount: "/vol1".to_string(),
419            },
420            AppVolume {
421                name: "vol2".to_string(),
422                mount: "/vol2".to_string(),
423            },
424        ];
425        if let Some(actual_volumes) = parsed.volumes {
426            assert_eq!(actual_volumes, expected_volumes);
427        } else {
428            panic!("Parsed volumes are None, expected Some({expected_volumes:?})");
429        }
430    }
431}