use alloc::collections::BTreeMap;
use alloc::format;
use alloc::string::String;
#[cfg(feature = "schemars")]
use schemars::JsonSchema;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::{ErrorCode, GResult, GreenticError};
pub const FLOW_RESOLVE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "schemars",
derive(JsonSchema),
schemars(
title = "Greentic Flow Resolve v1",
description = "Component source references for flow nodes.",
rename = "greentic.flow.resolve.v1"
)
)]
pub struct FlowResolveV1 {
pub schema_version: u32,
pub flow: String,
pub nodes: BTreeMap<String, NodeResolveV1>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub struct NodeResolveV1 {
pub source: ComponentSourceRefV1,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub mode: Option<ResolveModeV1>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub enum ResolveModeV1 {
Tracked,
Pinned,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
#[cfg_attr(feature = "schemars", derive(JsonSchema))]
pub enum ComponentSourceRefV1 {
Local {
path: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
digest: Option<String>,
},
Oci {
r#ref: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
digest: Option<String>,
},
Repo {
r#ref: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
digest: Option<String>,
},
Store {
r#ref: String,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
digest: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
license_hint: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
meter: Option<bool>,
},
}
#[cfg(feature = "std")]
use std::ffi::OsString;
#[cfg(feature = "std")]
use std::fs;
#[cfg(feature = "std")]
use std::path::{Path, PathBuf};
#[cfg(feature = "std")]
pub fn sidecar_path_for_flow(flow_path: &Path) -> PathBuf {
let file_name = flow_path
.file_name()
.map(OsString::from)
.unwrap_or_else(|| OsString::from("flow.ygtc"));
let mut sidecar_name = file_name;
sidecar_name.push(".resolve.json");
flow_path.with_file_name(sidecar_name)
}
#[cfg(all(feature = "std", feature = "serde"))]
pub fn read_flow_resolve(path: &Path) -> GResult<FlowResolveV1> {
let raw = fs::read_to_string(path).map_err(|err| io_error("read flow resolve", err))?;
let doc: FlowResolveV1 =
serde_json::from_str(&raw).map_err(|err| json_error("parse flow resolve", err))?;
validate_flow_resolve(&doc)?;
Ok(doc)
}
#[cfg(all(feature = "std", feature = "serde"))]
pub fn write_flow_resolve(path: &Path, doc: &FlowResolveV1) -> GResult<()> {
validate_flow_resolve(doc)?;
let raw = serde_json::to_string_pretty(doc)
.map_err(|err| json_error("serialize flow resolve", err))?;
fs::write(path, raw).map_err(|err| io_error("write flow resolve", err))?;
Ok(())
}
#[cfg(feature = "std")]
pub fn validate_flow_resolve(doc: &FlowResolveV1) -> GResult<()> {
if doc.schema_version != FLOW_RESOLVE_SCHEMA_VERSION {
return Err(GreenticError::new(
ErrorCode::InvalidInput,
format!(
"flow resolve schema_version must be {}",
FLOW_RESOLVE_SCHEMA_VERSION
),
));
}
for (node_name, node) in &doc.nodes {
match &node.source {
ComponentSourceRefV1::Local { path, digest } => {
if Path::new(path).is_absolute() {
return Err(GreenticError::new(
ErrorCode::InvalidInput,
format!(
"local component path for node '{}' must be relative",
node_name
),
));
}
if let Some(value) = digest {
validate_digest(value)?;
}
}
ComponentSourceRefV1::Oci { digest, .. }
| ComponentSourceRefV1::Repo { digest, .. }
| ComponentSourceRefV1::Store { digest, .. } => {
if let Some(value) = digest {
validate_digest(value)?;
}
}
}
}
Ok(())
}
#[cfg(feature = "std")]
fn validate_digest(digest: &str) -> GResult<()> {
let hex = digest.strip_prefix("sha256:").ok_or_else(|| {
GreenticError::new(ErrorCode::InvalidInput, "digest must match sha256:<hex>")
})?;
if hex.is_empty() || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(GreenticError::new(
ErrorCode::InvalidInput,
"digest must match sha256:<hex>",
));
}
Ok(())
}
#[cfg(all(feature = "std", feature = "serde"))]
fn json_error(context: &str, err: serde_json::Error) -> GreenticError {
GreenticError::new(ErrorCode::InvalidInput, format!("{context}: {err}")).with_source(err)
}
#[cfg(feature = "std")]
fn io_error(context: &str, err: std::io::Error) -> GreenticError {
let code = match err.kind() {
std::io::ErrorKind::NotFound => ErrorCode::NotFound,
std::io::ErrorKind::PermissionDenied => ErrorCode::PermissionDenied,
_ => ErrorCode::Unavailable,
};
GreenticError::new(code, format!("{context}: {err}")).with_source(err)
}