1#![forbid(unsafe_code)]
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow};
8use clap::Args;
9use greentic_distributor_client::{DistClient, DistOptions};
10use greentic_pack::pack_lock::{LockedComponent, PackLockV1, write_pack_lock};
11use greentic_types::flow_resolve::{ComponentSourceRefV1, FlowResolveV1};
12use sha2::{Digest, Sha256};
13
14use crate::config::load_pack_config;
15use crate::flow_resolve::discover_flow_resolves;
16use crate::runtime::{NetworkPolicy, RuntimeContext};
17
18#[derive(Debug, Args)]
19pub struct ResolveArgs {
20 #[arg(long = "in", value_name = "DIR", default_value = ".")]
22 pub input: PathBuf,
23
24 #[arg(long = "lock", value_name = "FILE")]
26 pub lock: Option<PathBuf>,
27}
28
29pub async fn handle(args: ResolveArgs, runtime: &RuntimeContext, emit_path: bool) -> Result<()> {
30 let pack_dir = args
31 .input
32 .canonicalize()
33 .with_context(|| format!("failed to resolve pack dir {}", args.input.display()))?;
34 let lock_path = resolve_lock_path(&pack_dir, args.lock.as_deref());
35
36 let config = load_pack_config(&pack_dir)?;
37 let resolves = discover_flow_resolves(&pack_dir, &config.flows);
38
39 for entry in &resolves {
40 if let Some(msg) = &entry.warning {
41 eprintln!("warning: {msg}");
42 }
43 }
44
45 let mut entries: Vec<LockedComponent> = Vec::new();
46 let dist = new_dist_client(runtime);
47
48 for sidecar in resolves {
49 let Some(doc) = sidecar.document else {
50 continue;
51 };
52 collect_from_sidecar(
53 &sidecar.flow_path,
54 &sidecar.flow_id,
55 &doc,
56 &dist,
57 runtime,
58 &mut entries,
59 )
60 .await?;
61 }
62
63 let lock = PackLockV1::new(entries);
64 write_pack_lock(&lock_path, &lock)?;
65 if emit_path {
66 eprintln!("wrote {}", lock_path.display());
67 }
68
69 Ok(())
70}
71
72fn resolve_lock_path(pack_dir: &Path, override_path: Option<&Path>) -> PathBuf {
73 match override_path {
74 Some(path) if path.is_absolute() => path.to_path_buf(),
75 Some(path) => pack_dir.join(path),
76 None => pack_dir.join("pack.lock.json"),
77 }
78}
79
80fn new_dist_client(runtime: &RuntimeContext) -> DistClient {
81 let opts = DistOptions {
82 cache_dir: runtime.cache_dir(),
83 allow_tags: true,
84 offline: runtime.network_policy() == NetworkPolicy::Offline,
85 };
86 DistClient::new(opts)
87}
88
89async fn collect_from_sidecar(
90 flow_path: &Path,
91 flow_id: &str,
92 doc: &FlowResolveV1,
93 dist: &DistClient,
94 runtime: &RuntimeContext,
95 out: &mut Vec<LockedComponent>,
96) -> Result<()> {
97 let mut seen: BTreeMap<String, (String, String)> = BTreeMap::new();
98
99 for (node, resolve) in &doc.nodes {
100 let name = format!("{flow_id}___{node}");
101 let source_ref = &resolve.source;
102 let (reference, digest) = match source_ref {
103 ComponentSourceRefV1::Local { path, digest } => {
104 let abs = normalize_local(flow_path, path)?;
105 let digest = match digest {
106 Some(d) => d.clone(),
107 None => compute_sha256(&abs)?,
108 };
109 (format!("file://{}", abs.to_string_lossy()), digest)
110 }
111 ComponentSourceRefV1::Oci { r#ref, digest }
112 | ComponentSourceRefV1::Repo { r#ref, digest }
113 | ComponentSourceRefV1::Store { r#ref, digest, .. } => {
114 if let Some(d) = digest {
115 (format_reference(source_ref), d.clone())
116 } else {
117 runtime.require_online("resolve component refs")?;
118 let resolved = dist
119 .resolve_ref(&format_reference(source_ref))
120 .await
121 .map_err(|err| anyhow!("resolve {} failed: {}", r#ref, err))?;
122 (format_reference(source_ref), resolved.digest)
123 }
124 }
125 };
126 seen.insert(name, (reference, digest));
127 }
128
129 for (name, (reference, digest)) in seen {
130 out.push(LockedComponent {
131 name,
132 r#ref: reference,
133 digest,
134 });
135 }
136
137 Ok(())
138}
139
140fn normalize_local(flow_path: &Path, rel: &str) -> Result<PathBuf> {
141 let parent = flow_path
142 .parent()
143 .ok_or_else(|| anyhow!("flow path {} has no parent", flow_path.display()))?;
144 let rel = rel.strip_prefix("file://").unwrap_or(rel);
145 Ok(parent.join(rel))
146}
147
148fn compute_sha256(path: &Path) -> Result<String> {
149 let bytes = fs::read(path)
150 .with_context(|| format!("failed to read local component {}", path.display()))?;
151 let mut sha = Sha256::new();
152 sha.update(&bytes);
153 Ok(format!("sha256:{:x}", sha.finalize()))
154}
155
156fn format_reference(source: &ComponentSourceRefV1) -> String {
157 match source {
158 ComponentSourceRefV1::Local { path, .. } => path.clone(),
159 ComponentSourceRefV1::Oci { r#ref, .. } => {
160 if r#ref.contains("://") {
161 r#ref.clone()
162 } else {
163 format!("oci://{}", r#ref)
164 }
165 }
166 ComponentSourceRefV1::Repo { r#ref, .. } => {
167 if r#ref.contains("://") {
168 r#ref.clone()
169 } else {
170 format!("repo://{}", r#ref)
171 }
172 }
173 ComponentSourceRefV1::Store { r#ref, .. } => {
174 if r#ref.contains("://") {
175 r#ref.clone()
176 } else {
177 format!("store://{}", r#ref)
178 }
179 }
180 }
181}