greentic_types/
flow_resolve_summary.rs1use alloc::collections::BTreeMap;
26use alloc::format;
27use alloc::string::String;
28
29use semver::Version;
30
31#[cfg(feature = "schemars")]
32use schemars::JsonSchema;
33#[cfg(feature = "serde")]
34use serde::{Deserialize, Serialize};
35
36use crate::{ComponentId, ErrorCode, GResult, GreenticError};
37
38pub const FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION: u32 = 1;
40
41#[derive(Clone, Debug, PartialEq, Eq)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44#[cfg_attr(
45 feature = "schemars",
46 derive(JsonSchema),
47 schemars(
48 title = "Greentic Flow Resolve Summary v1",
49 description = "Stable component resolution summary for flow nodes.",
50 rename = "greentic.flow.resolve-summary.v1"
51 )
52)]
53pub struct FlowResolveSummaryV1 {
54 pub schema_version: u32,
56 pub flow: String,
58 pub nodes: BTreeMap<String, NodeResolveSummaryV1>,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
65#[cfg_attr(feature = "schemars", derive(JsonSchema))]
66pub struct NodeResolveSummaryV1 {
67 pub component_id: ComponentId,
69 pub source: FlowResolveSummarySourceRefV1,
71 pub digest: String,
73 #[cfg_attr(
75 feature = "serde",
76 serde(default, skip_serializing_if = "Option::is_none")
77 )]
78 pub manifest: Option<FlowResolveSummaryManifestV1>,
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
83#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
84#[cfg_attr(feature = "schemars", derive(JsonSchema))]
85pub struct FlowResolveSummaryManifestV1 {
86 pub world: String,
88 #[cfg_attr(
90 feature = "schemars",
91 schemars(with = "String", description = "SemVer version")
92 )]
93 pub version: Version,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
100#[cfg_attr(feature = "schemars", derive(JsonSchema))]
101pub enum FlowResolveSummarySourceRefV1 {
102 Local {
104 path: String,
106 },
107 Oci {
109 r#ref: String,
111 },
112 Repo {
114 r#ref: String,
116 },
117 Store {
119 r#ref: String,
121 },
122}
123
124#[cfg(feature = "std")]
125use std::ffi::OsString;
126#[cfg(feature = "std")]
127use std::fs;
128#[cfg(feature = "std")]
129use std::path::{Path, PathBuf};
130
131#[cfg(feature = "std")]
133pub fn resolve_summary_path_for_flow(flow_path: &Path) -> PathBuf {
134 let file_name = flow_path
135 .file_name()
136 .map(OsString::from)
137 .unwrap_or_else(|| OsString::from("flow.ygtc"));
138 let mut sidecar_name = file_name;
139 sidecar_name.push(".resolve.summary.json");
140 flow_path.with_file_name(sidecar_name)
141}
142
143#[cfg(all(feature = "std", feature = "serde"))]
145pub fn read_flow_resolve_summary(path: &Path) -> GResult<FlowResolveSummaryV1> {
146 let raw = fs::read_to_string(path).map_err(|err| io_error("read flow resolve summary", err))?;
147 let doc: FlowResolveSummaryV1 =
148 serde_json::from_str(&raw).map_err(|err| json_error("parse flow resolve summary", err))?;
149 validate_flow_resolve_summary(&doc)?;
150 Ok(doc)
151}
152
153#[cfg(all(feature = "std", feature = "serde"))]
155pub fn write_flow_resolve_summary(path: &Path, doc: &FlowResolveSummaryV1) -> GResult<()> {
156 validate_flow_resolve_summary(doc)?;
157 let raw = serde_json::to_string_pretty(doc)
158 .map_err(|err| json_error("serialize flow resolve summary", err))?;
159 fs::write(path, raw).map_err(|err| io_error("write flow resolve summary", err))?;
160 Ok(())
161}
162
163#[cfg(feature = "std")]
165pub fn validate_flow_resolve_summary(doc: &FlowResolveSummaryV1) -> GResult<()> {
166 if doc.schema_version != FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION {
167 return Err(GreenticError::new(
168 ErrorCode::InvalidInput,
169 format!(
170 "flow resolve summary schema_version must be {}",
171 FLOW_RESOLVE_SUMMARY_SCHEMA_VERSION
172 ),
173 ));
174 }
175
176 for (node_name, node) in &doc.nodes {
177 if let FlowResolveSummarySourceRefV1::Local { path } = &node.source {
178 if Path::new(path).is_absolute() {
179 return Err(GreenticError::new(
180 ErrorCode::InvalidInput,
181 format!(
182 "local component path for node '{}' must be relative",
183 node_name
184 ),
185 ));
186 }
187 }
188 validate_digest(&node.digest)?;
189 if let Some(metadata) = &node.manifest {
190 if metadata.world.trim().is_empty() {
191 return Err(GreenticError::new(
192 ErrorCode::InvalidInput,
193 format!("manifest world for node '{}' must not be empty", node_name),
194 ));
195 }
196 }
197 }
198
199 Ok(())
200}
201
202#[cfg(feature = "std")]
203fn validate_digest(digest: &str) -> GResult<()> {
204 let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
205 GreenticError::new(ErrorCode::InvalidInput, "digest must match sha256:<hex>")
206 })?;
207 if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
208 return Err(GreenticError::new(
209 ErrorCode::InvalidInput,
210 "digest must match sha256:<hex>",
211 ));
212 }
213 Ok(())
214}
215
216#[cfg(all(feature = "std", feature = "serde"))]
217fn json_error(context: &str, err: serde_json::Error) -> GreenticError {
218 GreenticError::new(ErrorCode::InvalidInput, format!("{context}: {err}")).with_source(err)
219}
220
221#[cfg(feature = "std")]
222fn io_error(context: &str, err: std::io::Error) -> GreenticError {
223 let code = match err.kind() {
224 std::io::ErrorKind::NotFound => ErrorCode::NotFound,
225 std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
226 _ => ErrorCode::Unavailable,
227 };
228 GreenticError::new(code, format!("{context}: {err}")).with_source(err)
229}