Skip to main content

spin_environments/environment/
env_loader.rs

1//! Loading target environments, from a list of references through to
2//! a fully realised collection of WIT packages with their worlds and
3//! mappings.
4
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::{collections::HashMap, path::Path};
8
9use anyhow::{Context, anyhow};
10use futures::future::try_join_all;
11use spin_common::ui::quoted_path;
12use spin_manifest::schema::v2::TargetEnvironmentRef;
13
14use crate::environment::catalogue::Catalogue;
15
16use super::definition::{EnvironmentDefinition, WorldName, WorldRef};
17use super::lockfile::TargetEnvironmentLockfile;
18use super::{CandidateWorld, CandidateWorlds, TargetEnvironment, UnknownTrigger};
19
20const DEFAULT_PACKAGE_REGISTRY: &str = "spinframework.dev";
21
22pub struct LoadedEnvironmentDefinition {
23    pub name: String,
24    pub env_def: EnvironmentDefinition,
25    pub relative_path_base: Option<PathBuf>,
26}
27
28impl LoadedEnvironmentDefinition {
29    fn new(
30        name: impl Into<String>,
31        env_def: EnvironmentDefinition,
32        relative_path_base: Option<PathBuf>,
33    ) -> Self {
34        Self {
35            name: name.into(),
36            env_def,
37            relative_path_base,
38        }
39    }
40}
41
42/// Load all the listed environments from their registries or paths.
43/// Registry data will be cached, with a lockfile under `.spin` mapping
44/// environment IDs to digests (to allow cache lookup without needing
45/// to fetch the digest from the registry).
46pub async fn load_environments<'a>(
47    env_ids: &[&'a TargetEnvironmentRef],
48    cache_root: Option<std::path::PathBuf>,
49    app_dir: &std::path::Path,
50) -> anyhow::Result<HashMap<&'a TargetEnvironmentRef, Arc<TargetEnvironment>>> {
51    if env_ids.is_empty() {
52        return Ok(Default::default());
53    }
54
55    let cache = spin_loader::cache::Cache::new(cache_root)
56        .await
57        .context("Unable to create cache")?;
58    let lockfile_dir = app_dir.join(".spin");
59    let lockfile_path = lockfile_dir.join("target-environments.lock");
60
61    let orig_lockfile: TargetEnvironmentLockfile = tokio::fs::read_to_string(&lockfile_path)
62        .await
63        .ok()
64        .and_then(|s| serde_json::from_str(&s).ok())
65        .unwrap_or_default();
66    let lockfile = std::sync::Arc::new(tokio::sync::RwLock::new(orig_lockfile.clone()));
67
68    let envs = try_join_all(
69        env_ids
70            .iter()
71            .map(|e| load_environment(e, app_dir, &cache, &lockfile)),
72    )
73    .await?
74    .into_iter()
75    .map(|(k, v)| (k, Arc::new(v)))
76    .collect();
77
78    let final_lockfile = &*lockfile.read().await;
79    if *final_lockfile != orig_lockfile
80        && let Ok(lockfile_json) = serde_json::to_string_pretty(&final_lockfile)
81    {
82        _ = tokio::fs::create_dir_all(lockfile_dir).await;
83        _ = tokio::fs::write(&lockfile_path, lockfile_json).await; // failure to update lockfile is not an error
84    }
85
86    Ok(envs)
87}
88
89/// Loads the given `TargetEnvironment` from a registry or directory.
90async fn load_environment<'a>(
91    env_id: &'a TargetEnvironmentRef,
92    app_dir: &Path,
93    cache: &spin_loader::cache::Cache,
94    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
95) -> anyhow::Result<(&'a TargetEnvironmentRef, TargetEnvironment)> {
96    let loaded_env_def = load_environment_def(env_id, app_dir).await?;
97    let env = load_environment_from_env_def(loaded_env_def, cache, lockfile).await?;
98    Ok((env_id, env))
99}
100
101pub async fn load_environment_def(
102    env_id: &TargetEnvironmentRef,
103    app_dir: &Path,
104) -> Result<LoadedEnvironmentDefinition, anyhow::Error> {
105    match env_id {
106        TargetEnvironmentRef::Catalogue(id) => load_environment_def_from_catalogue(id).await,
107        TargetEnvironmentRef::Http { url } => load_environment_def_from_http(url).await,
108        TargetEnvironmentRef::File { path } => {
109            load_environment_def_from_file(app_dir.join(path)).await
110        }
111    }
112}
113
114/// Loads a `EnvironmentDefinition` from the catalogue. If not found, the catalogue is refreshed
115/// and retried. Any remote packages the environment references will be used
116/// from cache if available; otherwise, they will be saved to the cache, and the
117/// in-memory lockfile object updated.
118async fn load_environment_def_from_catalogue(
119    env_id: &str,
120) -> anyhow::Result<LoadedEnvironmentDefinition> {
121    let catalogue = Catalogue::try_default()?;
122    let env_id = env_id.replace(':', "@");
123    let env_def = match catalogue.get(&env_id).await? {
124        Some(env_def) => env_def,
125        None => {
126            catalogue.update().await?;
127            catalogue
128                .get(&env_id)
129                .await?
130                .with_context(|| anyhow!("Cannot load target environment '{env_id}'"))?
131        }
132    };
133    Ok(LoadedEnvironmentDefinition::new(env_id, env_def, None))
134}
135
136/// Loads a `EnvironmentDefinition` from the given
137/// URL. Any remote packages the environment references will be used
138/// from cache if available; otherwise, they will be saved to the cache, and the
139/// in-memory lockfile object updated.
140async fn load_environment_def_from_http(url: &str) -> anyhow::Result<LoadedEnvironmentDefinition> {
141    let toml_text = reqwest::get(url).await?.text().await?;
142    let env_def: EnvironmentDefinition = toml::from_str(&toml_text)?;
143    let url = url::Url::parse(url)?;
144    let env_id = url
145        .path_segments()
146        .with_context(|| format!("environment URL {url} does not have a path"))?
147        .next_back()
148        .with_context(|| format!("environment URL {url} does not have a path"))?;
149    let env_id = env_id
150        .rsplit_once('.')
151        .map(|(stem, _)| stem)
152        .unwrap_or(env_id);
153    Ok(LoadedEnvironmentDefinition::new(env_id, env_def, None))
154}
155
156/// Loads a `EnvironmentDefinition` from the given TOML file. Any remote packages
157/// it references will be used from cache if available; otherwise, they will be saved
158/// to the cache, and the in-memory lockfile object updated.
159async fn load_environment_def_from_file(
160    path: impl AsRef<Path>,
161) -> anyhow::Result<LoadedEnvironmentDefinition> {
162    let path = path.as_ref();
163    let env_def_dir = path.parent().map(|p| p.to_owned());
164    let name = path
165        .file_stem()
166        .and_then(|s| s.to_str())
167        .map(|s| s.to_owned())
168        .unwrap();
169    let toml_text = tokio::fs::read_to_string(path).await.with_context(|| {
170        format!(
171            "unable to read target environment from {}",
172            quoted_path(path)
173        )
174    })?;
175    let env_def: EnvironmentDefinition = toml::from_str(&toml_text)?;
176    Ok(LoadedEnvironmentDefinition::new(name, env_def, env_def_dir))
177}
178
179/// Loads a `TargetEnvironment` from the given TOML text. Any remote packages
180/// it references will be used from cache if available; otherwise, they will be saved
181/// to the cache, and the in-memory lockfile object updated.
182async fn load_environment_from_env_def(
183    loaded_env_def: LoadedEnvironmentDefinition,
184    cache: &spin_loader::cache::Cache,
185    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
186) -> anyhow::Result<TargetEnvironment> {
187    let mut trigger_worlds = HashMap::new();
188    let mut trigger_capabilities = HashMap::new();
189
190    let LoadedEnvironmentDefinition {
191        name,
192        env_def,
193        relative_path_base,
194    } = loaded_env_def;
195
196    // TODO: parallel all the things
197    // TODO: this loads _all_ triggers not just the ones we need
198    for (trigger_type, trigger_env) in env_def.triggers() {
199        trigger_worlds.insert(
200            trigger_type.to_owned(),
201            load_worlds(
202                trigger_env.world_refs(),
203                &relative_path_base,
204                cache,
205                lockfile,
206            )
207            .await?,
208        );
209        trigger_capabilities.insert(trigger_type.to_owned(), trigger_env.capabilities());
210    }
211
212    let unknown_trigger = match env_def.default() {
213        None => UnknownTrigger::Deny,
214        Some(env) => UnknownTrigger::Allow(
215            load_worlds(env.world_refs(), &relative_path_base, cache, lockfile).await?,
216        ),
217    };
218    let unknown_capabilities = match env_def.default() {
219        None => vec![],
220        Some(env) => env.capabilities(),
221    };
222
223    Ok(TargetEnvironment {
224        name: name.to_owned(),
225        trigger_worlds,
226        trigger_capabilities,
227        unknown_trigger,
228        unknown_capabilities,
229    })
230}
231
232async fn load_worlds(
233    world_refs: &[WorldRef],
234    relative_to_dir: &Option<PathBuf>,
235    cache: &spin_loader::cache::Cache,
236    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
237) -> anyhow::Result<CandidateWorlds> {
238    let mut worlds = vec![];
239
240    for world_ref in world_refs {
241        worlds.push(load_world(world_ref, relative_to_dir, cache, lockfile).await?);
242    }
243
244    Ok(CandidateWorlds { worlds })
245}
246
247async fn load_world(
248    world_ref: &WorldRef,
249    relative_to_dir: &Option<PathBuf>,
250    cache: &spin_loader::cache::Cache,
251    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
252) -> anyhow::Result<CandidateWorld> {
253    match world_ref {
254        WorldRef::DefaultRegistry(world) => {
255            load_world_from_registry(DEFAULT_PACKAGE_REGISTRY, world, cache, lockfile).await
256        }
257        WorldRef::Registry { registry, world } => {
258            load_world_from_registry(registry, world, cache, lockfile).await
259        }
260        WorldRef::OciRegistry { reference, world } => {
261            load_world_from_oci_ref(reference, world, cache, lockfile).await
262        }
263        WorldRef::WitDirectory { path, world } => {
264            let path = match relative_to_dir {
265                Some(dir) => dir.join(path),
266                None => path.to_owned(),
267            };
268            load_world_from_dir(&path, world)
269        }
270    }
271}
272
273fn load_world_from_dir(
274    path: impl AsRef<Path>,
275    world: &WorldName,
276) -> anyhow::Result<CandidateWorld> {
277    let path = path.as_ref();
278    let mut resolve = wit_parser::Resolve::default();
279    let (pkg_id, _) = resolve.push_dir(path)?;
280    let decoded = wit_parser::decoding::DecodedWasm::WitPackage(resolve, pkg_id);
281    CandidateWorld::from_decoded_wasm(world, path, decoded)
282}
283
284/// Loads the given `TargetEnvironment` from the given registry, or
285/// from cache if available. If the environment is not in cache, the
286/// encoded WIT will be cached, and the in-memory lockfile object
287/// updated.
288async fn load_world_from_registry(
289    registry: &str,
290    world_name: &WorldName,
291    cache: &spin_loader::cache::Cache,
292    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
293) -> anyhow::Result<CandidateWorld> {
294    use futures_util::TryStreamExt;
295
296    if let Some(digest) = lockfile
297        .read()
298        .await
299        .package_digest(registry, world_name.package())
300        && let Ok(cache_file) = cache.wasm_file(digest)
301        && let Ok(bytes) = tokio::fs::read(&cache_file).await
302    {
303        return CandidateWorld::from_package_bytes(world_name, bytes);
304    }
305
306    let pkg_name = world_name.package_namespaced_name();
307    let pkg_ref = world_name.package_ref()?;
308
309    let wkg_registry: wasm_pkg_client::Registry = registry
310        .parse()
311        .with_context(|| format!("Registry {registry} is not a valid registry name"))?;
312
313    let mut wkg_config = wasm_pkg_client::Config::global_defaults().await?;
314    wkg_config.set_package_registry_override(
315        pkg_ref,
316        wasm_pkg_client::RegistryMapping::Registry(wkg_registry),
317    );
318
319    let client = wasm_pkg_client::Client::new(wkg_config);
320
321    let package = pkg_name
322        .to_owned()
323        .try_into()
324        .with_context(|| format!("Failed to parse environment name {pkg_name} as package name"))?;
325    let version = world_name
326        .package_version() // TODO: surely we can cope with worlds from unversioned packages? surely?
327        .ok_or_else(|| anyhow!("{world_name} is unversioned: this is not currently supported"))?;
328
329    let release = client
330        .get_release(&package, version)
331        .await
332        .with_context(|| format!("Failed to get {} from registry", world_name.package()))?;
333    let stm = client
334        .stream_content(&package, &release)
335        .await
336        .with_context(|| format!("Failed to get {} from registry", world_name.package()))?;
337    let bytes = stm
338        .try_collect::<bytes::BytesMut>()
339        .await
340        .with_context(|| format!("Failed to get {} from registry", world_name.package()))?
341        .to_vec();
342
343    let digest = release.content_digest.to_string();
344    _ = cache.write_wasm(&bytes, &digest).await; // Failure to cache is not fatal
345    lockfile
346        .write()
347        .await
348        .set_package_digest(registry, world_name.package(), &digest);
349
350    CandidateWorld::from_package_bytes(world_name, bytes)
351}
352
353/// Loads the given `TargetEnvironment` from the given registry, or
354/// from cache if available. If the environment is not in cache, the
355/// encoded WIT will be cached, and the in-memory lockfile object
356/// updated.
357async fn load_world_from_oci_ref(
358    reference: &str,
359    world_name: &WorldName,
360    cache: &spin_loader::cache::Cache,
361    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
362) -> anyhow::Result<CandidateWorld> {
363    if let Some(digest) = lockfile
364        .read()
365        .await
366        .package_digest(reference, world_name.package())
367        && let Ok(cache_file) = cache.wasm_file(digest)
368        && let Ok(bytes) = tokio::fs::read(&cache_file).await
369    {
370        return CandidateWorld::from_package_bytes(world_name, bytes);
371    }
372
373    let oci_client = oci_client::Client::new(oci_client::client::ClientConfig {
374        protocol: oci_client::client::ClientProtocol::Https,
375        ..Default::default()
376    });
377    let client = oci_wasm::WasmClient::new(oci_client);
378
379    let oci_reference = reference.parse().with_context(|| {
380        format!("Target environment contains invalid OCI reference {reference}")
381    })?;
382    let data = client
383        .pull(
384            &oci_reference,
385            &oci_client::secrets::RegistryAuth::Anonymous,
386        )
387        .await
388        .with_context(|| format!("Failed to get {reference} from registry"))?;
389
390    let bytes = data
391        .layers
392        .into_iter()
393        .next()
394        .ok_or_else(|| anyhow::anyhow!("No layers found in target environment {reference}"))?
395        .data
396        .to_vec();
397
398    if let Some(digest) = data.digest {
399        _ = cache.write_wasm(&bytes, &digest).await; // Failure to cache is not fatal
400        lockfile
401            .write()
402            .await
403            .set_package_digest(reference, world_name.package(), &digest);
404    }
405
406    CandidateWorld::from_package_bytes(world_name, bytes)
407}