|
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,124 @@ 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 | +} |
0 commit comments