1use alloc::collections::BTreeMap;
22use alloc::format;
23use alloc::string::String;
24
25#[cfg(feature = "schemars")]
26use schemars::JsonSchema;
27#[cfg(feature = "serde")]
28use serde::{Deserialize, Serialize};
29
30use crate::{ErrorCode, GResult, GreenticError};
31
32pub const FLOW_RESOLVE_SCHEMA_VERSION: u32 = 1;
34
35#[derive(Clone, Debug, PartialEq, Eq)]
37#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
38#[cfg_attr(
39 feature = "schemars",
40 derive(JsonSchema),
41 schemars(
42 title = "Greentic Flow Resolve v1",
43 description = "Component source references for flow nodes.",
44 rename = "greentic.flow.resolve.v1"
45 )
46)]
47pub struct FlowResolveV1 {
48 pub schema_version: u32,
50 pub flow: String,
52 pub nodes: BTreeMap<String, NodeResolveV1>,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
59#[cfg_attr(feature = "schemars", derive(JsonSchema))]
60pub struct NodeResolveV1 {
61 pub source: ComponentSourceRefV1,
63 #[cfg_attr(
65 feature = "serde",
66 serde(default, skip_serializing_if = "Option::is_none")
67 )]
68 pub mode: Option<ResolveModeV1>,
69}
70
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
74#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
75#[cfg_attr(feature = "schemars", derive(JsonSchema))]
76pub enum ResolveModeV1 {
77 Tracked,
79 Pinned,
81}
82
83#[derive(Clone, Debug, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
86#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
87#[cfg_attr(feature = "schemars", derive(JsonSchema))]
88pub enum ComponentSourceRefV1 {
89 Local {
91 path: String,
93 #[cfg_attr(
95 feature = "serde",
96 serde(default, skip_serializing_if = "Option::is_none")
97 )]
98 digest: Option<String>,
99 },
100 Oci {
102 r#ref: String,
104 #[cfg_attr(
106 feature = "serde",
107 serde(default, skip_serializing_if = "Option::is_none")
108 )]
109 digest: Option<String>,
110 },
111 Repo {
113 r#ref: String,
115 #[cfg_attr(
117 feature = "serde",
118 serde(default, skip_serializing_if = "Option::is_none")
119 )]
120 digest: Option<String>,
121 },
122 Store {
124 r#ref: String,
126 #[cfg_attr(
128 feature = "serde",
129 serde(default, skip_serializing_if = "Option::is_none")
130 )]
131 digest: Option<String>,
132 #[cfg_attr(
134 feature = "serde",
135 serde(default, skip_serializing_if = "Option::is_none")
136 )]
137 license_hint: Option<String>,
138 #[cfg_attr(
140 feature = "serde",
141 serde(default, skip_serializing_if = "Option::is_none")
142 )]
143 meter: Option<bool>,
144 },
145 Ext {
149 r#ref: String,
151 #[cfg_attr(
153 feature = "serde",
154 serde(default, skip_serializing_if = "Option::is_none")
155 )]
156 digest: Option<String>,
157 },
158}
159
160#[cfg(feature = "std")]
161use std::ffi::OsString;
162#[cfg(feature = "std")]
163use std::fs;
164#[cfg(feature = "std")]
165use std::path::{Path, PathBuf};
166
167#[cfg(feature = "std")]
169pub fn sidecar_path_for_flow(flow_path: &Path) -> PathBuf {
170 let file_name = flow_path
171 .file_name()
172 .map(OsString::from)
173 .unwrap_or_else(|| OsString::from("flow.ygtc"));
174 let mut sidecar_name = file_name;
175 sidecar_name.push(".resolve.json");
176 flow_path.with_file_name(sidecar_name)
177}
178
179#[cfg(all(feature = "std", feature = "serde"))]
181pub fn read_flow_resolve(path: &Path) -> GResult<FlowResolveV1> {
182 let raw = fs::read_to_string(path).map_err(|err| io_error("read flow resolve", err))?;
183 let doc: FlowResolveV1 =
184 serde_json::from_str(&raw).map_err(|err| json_error("parse flow resolve", err))?;
185 validate_flow_resolve(&doc)?;
186 Ok(doc)
187}
188
189#[cfg(all(feature = "std", feature = "serde"))]
191pub fn write_flow_resolve(path: &Path, doc: &FlowResolveV1) -> GResult<()> {
192 validate_flow_resolve(doc)?;
193 let raw = serde_json::to_string_pretty(doc)
194 .map_err(|err| json_error("serialize flow resolve", err))?;
195 fs::write(path, raw).map_err(|err| io_error("write flow resolve", err))?;
196 Ok(())
197}
198
199#[cfg(feature = "std")]
201pub fn validate_flow_resolve(doc: &FlowResolveV1) -> GResult<()> {
202 if doc.schema_version != FLOW_RESOLVE_SCHEMA_VERSION {
203 return Err(GreenticError::new(
204 ErrorCode::InvalidInput,
205 format!(
206 "flow resolve schema_version must be {}",
207 FLOW_RESOLVE_SCHEMA_VERSION
208 ),
209 ));
210 }
211
212 for (node_name, node) in &doc.nodes {
213 match &node.source {
214 ComponentSourceRefV1::Local { path, digest } => {
215 if Path::new(path).is_absolute() {
216 return Err(GreenticError::new(
217 ErrorCode::InvalidInput,
218 format!(
219 "local component path for node '{}' must be relative",
220 node_name
221 ),
222 ));
223 }
224 if let Some(value) = digest {
225 validate_digest(value)?;
226 }
227 }
228 ComponentSourceRefV1::Oci { digest, .. }
229 | ComponentSourceRefV1::Repo { digest, .. }
230 | ComponentSourceRefV1::Store { digest, .. }
231 | ComponentSourceRefV1::Ext { digest, .. } => {
232 if let Some(value) = digest {
233 validate_digest(value)?;
234 }
235 }
236 }
237 }
238
239 Ok(())
240}
241
242#[cfg(feature = "std")]
243fn validate_digest(digest: &str) -> GResult<()> {
244 let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
245 GreenticError::new(ErrorCode::InvalidInput, "digest must match sha256:<hex>")
246 })?;
247 if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
248 return Err(GreenticError::new(
249 ErrorCode::InvalidInput,
250 "digest must match sha256:<hex>",
251 ));
252 }
253 Ok(())
254}
255
256#[cfg(all(feature = "std", feature = "serde"))]
257fn json_error(context: &str, err: serde_json::Error) -> GreenticError {
258 GreenticError::new(ErrorCode::InvalidInput, format!("{context}: {err}")).with_source(err)
259}
260
261#[cfg(feature = "std")]
262fn io_error(context: &str, err: std::io::Error) -> GreenticError {
263 let code = match err.kind() {
264 std::io::ErrorKind::NotFound => ErrorCode::NotFound,
265 std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
266 _ => ErrorCode::Unavailable,
267 };
268 GreenticError::new(code, format!("{context}: {err}")).with_source(err)
269}