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        configuration_constraints: env_def.configuration_constraints().clone(),
230    })
231}
232
233async fn load_worlds(
234    world_refs: &[WorldRef],
235    relative_to_dir: &Option<PathBuf>,
236    cache: &spin_loader::cache::Cache,
237    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
238) -> anyhow::Result<CandidateWorlds> {
239    let mut worlds = vec![];
240
241    for world_ref in world_refs {
242        worlds.push(load_world(world_ref, relative_to_dir, cache, lockfile).await?);
243    }
244
245    Ok(CandidateWorlds { worlds })
246}
247
248async fn load_world(
249    world_ref: &WorldRef,
250    relative_to_dir: &Option<PathBuf>,
251    cache: &spin_loader::cache::Cache,
252    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
253) -> anyhow::Result<CandidateWorld> {
254    match world_ref {
255        WorldRef::DefaultRegistry(world) => {
256            load_world_from_registry(DEFAULT_PACKAGE_REGISTRY, world, cache, lockfile).await
257        }
258        WorldRef::Registry { registry, world } => {
259            load_world_from_registry(registry, world, cache, lockfile).await
260        }
261        WorldRef::OciRegistry { reference, world } => {
262            load_world_from_oci_ref(reference, world, cache, lockfile).await
263        }
264        WorldRef::WitDirectory { path, world } => {
265            let path = match relative_to_dir {
266                Some(dir) => dir.join(path),
267                None => path.to_owned(),
268            };
269            load_world_from_dir(&path, world)
270        }
271    }
272}
273
274fn load_world_from_dir(
275    path: impl AsRef<Path>,
276    world: &WorldName,
277) -> anyhow::Result<CandidateWorld> {
278    let path = path.as_ref();
279    let mut resolve = wit_parser::Resolve::default();
280    let (pkg_id, _) = resolve.push_dir(path)?;
281    let decoded = wit_parser::decoding::DecodedWasm::WitPackage(resolve, pkg_id);
282    CandidateWorld::from_decoded_wasm(world, path, decoded)
283}
284
285/// Loads the given `TargetEnvironment` from the given registry, or
286/// from cache if available. If the environment is not in cache, the
287/// encoded WIT will be cached, and the in-memory lockfile object
288/// updated.
289async fn load_world_from_registry(
290    registry: &str,
291    world_name: &WorldName,
292    cache: &spin_loader::cache::Cache,
293    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
294) -> anyhow::Result<CandidateWorld> {
295    use futures_util::TryStreamExt;
296
297    if let Some(digest) = lockfile
298        .read()
299        .await
300        .package_digest(registry, world_name.package())
301        && let Ok(cache_file) = cache.wasm_file(digest)
302        && let Ok(bytes) = tokio::fs::read(&cache_file).await
303    {
304        return CandidateWorld::from_package_bytes(world_name, bytes);
305    }
306
307    let pkg_name = world_name.package_namespaced_name();
308    let pkg_ref = world_name.package_ref()?;
309
310    let wkg_registry: wasm_pkg_client::Registry = registry
311        .parse()
312        .with_context(|| format!("Registry {registry} is not a valid registry name"))?;
313
314    let mut wkg_config = wasm_pkg_client::Config::global_defaults().await?;
315    wkg_config.set_package_registry_override(
316        pkg_ref,
317        wasm_pkg_client::RegistryMapping::Registry(wkg_registry),
318    );
319
320    let client = wasm_pkg_client::Client::new(wkg_config);
321
322    let package = pkg_name
323        .to_owned()
324        .try_into()
325        .with_context(|| format!("Failed to parse environment name {pkg_name} as package name"))?;
326    let version = world_name
327        .package_version() // TODO: surely we can cope with worlds from unversioned packages? surely?
328        .ok_or_else(|| anyhow!("{world_name} is unversioned: this is not currently supported"))?;
329
330    let release = client
331        .get_release(&package, version)
332        .await
333        .with_context(|| format!("Failed to get {} from registry", world_name.package()))?;
334    let stm = client
335        .stream_content(&package, &release)
336        .await
337        .with_context(|| format!("Failed to get {} from registry", world_name.package()))?;
338    let bytes = stm
339        .try_collect::<bytes::BytesMut>()
340        .await
341        .with_context(|| format!("Failed to get {} from registry", world_name.package()))?
342        .to_vec();
343
344    let digest = release.content_digest.to_string();
345    _ = cache.write_wasm(&bytes, &digest).await; // Failure to cache is not fatal
346    lockfile
347        .write()
348        .await
349        .set_package_digest(registry, world_name.package(), &digest);
350
351    CandidateWorld::from_package_bytes(world_name, bytes)
352}
353
354/// Loads the given `TargetEnvironment` from the given registry, or
355/// from cache if available. If the environment is not in cache, the
356/// encoded WIT will be cached, and the in-memory lockfile object
357/// updated.
358async fn load_world_from_oci_ref(
359    reference: &str,
360    world_name: &WorldName,
361    cache: &spin_loader::cache::Cache,
362    lockfile: &std::sync::Arc<tokio::sync::RwLock<TargetEnvironmentLockfile>>,
363) -> anyhow::Result<CandidateWorld> {
364    if let Some(digest) = lockfile
365        .read()
366        .await
367        .package_digest(reference, world_name.package())
368        && let Ok(cache_file) = cache.wasm_file(digest)
369        && let Ok(bytes) = tokio::fs::read(&cache_file).await
370    {
371        return CandidateWorld::from_package_bytes(world_name, bytes);
372    }
373
374    let oci_client = oci_client::Client::new(oci_client::client::ClientConfig {
375        protocol: oci_client::client::ClientProtocol::Https,
376        ..Default::default()
377    });
378    let client = oci_wasm::WasmClient::new(oci_client);
379
380    let oci_reference = reference.parse().with_context(|| {
381        format!("Target environment contains invalid OCI reference {reference}")
382    })?;
383    let data = client
384        .pull(
385            &oci_reference,
386            &oci_client::secrets::RegistryAuth::Anonymous,
387        )
388        .await
389        .with_context(|| format!("Failed to get {reference} from registry"))?;
390
391    let bytes = data
392        .layers
393        .into_iter()
394        .next()
395        .ok_or_else(|| anyhow::anyhow!("No layers found in target environment {reference}"))?
396        .data
397        .to_vec();
398
399    if let Some(digest) = data.digest {
400        _ = cache.write_wasm(&bytes, &digest).await; // Failure to cache is not fatal
401        lockfile
402            .write()
403            .await
404            .set_package_digest(reference, world_name.package(), &digest);
405    }
406
407    CandidateWorld::from_package_bytes(world_name, bytes)
408}