greentic_component/manifest/
mod.rs1use std::path::{Component, Path, PathBuf};
2
3use jsonschema::{Validator, validator_for};
4use once_cell::sync::Lazy;
5use semver::Version;
6use serde::Serialize;
7use serde_json::Value;
8use thiserror::Error;
9
10use crate::capabilities::{
11 Capabilities, ComponentConfigurators, ComponentProfiles, validate_capabilities,
12};
13use crate::limits::Limits;
14use crate::provenance::Provenance;
15use crate::telemetry::TelemetrySpec;
16use greentic_types::flow::FlowKind;
17use greentic_types::{SecretKey, SecretRequirement};
18
19static RAW_SCHEMA: &str = include_str!("../../schemas/v1/component.manifest.schema.json");
20
21static COMPILED_SCHEMA: Lazy<Validator> = Lazy::new(|| {
22 let value: Value =
23 serde_json::from_str(RAW_SCHEMA).expect("component manifest schema must be valid JSON");
24 validator_for(&value).expect("component manifest schema must compile")
25});
26
27#[derive(Debug, Clone, Serialize, PartialEq)]
28pub struct ComponentManifest {
29 pub id: ManifestId,
30 pub name: String,
31 pub version: Version,
32 #[serde(default)]
33 pub supports: Vec<FlowKind>,
34 pub world: World,
35 #[serde(default)]
36 pub capabilities: Capabilities,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub secret_requirements: Vec<SecretRequirement>,
39 pub profiles: ComponentProfiles,
40 #[serde(default)]
41 pub configurators: Option<ComponentConfigurators>,
42 #[serde(default)]
43 pub limits: Option<Limits>,
44 #[serde(default)]
45 pub telemetry: Option<TelemetrySpec>,
46 pub describe_export: DescribeExport,
47 #[serde(default)]
48 pub provenance: Option<Provenance>,
49 pub artifacts: Artifacts,
50 pub hashes: Hashes,
51}
52
53impl ComponentManifest {
54 pub fn wasm_artifact_path(&self, root: &Path) -> PathBuf {
55 root.join(&self.artifacts.component_wasm)
56 }
57}
58
59#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
60#[serde(transparent)]
61pub struct ManifestId(String);
62
63impl ManifestId {
64 fn parse(id: String) -> Result<Self, ManifestError> {
65 if id.trim().is_empty() {
66 return Err(ManifestError::EmptyField("id"));
67 }
68 Ok(Self(id))
69 }
70
71 pub fn as_str(&self) -> &str {
72 &self.0
73 }
74}
75
76impl std::fmt::Display for ManifestId {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 f.write_str(&self.0)
79 }
80}
81
82#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
83#[serde(transparent)]
84pub struct World(String);
85
86impl World {
87 fn parse(world: String) -> Result<Self, ManifestError> {
88 if world.trim().is_empty() {
89 return Err(ManifestError::InvalidWorld { world });
90 }
91 Ok(Self(world))
92 }
93
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99impl std::fmt::Display for World {
100 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101 f.write_str(&self.0)
102 }
103}
104
105#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
106#[serde(transparent)]
107pub struct DescribeExport(String);
108
109impl DescribeExport {
110 fn parse(export: String) -> Result<Self, ManifestError> {
111 if export.trim().is_empty() {
112 return Err(ManifestError::InvalidDescribeExport {
113 export,
114 reason: "describe_export cannot be empty".into(),
115 });
116 }
117 Ok(Self(export))
118 }
119
120 pub fn as_str(&self) -> &str {
121 &self.0
122 }
123
124 pub fn kind(&self) -> DescribeKind {
125 if self.0.contains(':') && self.0.contains('/') {
126 DescribeKind::WitWorld
127 } else {
128 DescribeKind::Export
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum DescribeKind {
135 Export,
136 WitWorld,
137}
138
139#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
140pub struct Artifacts {
141 component_wasm: PathBuf,
142}
143
144impl Artifacts {
145 pub fn component_wasm(&self) -> &Path {
146 &self.component_wasm
147 }
148}
149
150#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
151pub struct Hashes {
152 pub component_wasm: WasmHash,
153}
154
155#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
156#[serde(transparent)]
157pub struct WasmHash(String);
158
159impl WasmHash {
160 fn parse(hash: String) -> Result<Self, ManifestError> {
161 let Some(rest) = hash.strip_prefix("blake3:") else {
162 return Err(ManifestError::InvalidHashFormat { hash });
163 };
164 if rest.len() != 64 || !rest.chars().all(|c| c.is_ascii_hexdigit()) {
165 return Err(ManifestError::InvalidHashFormat {
166 hash: format!("blake3:{rest}"),
167 });
168 }
169 Ok(Self(format!("blake3:{rest}")))
170 }
171
172 pub fn algorithm(&self) -> &str {
173 "blake3"
174 }
175
176 pub fn digest(&self) -> &str {
177 &self.0[7..]
178 }
179
180 pub fn as_str(&self) -> &str {
181 &self.0
182 }
183}
184
185pub fn schema() -> &'static str {
186 RAW_SCHEMA
187}
188
189pub fn parse_manifest(raw: &str) -> Result<ComponentManifest, ManifestError> {
190 let value: Value = serde_json::from_str(raw)?;
191 validate_value(&value)?;
192 let raw_manifest: RawManifest = serde_json::from_value(value)?;
193 raw_manifest.try_into()
194}
195
196pub fn validate_manifest(raw: &str) -> Result<(), ManifestError> {
197 let value: Value = serde_json::from_str(raw)?;
198 validate_value(&value)
199}
200
201fn validate_value(value: &Value) -> Result<(), ManifestError> {
202 let errors: Vec<String> = COMPILED_SCHEMA
203 .iter_errors(value)
204 .map(|err| err.to_string())
205 .collect();
206 if errors.is_empty() {
207 Ok(())
208 } else {
209 Err(ManifestError::Schema(errors.join(", ")))
210 }
211}
212
213#[derive(Debug, Error)]
214pub enum ManifestError {
215 #[error("manifest json parse failed: {0}")]
216 Json(#[from] serde_json::Error),
217 #[error("manifest schema validation failed: {0}")]
218 Schema(String),
219 #[error("world identifier is invalid: `{world}`")]
220 InvalidWorld { world: String },
221 #[error("manifest field `{0}` cannot be empty")]
222 EmptyField(&'static str),
223 #[error("component must support at least one flow kind")]
224 MissingSupports,
225 #[error("profiles.supported must include at least one profile identifier")]
226 MissingProfiles,
227 #[error("profiles.default `{default}` must be one of the supported profiles")]
228 InvalidProfileDefault { default: String },
229 #[error("invalid semantic version `{version}`: {source}")]
230 InvalidVersion {
231 version: String,
232 #[source]
233 source: semver::Error,
234 },
235 #[error("invalid describe export `{export}`: {reason}")]
236 InvalidDescribeExport { export: String, reason: String },
237 #[error("component wasm path must be relative (got `{path}`)")]
238 InvalidArtifactPath { path: String },
239 #[error("component wasm hash must be blake3:<hex> (got `{hash}`)")]
240 InvalidHashFormat { hash: String },
241 #[error("capability validation failed: {0}")]
242 Capability(String),
243 #[error("duplicate secret requirement `{0}` detected")]
244 DuplicateSecretRequirement(String),
245 #[error("secret requirement `{key}` is invalid: {reason}")]
246 InvalidSecretRequirement { key: String, reason: String },
247 #[error("limits invalid: {0}")]
248 Limits(String),
249 #[error("provenance invalid: {0}")]
250 Provenance(String),
251}
252
253#[derive(Debug, serde::Deserialize)]
254struct RawManifest {
255 id: String,
256 name: String,
257 version: String,
258 world: String,
259 #[serde(default)]
260 supports: Vec<FlowKind>,
261 #[serde(default)]
262 capabilities: Capabilities,
263 #[serde(default)]
264 secret_requirements: Vec<SecretRequirement>,
265 #[serde(default)]
266 profiles: ComponentProfiles,
267 #[serde(default)]
268 configurators: Option<ComponentConfigurators>,
269 #[serde(default)]
270 limits: Option<Limits>,
271 #[serde(default)]
272 telemetry: Option<TelemetrySpec>,
273 describe_export: String,
274 #[serde(default)]
275 provenance: Option<Provenance>,
276 artifacts: RawArtifacts,
277 hashes: RawHashes,
278}
279
280impl TryFrom<RawManifest> for ComponentManifest {
281 type Error = ManifestError;
282
283 fn try_from(raw: RawManifest) -> Result<Self, Self::Error> {
284 if raw.name.trim().is_empty() {
285 return Err(ManifestError::EmptyField("name"));
286 }
287
288 let id = ManifestId::parse(raw.id)?;
289 let world = World::parse(raw.world)?;
290 let version =
291 Version::parse(&raw.version).map_err(|source| ManifestError::InvalidVersion {
292 version: raw.version,
293 source,
294 })?;
295 let describe_export = DescribeExport::parse(raw.describe_export)?;
296 let artifacts = Artifacts::try_from(raw.artifacts)?;
297 let hashes = Hashes::try_from(raw.hashes)?;
298
299 if raw.supports.is_empty() {
300 return Err(ManifestError::MissingSupports);
301 }
302
303 validate_profiles(&raw.profiles)?;
304
305 if let Some(configurators) = &raw.configurators {
306 validate_configurators(configurators)?;
307 }
308
309 validate_capabilities(&raw.capabilities)
310 .map_err(|err| ManifestError::Capability(err.to_string()))?;
311
312 validate_secret_requirements(&raw.secret_requirements)?;
313
314 if let Some(limits) = &raw.limits {
315 limits
316 .validate()
317 .map_err(|err| ManifestError::Limits(err.to_string()))?;
318 }
319
320 if let Some(provenance) = &raw.provenance {
321 provenance
322 .validate()
323 .map_err(|err| ManifestError::Provenance(err.to_string()))?;
324 }
325
326 Ok(Self {
327 id,
328 name: raw.name,
329 version,
330 world,
331 supports: raw.supports,
332 capabilities: raw.capabilities,
333 secret_requirements: raw.secret_requirements,
334 profiles: raw.profiles,
335 configurators: raw.configurators,
336 limits: raw.limits,
337 telemetry: raw.telemetry,
338 describe_export,
339 provenance: raw.provenance,
340 artifacts,
341 hashes,
342 })
343 }
344}
345
346#[derive(Debug, serde::Deserialize)]
347struct RawArtifacts {
348 component_wasm: String,
349}
350
351impl TryFrom<RawArtifacts> for Artifacts {
352 type Error = ManifestError;
353
354 fn try_from(value: RawArtifacts) -> Result<Self, Self::Error> {
355 ensure_relative(&value.component_wasm)?;
356 Ok(Artifacts {
357 component_wasm: PathBuf::from(value.component_wasm),
358 })
359 }
360}
361
362#[derive(Debug, serde::Deserialize)]
363struct RawHashes {
364 component_wasm: String,
365}
366
367impl TryFrom<RawHashes> for Hashes {
368 type Error = ManifestError;
369
370 fn try_from(value: RawHashes) -> Result<Self, Self::Error> {
371 Ok(Hashes {
372 component_wasm: WasmHash::parse(value.component_wasm)?,
373 })
374 }
375}
376
377fn ensure_relative(path: &str) -> Result<(), ManifestError> {
378 let path_buf = PathBuf::from(path);
379 if path_buf.is_absolute() {
380 return Err(ManifestError::InvalidArtifactPath {
381 path: path.to_string(),
382 });
383 }
384 if matches!(path_buf.components().next(), Some(Component::Prefix(_))) {
385 return Err(ManifestError::InvalidArtifactPath {
386 path: path.to_string(),
387 });
388 }
389 Ok(())
390}
391
392fn validate_secret_requirements(requirements: &[SecretRequirement]) -> Result<(), ManifestError> {
393 let mut seen = std::collections::HashSet::new();
394 for req in requirements {
395 if !seen.insert(req.key.as_str().to_string()) {
396 return Err(ManifestError::DuplicateSecretRequirement(
397 req.key.as_str().to_string(),
398 ));
399 }
400
401 SecretKey::new(req.key.as_str()).map_err(|err| {
402 ManifestError::InvalidSecretRequirement {
403 key: req.key.as_str().to_string(),
404 reason: err.to_string(),
405 }
406 })?;
407
408 let scope = req
409 .scope
410 .as_ref()
411 .ok_or_else(|| ManifestError::InvalidSecretRequirement {
412 key: req.key.as_str().to_string(),
413 reason: "scope must include env and tenant".into(),
414 })?;
415
416 if scope.env.trim().is_empty() {
417 return Err(ManifestError::InvalidSecretRequirement {
418 key: req.key.as_str().to_string(),
419 reason: "scope.env must not be empty".into(),
420 });
421 }
422 if scope.tenant.trim().is_empty() {
423 return Err(ManifestError::InvalidSecretRequirement {
424 key: req.key.as_str().to_string(),
425 reason: "scope.tenant must not be empty".into(),
426 });
427 }
428 if let Some(team) = &scope.team
429 && team.trim().is_empty()
430 {
431 return Err(ManifestError::InvalidSecretRequirement {
432 key: req.key.as_str().to_string(),
433 reason: "scope.team must not be empty when provided".into(),
434 });
435 }
436
437 if req.format.is_none() {
438 return Err(ManifestError::InvalidSecretRequirement {
439 key: req.key.as_str().to_string(),
440 reason: "format must be specified".into(),
441 });
442 }
443
444 if let Some(schema) = &req.schema
445 && !schema.is_object()
446 {
447 return Err(ManifestError::InvalidSecretRequirement {
448 key: req.key.as_str().to_string(),
449 reason: "schema must be an object when provided".into(),
450 });
451 }
452 }
453 Ok(())
454}
455
456fn validate_profiles(profiles: &ComponentProfiles) -> Result<(), ManifestError> {
457 if profiles.supported.is_empty() {
458 return Err(ManifestError::MissingProfiles);
459 }
460 if let Some(default) = &profiles.default
461 && !profiles.supported.iter().any(|entry| entry == default)
462 {
463 return Err(ManifestError::InvalidProfileDefault {
464 default: default.clone(),
465 });
466 }
467 Ok(())
468}
469
470fn validate_configurators(_configurators: &ComponentConfigurators) -> Result<(), ManifestError> {
471 Ok(())
473}