Skip to main content

packc/cli/
resolve.rs

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    /// Pack root directory containing pack.yaml.
21    #[arg(long = "in", value_name = "DIR", default_value = ".")]
22    pub input: PathBuf,
23
24    /// Output path for pack.lock.json (default: pack.lock.json under pack root).
25    #[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}