greentic_dev/
component_add.rs

1use std::convert::TryFrom;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use anyhow::{Context, Result, anyhow, bail};
7use bytes::Bytes;
8use greentic_distributor_client::{
9    DistributorClient, DistributorClientConfig, DistributorEnvironmentId, HttpDistributorClient,
10    ResolveComponentRequest,
11};
12use greentic_pack::builder::ComponentEntry;
13use greentic_types::{EnvId, TenantCtx, TenantId};
14use reqwest::blocking::Client;
15use semver::Version;
16use serde_json::json;
17use tokio::runtime::Runtime;
18
19use crate::config;
20use crate::distributor;
21use crate::pack_init::{
22    PackInitIntent, WorkspaceComponent, WorkspaceManifest, manifest_path, slugify,
23};
24
25#[derive(Debug, Clone)]
26struct SimpleStub {
27    artifact_path: PathBuf,
28    digest: String,
29    signature: greentic_types::distributor::SignatureSummary,
30    cache: greentic_types::distributor::CacheInfo,
31}
32
33pub fn run_component_add(
34    coordinate: &str,
35    profile: Option<&str>,
36    intent: PackInitIntent,
37) -> Result<PathBuf> {
38    let coordinate_path = PathBuf::from(coordinate);
39    if coordinate_path.exists() {
40        return Ok(coordinate_path);
41    }
42
43    let offline = std::env::var("GREENTIC_DEV_OFFLINE")
44        .ok()
45        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
46        .unwrap_or(false);
47    let stubbed_response = load_stubbed_response();
48    if offline && stubbed_response.is_none() {
49        bail!(
50            "offline mode enabled (GREENTIC_DEV_OFFLINE=1) and coordinate `{coordinate}` is not a local path; provide a stub via GREENTIC_DEV_RESOLVE_STUB or use a local component path"
51        );
52    }
53
54    let (component_id, version_req) = parse_coordinate(coordinate)?;
55
56    let response = if let Some(resp) = stubbed_response {
57        resp?
58    } else {
59        let cfg = config::load_with_meta(None)?;
60        let profile = distributor::resolve_profile(&cfg, profile)?;
61        let tenant_ctx = build_tenant_ctx(&profile)?;
62        let environment_id = DistributorEnvironmentId::from(profile.environment_id.as_str());
63        let pack_id = detect_pack_id().unwrap_or_else(|| "greentic-dev-local".to_string());
64
65        let req = ResolveComponentRequest {
66            tenant: tenant_ctx.clone(),
67            environment_id,
68            pack_id,
69            component_id: component_id.clone(),
70            version: version_req.to_string(),
71            extra: json!({ "intent": format!("{:?}", intent) }),
72        };
73
74        let client = http_client(&profile)?;
75        let rt = Runtime::new().context("failed to start tokio runtime for distributor client")?;
76        rt.block_on(client.resolve_component(req))?
77    };
78
79    let artifact_bytes = fetch_artifact(&response.artifact)?;
80    let (cache_dir, cache_path) =
81        write_component_to_cache(&component_id, &version_req, &artifact_bytes)?;
82    update_manifest(coordinate, &component_id, &version_req, &cache_path)?;
83
84    println!(
85        "Resolved {} -> {}@{}",
86        coordinate, component_id, version_req
87    );
88    println!("Cached component at {}", cache_path.display());
89    println!(
90        "Updated workspace manifest at {}",
91        manifest_path()?.display()
92    );
93
94    Ok(cache_dir)
95}
96
97fn parse_coordinate(input: &str) -> Result<(String, String)> {
98    if let Some((id, ver)) = input.rsplit_once('@') {
99        Ok((id.to_string(), ver.to_string()))
100    } else {
101        Ok((input.to_string(), "*".to_string()))
102    }
103}
104
105fn build_tenant_ctx(profile: &distributor::DistributorProfile) -> Result<TenantCtx> {
106    let env = EnvId::from_str(&profile.environment_id)
107        .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
108        .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
109    let tenant = TenantId::from_str(&profile.tenant_id)
110        .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
111        .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
112    Ok(TenantCtx::new(env, tenant))
113}
114
115fn http_client(profile: &distributor::DistributorProfile) -> Result<HttpDistributorClient> {
116    let env_id = EnvId::from_str(&profile.environment_id)
117        .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
118        .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
119    let tenant_id = TenantId::from_str(&profile.tenant_id)
120        .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
121        .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
122    let cfg = DistributorClientConfig {
123        base_url: Some(profile.url.clone()),
124        environment_id: DistributorEnvironmentId::from(profile.environment_id.as_str()),
125        tenant: TenantCtx::new(env_id, tenant_id),
126        auth_token: profile.token.clone(),
127        extra_headers: profile.headers.clone(),
128        request_timeout: None,
129    };
130    HttpDistributorClient::new(cfg).map_err(Into::into)
131}
132
133fn fetch_artifact(location: &greentic_distributor_client::ArtifactLocation) -> Result<Bytes> {
134    match location {
135        greentic_distributor_client::ArtifactLocation::FilePath { path } => {
136            if path.starts_with("http://") || path.starts_with("https://") {
137                let client = Client::new();
138                let resp = client
139                    .get(path)
140                    .send()
141                    .context("failed to download artifact")?;
142                if !resp.status().is_success() {
143                    bail!("artifact download failed with status {}", resp.status());
144                }
145                resp.bytes().map_err(Into::into)
146            } else if let Some(rest) = path.strip_prefix("file://") {
147                fs::read(rest)
148                    .map(Bytes::from)
149                    .with_context(|| format!("failed to read component at {}", rest))
150            } else {
151                fs::read(path)
152                    .map(Bytes::from)
153                    .with_context(|| format!("failed to read component at {}", path))
154            }
155        }
156        greentic_distributor_client::ArtifactLocation::OciReference { reference } => {
157            bail!("OCI component artifacts are not supported yet ({reference})")
158        }
159        greentic_distributor_client::ArtifactLocation::DistributorInternal { handle } => {
160            bail!("Distributor internal artifacts are not supported yet ({handle})")
161        }
162    }
163}
164
165fn write_component_to_cache(
166    component_id: &str,
167    version: &str,
168    bytes: &Bytes,
169) -> Result<(PathBuf, PathBuf)> {
170    let mut path = cache_base_dir()?;
171    let slug = cache_slug_parts(component_id, version);
172    path.push(slug);
173    fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
174    let file_path = path.join("artifact.wasm");
175    fs::write(&file_path, bytes)
176        .with_context(|| format!("failed to write {}", file_path.display()))?;
177    Ok((path, file_path))
178}
179
180fn cache_base_dir() -> Result<PathBuf> {
181    let mut base = std::env::current_dir().context("unable to determine workspace root")?;
182    base.push(".greentic");
183    base.push("components");
184    fs::create_dir_all(&base)
185        .with_context(|| format!("failed to create cache directory {}", base.display()))?;
186    Ok(base)
187}
188
189fn cache_slug_parts(component_id: &str, version: &str) -> String {
190    slugify(&format!("{}-{}", component_id.replace('/', "-"), version))
191}
192
193fn update_manifest(
194    coordinate: &str,
195    component_id: &str,
196    version: &str,
197    wasm_path: &Path,
198) -> Result<()> {
199    let manifest_path = manifest_path()?;
200    let mut manifest: WorkspaceManifest = if manifest_path.exists() {
201        let data = fs::read_to_string(&manifest_path)
202            .with_context(|| format!("failed to read {}", manifest_path.display()))?;
203        serde_json::from_str(&data)
204            .with_context(|| format!("failed to parse {}", manifest_path.display()))?
205    } else {
206        WorkspaceManifest::default()
207    };
208
209    let entry = WorkspaceComponent {
210        coordinate: coordinate.to_string(),
211        entry: ComponentEntry {
212            name: component_id.to_string(),
213            version: Version::parse(version).unwrap_or_else(|_| Version::new(0, 0, 0)),
214            file_wasm: wasm_path.display().to_string(),
215            hash_blake3: String::new(),
216            schema_file: None,
217            manifest_file: None,
218            world: None,
219            capabilities: None,
220        },
221    };
222
223    let mut replaced = false;
224    for existing in manifest.components.iter_mut() {
225        if existing.entry.name == entry.entry.name {
226            *existing = entry.clone();
227            replaced = true;
228            break;
229        }
230    }
231    if !replaced {
232        manifest.components.push(entry);
233    }
234
235    let rendered =
236        serde_json::to_string_pretty(&manifest).context("failed to render workspace manifest")?;
237    fs::write(&manifest_path, rendered)
238        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
239    Ok(())
240}
241
242fn detect_pack_id() -> Option<String> {
243    let candidates = ["pack.toml", "Pack.toml"];
244    for candidate in candidates {
245        let path = Path::new(candidate);
246        if path.exists() {
247            let data = fs::read_to_string(path).ok()?;
248            if let Ok(value) = data.parse::<toml::Value>()
249                && let Some(id) = value.get("pack_id").and_then(|v| v.as_str())
250            {
251                return Some(id.to_string());
252            }
253        }
254    }
255    None
256}
257
258fn load_stubbed_response() -> Option<Result<greentic_distributor_client::ResolveComponentResponse>>
259{
260    let path = std::env::var("GREENTIC_DEV_RESOLVE_STUB").ok()?;
261    let data = fs::read_to_string(&path)
262        .map_err(|err| anyhow!("failed to read stub response {}: {err}", path))
263        .ok()?;
264
265    // First try to parse the real response shape.
266    if let Ok(resp) =
267        serde_json::from_str::<greentic_distributor_client::ResolveComponentResponse>(&data)
268    {
269        return Some(Ok(resp));
270    }
271
272    // Fallback: accept a minimal stub JSON.
273    let parsed = serde_json::from_str::<serde_json::Value>(&data).ok()?;
274    let Some(artifact_path) = parsed
275        .get("artifact_path")
276        .and_then(|v| v.as_str())
277        .map(PathBuf::from)
278    else {
279        return Some(Err(anyhow!(
280            "stub missing `artifact_path` (expected JSON with artifact_path pointing to the component wasm)"
281        )));
282    };
283    let digest = parsed
284        .get("digest")
285        .and_then(|v| v.as_str())
286        .unwrap_or("sha256:stub")
287        .to_string();
288    let signature = greentic_types::distributor::SignatureSummary {
289        verified: false,
290        signer: "stub".to_string(),
291        extra: serde_json::Value::Object(serde_json::Map::new()),
292    };
293    let cache = greentic_types::distributor::CacheInfo {
294        size_bytes: 0,
295        last_used_utc: "stub".to_string(),
296        last_refreshed_utc: "stub".to_string(),
297    };
298    let stub = SimpleStub {
299        artifact_path,
300        digest,
301        signature,
302        cache,
303    };
304
305    Some(Ok(to_resolve_response(stub)))
306}
307
308fn to_resolve_response(stub: SimpleStub) -> greentic_distributor_client::ResolveComponentResponse {
309    greentic_distributor_client::ResolveComponentResponse {
310        status: greentic_types::distributor::ComponentStatus::Ready,
311        digest: greentic_types::distributor::ComponentDigest(stub.digest),
312        artifact: greentic_distributor_client::ArtifactLocation::FilePath {
313            path: stub.artifact_path.display().to_string(),
314        },
315        signature: stub.signature,
316        cache: stub.cache,
317        secret_requirements: None,
318    }
319}