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
25pub fn run_component_add(
26    coordinate: &str,
27    profile: Option<&str>,
28    intent: PackInitIntent,
29) -> Result<PathBuf> {
30    let cfg = config::load()?;
31    let profile = distributor::resolve_profile(&cfg, profile)?;
32    let (component_id, version_req) = parse_coordinate(coordinate)?;
33    let tenant_ctx = build_tenant_ctx(&profile)?;
34    let environment_id = DistributorEnvironmentId::from(profile.environment_id.as_str());
35    let pack_id = detect_pack_id().unwrap_or_else(|| "greentic-dev-local".to_string());
36
37    let req = ResolveComponentRequest {
38        tenant: tenant_ctx.clone(),
39        environment_id,
40        pack_id,
41        component_id: component_id.clone(),
42        version: version_req.to_string(),
43        extra: json!({ "intent": format!("{:?}", intent) }),
44    };
45
46    let client = http_client(&profile)?;
47    let rt = Runtime::new().context("failed to start tokio runtime for distributor client")?;
48    let response = rt.block_on(client.resolve_component(req))?;
49
50    let artifact_bytes = fetch_artifact(&response.artifact)?;
51    let (cache_dir, cache_path) =
52        write_component_to_cache(&component_id, &version_req, &artifact_bytes)?;
53    update_manifest(coordinate, &component_id, &version_req, &cache_path)?;
54
55    println!(
56        "Resolved {} -> {}@{}",
57        coordinate, component_id, version_req
58    );
59    println!("Cached component at {}", cache_path.display());
60    println!(
61        "Updated workspace manifest at {}",
62        manifest_path()?.display()
63    );
64
65    Ok(cache_dir)
66}
67
68fn parse_coordinate(input: &str) -> Result<(String, String)> {
69    if let Some((id, ver)) = input.rsplit_once('@') {
70        Ok((id.to_string(), ver.to_string()))
71    } else {
72        Ok((input.to_string(), "*".to_string()))
73    }
74}
75
76fn build_tenant_ctx(profile: &distributor::DistributorProfile) -> Result<TenantCtx> {
77    let env = EnvId::from_str(&profile.environment_id)
78        .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
79        .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
80    let tenant = TenantId::from_str(&profile.tenant_id)
81        .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
82        .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
83    Ok(TenantCtx::new(env, tenant))
84}
85
86fn http_client(profile: &distributor::DistributorProfile) -> Result<HttpDistributorClient> {
87    let env_id = EnvId::from_str(&profile.environment_id)
88        .or_else(|_| EnvId::try_from(profile.environment_id.as_str()))
89        .map_err(|err| anyhow!("invalid environment id `{}`: {err}", profile.environment_id))?;
90    let tenant_id = TenantId::from_str(&profile.tenant_id)
91        .or_else(|_| TenantId::try_from(profile.tenant_id.as_str()))
92        .map_err(|err| anyhow!("invalid tenant id `{}`: {err}", profile.tenant_id))?;
93    let cfg = DistributorClientConfig {
94        base_url: Some(profile.url.clone()),
95        environment_id: DistributorEnvironmentId::from(profile.environment_id.as_str()),
96        tenant: TenantCtx::new(env_id, tenant_id),
97        auth_token: profile.token.clone(),
98        extra_headers: profile.headers.clone(),
99        request_timeout: None,
100    };
101    HttpDistributorClient::new(cfg).map_err(Into::into)
102}
103
104fn fetch_artifact(location: &greentic_distributor_client::ArtifactLocation) -> Result<Bytes> {
105    match location {
106        greentic_distributor_client::ArtifactLocation::FilePath { path } => {
107            if path.starts_with("http://") || path.starts_with("https://") {
108                let client = Client::new();
109                let resp = client
110                    .get(path)
111                    .send()
112                    .context("failed to download artifact")?;
113                if !resp.status().is_success() {
114                    bail!("artifact download failed with status {}", resp.status());
115                }
116                resp.bytes().map_err(Into::into)
117            } else if let Some(rest) = path.strip_prefix("file://") {
118                fs::read(rest)
119                    .map(Bytes::from)
120                    .with_context(|| format!("failed to read component at {}", rest))
121            } else {
122                fs::read(path)
123                    .map(Bytes::from)
124                    .with_context(|| format!("failed to read component at {}", path))
125            }
126        }
127        greentic_distributor_client::ArtifactLocation::OciReference { reference } => {
128            bail!("OCI component artifacts are not supported yet ({reference})")
129        }
130        greentic_distributor_client::ArtifactLocation::DistributorInternal { handle } => {
131            bail!("Distributor internal artifacts are not supported yet ({handle})")
132        }
133    }
134}
135
136fn write_component_to_cache(
137    component_id: &str,
138    version: &str,
139    bytes: &Bytes,
140) -> Result<(PathBuf, PathBuf)> {
141    let mut path = cache_base_dir()?;
142    let slug = cache_slug_parts(component_id, version);
143    path.push(slug);
144    fs::create_dir_all(&path).with_context(|| format!("failed to create {}", path.display()))?;
145    let file_path = path.join("artifact.wasm");
146    fs::write(&file_path, bytes)
147        .with_context(|| format!("failed to write {}", file_path.display()))?;
148    Ok((path, file_path))
149}
150
151fn cache_base_dir() -> Result<PathBuf> {
152    let mut base = std::env::current_dir().context("unable to determine workspace root")?;
153    base.push(".greentic");
154    base.push("components");
155    fs::create_dir_all(&base)
156        .with_context(|| format!("failed to create cache directory {}", base.display()))?;
157    Ok(base)
158}
159
160fn cache_slug_parts(component_id: &str, version: &str) -> String {
161    slugify(&format!("{}-{}", component_id.replace('/', "-"), version))
162}
163
164fn update_manifest(
165    coordinate: &str,
166    component_id: &str,
167    version: &str,
168    wasm_path: &Path,
169) -> Result<()> {
170    let manifest_path = manifest_path()?;
171    let mut manifest: WorkspaceManifest = if manifest_path.exists() {
172        let data = fs::read_to_string(&manifest_path)
173            .with_context(|| format!("failed to read {}", manifest_path.display()))?;
174        serde_json::from_str(&data)
175            .with_context(|| format!("failed to parse {}", manifest_path.display()))?
176    } else {
177        WorkspaceManifest::default()
178    };
179
180    let entry = WorkspaceComponent {
181        coordinate: coordinate.to_string(),
182        entry: ComponentEntry {
183            name: component_id.to_string(),
184            version: Version::parse(version).unwrap_or_else(|_| Version::new(0, 0, 0)),
185            file_wasm: wasm_path.display().to_string(),
186            hash_blake3: String::new(),
187            schema_file: None,
188            manifest_file: None,
189            world: None,
190            capabilities: None,
191        },
192    };
193
194    let mut replaced = false;
195    for existing in manifest.components.iter_mut() {
196        if existing.entry.name == entry.entry.name {
197            *existing = entry.clone();
198            replaced = true;
199            break;
200        }
201    }
202    if !replaced {
203        manifest.components.push(entry);
204    }
205
206    let rendered =
207        serde_json::to_string_pretty(&manifest).context("failed to render workspace manifest")?;
208    fs::write(&manifest_path, rendered)
209        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
210    Ok(())
211}
212
213fn detect_pack_id() -> Option<String> {
214    let candidates = ["pack.toml", "Pack.toml"];
215    for candidate in candidates {
216        let path = Path::new(candidate);
217        if path.exists() {
218            let data = fs::read_to_string(path).ok()?;
219            if let Ok(value) = data.parse::<toml::Value>()
220                && let Some(id) = value.get("pack_id").and_then(|v| v.as_str())
221            {
222                return Some(id.to_string());
223            }
224        }
225    }
226    None
227}