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