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}
146
147#[cfg(feature = "std")]
148use std::ffi::OsString;
149#[cfg(feature = "std")]
150use std::fs;
151#[cfg(feature = "std")]
152use std::path::{Path, PathBuf};
153
154#[cfg(feature = "std")]
156pub fn sidecar_path_for_flow(flow_path: &Path) -> PathBuf {
157 let file_name = flow_path
158 .file_name()
159 .map(OsString::from)
160 .unwrap_or_else(|| OsString::from("flow.ygtc"));
161 let mut sidecar_name = file_name;
162 sidecar_name.push(".resolve.json");
163 flow_path.with_file_name(sidecar_name)
164}
165
166#[cfg(all(feature = "std", feature = "serde"))]
168pub fn read_flow_resolve(path: &Path) -> GResult<FlowResolveV1> {
169 let raw = fs::read_to_string(path).map_err(|err| io_error("read flow resolve", err))?;
170 let doc: FlowResolveV1 =
171 serde_json::from_str(&raw).map_err(|err| json_error("parse flow resolve", err))?;
172 validate_flow_resolve(&doc)?;
173 Ok(doc)
174}
175
176#[cfg(all(feature = "std", feature = "serde"))]
178pub fn write_flow_resolve(path: &Path, doc: &FlowResolveV1) -> GResult<()> {
179 validate_flow_resolve(doc)?;
180 let raw = serde_json::to_string_pretty(doc)
181 .map_err(|err| json_error("serialize flow resolve", err))?;
182 fs::write(path, raw).map_err(|err| io_error("write flow resolve", err))?;
183 Ok(())
184}
185
186#[cfg(feature = "std")]
188pub fn validate_flow_resolve(doc: &FlowResolveV1) -> GResult<()> {
189 if doc.schema_version != FLOW_RESOLVE_SCHEMA_VERSION {
190 return Err(GreenticError::new(
191 ErrorCode::InvalidInput,
192 format!(
193 "flow resolve schema_version must be {}",
194 FLOW_RESOLVE_SCHEMA_VERSION
195 ),
196 ));
197 }
198
199 for (node_name, node) in &doc.nodes {
200 match &node.source {
201 ComponentSourceRefV1::Local { path, digest } => {
202 if Path::new(path).is_absolute() {
203 return Err(GreenticError::new(
204 ErrorCode::InvalidInput,
205 format!(
206 "local component path for node '{}' must be relative",
207 node_name
208 ),
209 ));
210 }
211 if let Some(value) = digest {
212 validate_digest(value)?;
213 }
214 }
215 ComponentSourceRefV1::Oci { digest, .. }
216 | ComponentSourceRefV1::Repo { digest, .. }
217 | ComponentSourceRefV1::Store { digest, .. } => {
218 if let Some(value) = digest {
219 validate_digest(value)?;
220 }
221 }
222 }
223 }
224
225 Ok(())
226}
227
228#[cfg(feature = "std")]
229fn validate_digest(digest: &str) -> GResult<()> {
230 let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
231 GreenticError::new(ErrorCode::InvalidInput, "digest must match sha256:<hex>")
232 })?;
233 if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
234 return Err(GreenticError::new(
235 ErrorCode::InvalidInput,
236 "digest must match sha256:<hex>",
237 ));
238 }
239 Ok(())
240}
241
242#[cfg(all(feature = "std", feature = "serde"))]
243fn json_error(context: &str, err: serde_json::Error) -> GreenticError {
244 GreenticError::new(ErrorCode::InvalidInput, format!("{context}: {err}")).with_source(err)
245}
246
247#[cfg(feature = "std")]
248fn io_error(context: &str, err: std::io::Error) -> GreenticError {
249 let code = match err.kind() {
250 std::io::ErrorKind::NotFound => ErrorCode::NotFound,
251 std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
252 _ => ErrorCode::Unavailable,
253 };
254 GreenticError::new(code, format!("{context}: {err}")).with_source(err)
255}