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