greentic_types/
flow_resolve.rs

1//! Canonical flow resolve sidecar types and helpers.
2//!
3//! # JSON shape (v1)
4//! ```json
5//! {
6//!   "schema_version": 1,
7//!   "flow": "main.ygtc",
8//!   "nodes": {
9//!     "fetch": {
10//!       "source": {
11//!         "kind": "oci",
12//!         "ref": "ghcr.io/greentic/demo/component:1.2.3",
13//!         "digest": "sha256:deadbeef"
14//!       },
15//!       "mode": "pinned"
16//!     }
17//!   }
18//! }
19//! ```
20
21use 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
32/// Current schema version for flow resolve sidecars.
33pub const FLOW_RESOLVE_SCHEMA_VERSION: u32 = 1;
34
35/// Flow resolve sidecar (v1).
36#[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    /// Schema version (must be 1).
49    pub schema_version: u32,
50    /// Flow file basename (for example `main.ygtc`).
51    pub flow: String,
52    /// Resolve metadata keyed by node name.
53    pub nodes: BTreeMap<String, NodeResolveV1>,
54}
55
56/// Resolve metadata for a flow node.
57#[derive(Clone, Debug, PartialEq, Eq)]
58#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
59#[cfg_attr(feature = "schemars", derive(JsonSchema))]
60pub struct NodeResolveV1 {
61    /// Component source reference.
62    pub source: ComponentSourceRefV1,
63    /// Optional resolve mode hint.
64    #[cfg_attr(
65        feature = "serde",
66        serde(default, skip_serializing_if = "Option::is_none")
67    )]
68    pub mode: Option<ResolveModeV1>,
69}
70
71/// Resolve mode hints for sidecars.
72#[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    /// Follows moving references.
78    Tracked,
79    /// Uses pinned digest only.
80    Pinned,
81}
82
83/// Component source references for flow resolve sidecars.
84#[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 wasm path relative to the flow file.
90    Local {
91        /// Relative path to the wasm artifact.
92        path: String,
93        /// Optional pinned digest.
94        #[cfg_attr(
95            feature = "serde",
96            serde(default, skip_serializing_if = "Option::is_none")
97        )]
98        digest: Option<String>,
99    },
100    /// OCI component reference.
101    Oci {
102        /// OCI reference.
103        r#ref: String,
104        /// Optional pinned digest.
105        #[cfg_attr(
106            feature = "serde",
107            serde(default, skip_serializing_if = "Option::is_none")
108        )]
109        digest: Option<String>,
110    },
111    /// Repository component reference.
112    Repo {
113        /// Repository reference.
114        r#ref: String,
115        /// Optional pinned digest.
116        #[cfg_attr(
117            feature = "serde",
118            serde(default, skip_serializing_if = "Option::is_none")
119        )]
120        digest: Option<String>,
121    },
122    /// Store component reference.
123    Store {
124        /// Store reference.
125        r#ref: String,
126        /// Optional pinned digest.
127        #[cfg_attr(
128            feature = "serde",
129            serde(default, skip_serializing_if = "Option::is_none")
130        )]
131        digest: Option<String>,
132        /// Optional license hint for store resolution.
133        #[cfg_attr(
134            feature = "serde",
135            serde(default, skip_serializing_if = "Option::is_none")
136        )]
137        license_hint: Option<String>,
138        /// Optional meter flag for usage tracking.
139        #[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/// Returns the expected sidecar path for a flow file.
155#[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/// Reads a flow resolve sidecar from disk and validates it.
167#[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/// Writes a flow resolve sidecar to disk after validation.
177#[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/// Validates a flow resolve document.
187#[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}