Skip to main content

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    /// Extension-sourced component reference: the runtime component embedded in
146    /// an extension's `.gtxpack` (resolved by packc to a `Local` embed at build).
147    /// Ref form: `ext://<extension-id>#component`.
148    Ext {
149        /// Extension component reference (`ext://<id>#component`).
150        r#ref: String,
151        /// Optional pinned digest of the embedded component wasm.
152        #[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/// Returns the expected sidecar path for a flow file.
168#[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/// Reads a flow resolve sidecar from disk and validates it.
180#[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/// Writes a flow resolve sidecar to disk after validation.
190#[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/// Validates a flow resolve document.
200#[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}