Skip to main content

spin_manifest/
compat.rs

1//! Compatibility for old manifest versions.
2
3mod allowed_http_hosts;
4
5use crate::{
6    error::Error,
7    schema::{v1, v2},
8};
9use allowed_http_hosts::{parse_allowed_http_hosts, AllowedHttpHosts};
10
11/// Converts a V1 app manifest to V2.
12pub fn v1_to_v2_app(manifest: v1::AppManifestV1) -> Result<v2::AppManifest, Error> {
13    let trigger_type = manifest.trigger.trigger_type.clone();
14    let trigger_global_configs = [(trigger_type.clone(), manifest.trigger.config)]
15        .into_iter()
16        .collect();
17
18    let application = v2::AppDetails {
19        name: manifest.name,
20        version: manifest.version,
21        description: manifest.description,
22        authors: manifest.authors,
23        targets: Default::default(),
24        trigger_global_configs,
25        tool: Default::default(),
26    };
27
28    let app_variables = manifest
29        .variables
30        .into_iter()
31        .map(|(key, var)| Ok((id_from_string(key)?, var)))
32        .collect::<Result<_, Error>>()?;
33
34    let mut triggers = v2::Map::<String, Vec<v2::Trigger>>::default();
35    let mut components = v2::Map::default();
36    for component in manifest.components {
37        let component_id = component_id_from_string(component.id)?;
38
39        let variables = component
40            .config
41            .into_iter()
42            .map(|(key, var)| Ok((id_from_string(key)?, var)))
43            .collect::<Result<_, Error>>()?;
44
45        let allowed_http = convert_allowed_http_to_allowed_hosts(
46            &component.allowed_http_hosts,
47            component.allowed_outbound_hosts.is_none(),
48        )
49        .map_err(Error::ValidationError)?;
50        let allowed_outbound_hosts = match component.allowed_outbound_hosts {
51            Some(mut hs) => {
52                hs.extend(allowed_http);
53                hs
54            }
55            None => allowed_http,
56        };
57        components.insert(
58            component_id.clone(),
59            #[allow(deprecated)]
60            v2::Component {
61                source: component.source,
62                description: component.description,
63                variables,
64                environment: component.environment,
65                files: component.files,
66                exclude_files: component.exclude_files,
67                key_value_stores: component.key_value_stores,
68                sqlite_databases: component.sqlite_databases,
69                ai_models: component.ai_models,
70                targets: Default::default(),
71                build: component.build,
72                tool: Default::default(),
73                allowed_outbound_hosts,
74                allowed_http_hosts: Vec::new(),
75                dependencies_inherit_configuration: false,
76                dependencies: Default::default(),
77                profile: Default::default(),
78            },
79        );
80        triggers
81            .entry(trigger_type.clone())
82            .or_default()
83            .push(v2::Trigger {
84                id: format!("trigger-{component_id}"),
85                component: Some(v2::ComponentSpec::Reference(component_id)),
86                components: Default::default(),
87                config: component.trigger,
88            });
89    }
90    Ok(v2::AppManifest {
91        spin_manifest_version: Default::default(),
92        application,
93        variables: app_variables,
94        triggers,
95        components,
96    })
97}
98
99/// Converts the old `allowed_http_hosts` field to the new `allowed_outbound_hosts` field.
100///
101/// If `allow_database_access` is `true`, the function will also allow access to all redis,
102/// mysql, and postgres databases as this was the default before `allowed_outbound_hosts` was introduced.
103pub fn convert_allowed_http_to_allowed_hosts(
104    allowed_http_hosts: &[impl AsRef<str>],
105    allow_database_access: bool,
106) -> anyhow::Result<Vec<String>> {
107    let http_hosts = parse_allowed_http_hosts(allowed_http_hosts)?;
108    let mut outbound_hosts = if allow_database_access {
109        vec![
110            "redis://*:*".into(),
111            "mysql://*:*".into(),
112            "postgres://*:*".into(),
113        ]
114    } else {
115        Vec::new()
116    };
117    match http_hosts {
118        AllowedHttpHosts::AllowAll => outbound_hosts.extend([
119            "http://*:*".into(),
120            "https://*:*".into(),
121            "http://self".into(),
122        ]),
123        AllowedHttpHosts::AllowSpecific(specific) => {
124            outbound_hosts.extend(specific.into_iter().flat_map(|s| {
125                if s.domain == "self" {
126                    vec!["http://self".into()]
127                } else {
128                    let port = s.port.map(|p| format!(":{p}")).unwrap_or_default();
129                    vec![
130                        format!("http://{}{}", s.domain, port),
131                        format!("https://{}{}", s.domain, port),
132                    ]
133                }
134            }))
135        }
136    };
137    Ok(outbound_hosts)
138}
139
140fn component_id_from_string(id: String) -> Result<v2::KebabId, Error> {
141    // If it's already valid, do nothing
142    if let Ok(id) = id.clone().try_into() {
143        return Ok(id);
144    }
145    // Fix two likely problems; under_scores and mixedCase
146    let id = id.replace('_', "-").to_lowercase();
147    id.clone()
148        .try_into()
149        .map_err(|err: String| Error::InvalidID { id, reason: err })
150}
151
152fn id_from_string<const DELIM: char, const LOWER: bool>(
153    id: String,
154) -> Result<spin_serde::id::Id<DELIM, LOWER>, Error> {
155    id.clone()
156        .try_into()
157        .map_err(|err: String| Error::InvalidID { id, reason: err })
158}