|
1 | 1 | //! Manifest normalization functions.
|
2 | 2 |
|
3 |
| -use std::collections::HashSet; |
| 3 | +use std::{collections::HashSet, path::PathBuf}; |
4 | 4 |
|
5 | 5 | use crate::schema::v2::{AppManifest, ComponentSpec, KebabId};
|
| 6 | +use anyhow::Context; |
6 | 7 |
|
7 | 8 | /// Normalizes some optional [`AppManifest`] features into a canonical form:
|
8 | 9 | /// - Inline components in trigger configs are moved into top-level
|
9 | 10 | /// components and replaced with a reference.
|
10 | 11 | /// - Any triggers without an ID are assigned a generated ID.
|
11 |
| -pub fn normalize_manifest(manifest: &mut AppManifest) { |
| 12 | +pub fn normalize_manifest(manifest: &mut AppManifest) -> anyhow::Result<()> { |
12 | 13 | normalize_trigger_ids(manifest);
|
13 | 14 | normalize_inline_components(manifest);
|
| 15 | + normalize_dependency_component_refs(manifest)?; |
| 16 | + Ok(()) |
14 | 17 | }
|
15 | 18 |
|
16 | 19 | fn normalize_inline_components(manifest: &mut AppManifest) {
|
@@ -103,3 +106,271 @@ fn normalize_trigger_ids(manifest: &mut AppManifest) {
|
103 | 106 | }
|
104 | 107 | }
|
105 | 108 | }
|
| 109 | + |
| 110 | +use crate::schema::v2::{Component, ComponentDependency, ComponentSource}; |
| 111 | + |
| 112 | +fn normalize_dependency_component_refs(manifest: &mut AppManifest) -> anyhow::Result<()> { |
| 113 | + // `clone` a snapshot, because we are about to mutate collection elements, |
| 114 | + // and the borrow checker gets mad at us if we try to index into the collection |
| 115 | + // while that's happening. |
| 116 | + let components = manifest.components.clone(); |
| 117 | + |
| 118 | + for (depender_id, component) in &mut manifest.components { |
| 119 | + for dependency in component.dependencies.inner.values_mut() { |
| 120 | + if let ComponentDependency::AppComponent { |
| 121 | + component: depended_on_id, |
| 122 | + export, |
| 123 | + } = dependency |
| 124 | + { |
| 125 | + let depended_on = components |
| 126 | + .get(depended_on_id) |
| 127 | + .with_context(|| format!("dependency ID {depended_on_id} does not exist"))?; |
| 128 | + ensure_is_acceptable_dependency(depended_on, depended_on_id, depender_id)?; |
| 129 | + *dependency = component_source_to_dependency(&depended_on.source, export.clone()); |
| 130 | + } |
| 131 | + } |
| 132 | + } |
| 133 | + |
| 134 | + Ok(()) |
| 135 | +} |
| 136 | + |
| 137 | +fn component_source_to_dependency( |
| 138 | + source: &ComponentSource, |
| 139 | + export: Option<String>, |
| 140 | +) -> ComponentDependency { |
| 141 | + match source { |
| 142 | + ComponentSource::Local(path) => ComponentDependency::Local { |
| 143 | + path: PathBuf::from(path), |
| 144 | + export, |
| 145 | + }, |
| 146 | + ComponentSource::Remote { url, digest } => ComponentDependency::HTTP { |
| 147 | + url: url.clone(), |
| 148 | + digest: digest.clone(), |
| 149 | + export, |
| 150 | + }, |
| 151 | + ComponentSource::Registry { |
| 152 | + registry, |
| 153 | + package, |
| 154 | + version, |
| 155 | + } => ComponentDependency::Package { |
| 156 | + version: version.clone(), |
| 157 | + registry: registry.as_ref().map(|r| r.to_string()), |
| 158 | + package: Some(package.to_string()), |
| 159 | + export, |
| 160 | + }, |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +/// If a dependency has things like files or KV stores or network access... |
| 165 | +/// those won't apply when it's composed, and that's likely to be surprising, |
| 166 | +/// and developers hate surprises. |
| 167 | +fn ensure_is_acceptable_dependency( |
| 168 | + component: &Component, |
| 169 | + depended_on_id: &KebabId, |
| 170 | + depender_id: &KebabId, |
| 171 | +) -> anyhow::Result<()> { |
| 172 | + let mut surprises = vec![]; |
| 173 | + |
| 174 | + // Explicitly discard fields we don't need to check (do *not* .. them away). This |
| 175 | + // way, the compiler will give us a heads up if a new field is added so we can |
| 176 | + // decide whether or not we need to check it. |
| 177 | + #[allow(deprecated)] |
| 178 | + let Component { |
| 179 | + source: _, |
| 180 | + description: _, |
| 181 | + variables, |
| 182 | + environment, |
| 183 | + files, |
| 184 | + exclude_files: _, |
| 185 | + allowed_http_hosts, |
| 186 | + allowed_outbound_hosts, |
| 187 | + key_value_stores, |
| 188 | + sqlite_databases, |
| 189 | + ai_models, |
| 190 | + build: _, |
| 191 | + tool: _, |
| 192 | + dependencies_inherit_configuration: _, |
| 193 | + dependencies, |
| 194 | + } = component; |
| 195 | + |
| 196 | + if !ai_models.is_empty() { |
| 197 | + surprises.push("ai_models"); |
| 198 | + } |
| 199 | + if !allowed_http_hosts.is_empty() { |
| 200 | + surprises.push("allowed_http_hosts"); |
| 201 | + } |
| 202 | + if !allowed_outbound_hosts.is_empty() { |
| 203 | + surprises.push("allowed_outbound_hosts"); |
| 204 | + } |
| 205 | + if !dependencies.inner.is_empty() { |
| 206 | + surprises.push("dependencies"); |
| 207 | + } |
| 208 | + if !environment.is_empty() { |
| 209 | + surprises.push("environment"); |
| 210 | + } |
| 211 | + if !files.is_empty() { |
| 212 | + surprises.push("files"); |
| 213 | + } |
| 214 | + if !key_value_stores.is_empty() { |
| 215 | + surprises.push("key_value_stores"); |
| 216 | + } |
| 217 | + if !sqlite_databases.is_empty() { |
| 218 | + surprises.push("sqlite_databases"); |
| 219 | + } |
| 220 | + if !variables.is_empty() { |
| 221 | + surprises.push("variables"); |
| 222 | + } |
| 223 | + |
| 224 | + if surprises.is_empty() { |
| 225 | + Ok(()) |
| 226 | + } else { |
| 227 | + anyhow::bail!("Dependencies may not have their own resources or permissions. Component {depended_on_id} cannot be used as a dependency of {depender_id} because it specifies: {}", surprises.join(", ")); |
| 228 | + } |
| 229 | +} |
| 230 | + |
| 231 | +#[cfg(test)] |
| 232 | +mod test { |
| 233 | + use super::*; |
| 234 | + |
| 235 | + use serde::Deserialize; |
| 236 | + use toml::toml; |
| 237 | + |
| 238 | + fn package_name(name: &str) -> spin_serde::DependencyName { |
| 239 | + let dpn = spin_serde::DependencyPackageName::try_from(name.to_string()).unwrap(); |
| 240 | + spin_serde::DependencyName::Package(dpn) |
| 241 | + } |
| 242 | + |
| 243 | + #[test] |
| 244 | + fn can_resolve_dependency_on_file_source() { |
| 245 | + let mut manifest = AppManifest::deserialize(toml! { |
| 246 | + spin_manifest_version = 2 |
| 247 | + |
| 248 | + [application] |
| 249 | + name = "dummy" |
| 250 | + |
| 251 | + [[trigger.dummy]] |
| 252 | + component = "a" |
| 253 | + |
| 254 | + [component.a] |
| 255 | + source = "a.wasm" |
| 256 | + [component.a.dependencies] |
| 257 | + "b:b" = { component = "b" } |
| 258 | + |
| 259 | + [component.b] |
| 260 | + source = "b.wasm" |
| 261 | + }) |
| 262 | + .unwrap(); |
| 263 | + |
| 264 | + normalize_manifest(&mut manifest).unwrap(); |
| 265 | + |
| 266 | + let dep = manifest |
| 267 | + .components |
| 268 | + .get("a") |
| 269 | + .unwrap() |
| 270 | + .dependencies |
| 271 | + .inner |
| 272 | + .get(&package_name("b:b")) |
| 273 | + .unwrap(); |
| 274 | + |
| 275 | + let ComponentDependency::Local { path, export } = dep else { |
| 276 | + panic!("should have normalised to local dep"); |
| 277 | + }; |
| 278 | + |
| 279 | + assert_eq!(&PathBuf::from("b.wasm"), path); |
| 280 | + assert_eq!(&None, export); |
| 281 | + } |
| 282 | + |
| 283 | + #[test] |
| 284 | + fn can_resolve_dependency_on_http_source() { |
| 285 | + let mut manifest = AppManifest::deserialize(toml! { |
| 286 | + spin_manifest_version = 2 |
| 287 | + |
| 288 | + [application] |
| 289 | + name = "dummy" |
| 290 | + |
| 291 | + [[trigger.dummy]] |
| 292 | + component = "a" |
| 293 | + |
| 294 | + [component.a] |
| 295 | + source = "a.wasm" |
| 296 | + [component.a.dependencies] |
| 297 | + "b:b" = { component = "b", export = "c:d/e" } |
| 298 | + |
| 299 | + [component.b] |
| 300 | + source = { url = "http://example.com/b.wasm", digest = "12345" } |
| 301 | + }) |
| 302 | + .unwrap(); |
| 303 | + |
| 304 | + normalize_manifest(&mut manifest).unwrap(); |
| 305 | + |
| 306 | + let dep = manifest |
| 307 | + .components |
| 308 | + .get("a") |
| 309 | + .unwrap() |
| 310 | + .dependencies |
| 311 | + .inner |
| 312 | + .get(&package_name("b:b")) |
| 313 | + .unwrap(); |
| 314 | + |
| 315 | + let ComponentDependency::HTTP { |
| 316 | + url, |
| 317 | + digest, |
| 318 | + export, |
| 319 | + } = dep |
| 320 | + else { |
| 321 | + panic!("should have normalised to HTTP dep"); |
| 322 | + }; |
| 323 | + |
| 324 | + assert_eq!("http://example.com/b.wasm", url); |
| 325 | + assert_eq!("12345", digest); |
| 326 | + assert_eq!("c:d/e", export.as_ref().unwrap()); |
| 327 | + } |
| 328 | + |
| 329 | + #[test] |
| 330 | + fn can_resolve_dependency_on_package() { |
| 331 | + let mut manifest = AppManifest::deserialize(toml! { |
| 332 | + spin_manifest_version = 2 |
| 333 | + |
| 334 | + [application] |
| 335 | + name = "dummy" |
| 336 | + |
| 337 | + [[trigger.dummy]] |
| 338 | + component = "a" |
| 339 | + |
| 340 | + [component.a] |
| 341 | + source = "a.wasm" |
| 342 | + [component.a.dependencies] |
| 343 | + "b:b" = { component = "b" } |
| 344 | + |
| 345 | + [component.b] |
| 346 | + source = { package = "bb:bb", version = "1.2.3", registry = "reginalds-registry.reg" } |
| 347 | + }) |
| 348 | + .unwrap(); |
| 349 | + |
| 350 | + normalize_manifest(&mut manifest).unwrap(); |
| 351 | + |
| 352 | + let dep = manifest |
| 353 | + .components |
| 354 | + .get("a") |
| 355 | + .unwrap() |
| 356 | + .dependencies |
| 357 | + .inner |
| 358 | + .get(&package_name("b:b")) |
| 359 | + .unwrap(); |
| 360 | + |
| 361 | + let ComponentDependency::Package { |
| 362 | + version, |
| 363 | + registry, |
| 364 | + package, |
| 365 | + export, |
| 366 | + } = dep |
| 367 | + else { |
| 368 | + panic!("should have normalised to HTTP dep"); |
| 369 | + }; |
| 370 | + |
| 371 | + assert_eq!("1.2.3", version); |
| 372 | + assert_eq!("reginalds-registry.reg", registry.as_ref().unwrap()); |
| 373 | + assert_eq!("bb:bb", package.as_ref().unwrap()); |
| 374 | + assert_eq!(&None, export); |
| 375 | + } |
| 376 | +} |
0 commit comments