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