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