greentic_dev/
distributor.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use anyhow::{Context, Result, bail};
5use reqwest::blocking::Client;
6use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
7
8use crate::config::{
9    DefaultProfileSelection, DistributorProfileConfig, GreenticConfig, LoadedGreenticConfig,
10};
11
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum DevIntent {
15    Dev,
16    Runtime,
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum DevArtifactKind {
22    Component,
23    Pack,
24}
25
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub struct DevResolveRequest {
28    pub coordinate: String,
29    pub intent: DevIntent,
30    pub platform: Option<String>,
31    pub features: Vec<String>,
32}
33
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum DevLicenseType {
37    Free,
38    Commercial,
39    Trial,
40}
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
43pub struct DevLicenseInfo {
44    pub license_type: DevLicenseType,
45    pub id: Option<String>,
46    pub requires_acceptance: bool,
47    pub checkout_url: Option<String>,
48}
49
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct DevResolveResponse {
52    pub kind: DevArtifactKind,
53    pub name: String,
54    pub version: String,
55    pub coordinate: String,
56    pub artifact_id: String,
57    pub artifact_download_path: String,
58    pub digest: Option<String>,
59    pub license: DevLicenseInfo,
60    pub metadata: serde_json::Value,
61}
62
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct DevLicenseRequiredErrorBody {
65    pub error: String,
66    pub coordinate: String,
67    pub message: String,
68    pub checkout_url: String,
69}
70
71#[derive(Debug)]
72pub enum DevDistributorError {
73    Http(reqwest::Error),
74    LicenseRequired(DevLicenseRequiredErrorBody),
75    Status(reqwest::StatusCode, Option<String>),
76    InvalidResponse(anyhow::Error),
77}
78
79impl std::fmt::Display for DevDistributorError {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            DevDistributorError::Http(err) => write!(f, "http error: {err}"),
83            DevDistributorError::LicenseRequired(body) => {
84                write!(f, "{} (checkout: {})", body.message, body.checkout_url)
85            }
86            DevDistributorError::Status(code, body) => {
87                if let Some(body) = body {
88                    write!(f, "unexpected status {code}: {body}")
89                } else {
90                    write!(f, "unexpected status {code}")
91                }
92            }
93            DevDistributorError::InvalidResponse(err) => write!(f, "invalid response: {err}"),
94        }
95    }
96}
97
98impl std::error::Error for DevDistributorError {}
99
100impl From<reqwest::Error> for DevDistributorError {
101    fn from(value: reqwest::Error) -> Self {
102        DevDistributorError::Http(value)
103    }
104}
105
106#[derive(Debug, Clone)]
107pub struct DistributorProfile {
108    pub name: String,
109    pub url: String,
110    pub token: Option<String>,
111    pub tenant_id: String,
112    pub environment_id: String,
113    pub headers: Option<HashMap<String, String>>,
114}
115
116impl DistributorProfile {
117    fn from_pair(name: &str, cfg: &DistributorProfileConfig) -> Result<Self> {
118        let token = resolve_token(cfg.token.clone())?;
119        let base_url = cfg
120            .base_url
121            .as_ref()
122            .or(cfg.url.as_ref())
123            .map(|s| s.trim_end_matches('/').to_string())
124            .unwrap_or_else(|| "http://localhost:8080".to_string());
125        let tenant_id = cfg.tenant_id.clone().unwrap_or_else(|| "local".to_string());
126        let environment_id = cfg
127            .environment_id
128            .clone()
129            .unwrap_or_else(|| "dev".to_string());
130        Ok(Self {
131            name: cfg.name.clone().unwrap_or_else(|| name.to_string()),
132            url: base_url,
133            token,
134            tenant_id,
135            environment_id,
136            headers: cfg.headers.clone(),
137        })
138    }
139}
140
141pub fn resolve_profile(
142    config: &LoadedGreenticConfig,
143    profile_arg: Option<&str>,
144) -> Result<DistributorProfile> {
145    let env_profile = std::env::var("GREENTIC_DISTRIBUTOR_PROFILE").ok();
146    let map: HashMap<String, DistributorProfileConfig> = config.config.distributor_profiles();
147    let selection = select_profile(profile_arg, env_profile.as_deref(), &config.config);
148
149    match selection {
150        ProfileSelection::Inline(cfg) => {
151            let name = cfg.name.clone().unwrap_or_else(|| "default".to_string());
152            DistributorProfile::from_pair(&name, &cfg)
153        }
154        ProfileSelection::Named(profile_name) => {
155            let Some(profile_cfg) = map.get(&profile_name) else {
156                let mut available_profiles = map.keys().cloned().collect::<Vec<_>>();
157                available_profiles.sort();
158                let available = if available_profiles.is_empty() {
159                    "<none>".to_string()
160                } else {
161                    available_profiles.join(", ")
162                };
163                let loaded = config
164                    .loaded_from
165                    .as_ref()
166                    .map(|p| p.display().to_string())
167                    .unwrap_or_else(|| "(no config file loaded)".to_string());
168                let attempted = if config.attempted_paths.is_empty() {
169                    "(none)".to_string()
170                } else {
171                    config
172                        .attempted_paths
173                        .iter()
174                        .map(|p| p.display().to_string())
175                        .collect::<Vec<_>>()
176                        .join(", ")
177                };
178                bail!(
179                    "distributor profile `{profile_name}` not found in {} (available: {}). searched config paths: {}. Override with --profile, GREENTIC_DISTRIBUTOR_PROFILE, or GREENTIC_DEV_CONFIG_FILE.",
180                    loaded,
181                    available,
182                    attempted
183                );
184            };
185            DistributorProfile::from_pair(&profile_name, profile_cfg)
186        }
187    }
188}
189
190fn resolve_token(raw: Option<String>) -> Result<Option<String>> {
191    let Some(raw) = raw else {
192        return Ok(None);
193    };
194    if let Some(rest) = raw.strip_prefix("env:") {
195        let value = std::env::var(rest)
196            .with_context(|| format!("failed to resolve env var {rest} for distributor token"))?;
197        Ok(Some(value))
198    } else {
199        Ok(Some(raw))
200    }
201}
202
203fn select_profile(
204    arg: Option<&str>,
205    env: Option<&str>,
206    config: &GreenticConfig,
207) -> ProfileSelection {
208    if let Some(arg) = arg {
209        return ProfileSelection::Named(arg.to_string());
210    }
211    if let Some(env) = env {
212        return ProfileSelection::Named(env.to_string());
213    }
214    if let Some(default) = &config.distributor.default_profile {
215        return match default {
216            DefaultProfileSelection::Name(name) => ProfileSelection::Named(name.clone()),
217            DefaultProfileSelection::Inline(cfg) => ProfileSelection::Inline(cfg.clone()),
218        };
219    }
220    ProfileSelection::Named("default".to_string())
221}
222
223enum ProfileSelection {
224    Named(String),
225    Inline(DistributorProfileConfig),
226}
227
228#[derive(Debug, Clone)]
229pub struct DevDistributorClient {
230    base_url: String,
231    auth_token: Option<String>,
232    http: Client,
233}
234
235impl DevDistributorClient {
236    pub fn from_profile(profile: DistributorProfile) -> Result<Self> {
237        let client = Client::builder()
238            .timeout(Duration::from_secs(30))
239            .build()
240            .context("failed to build HTTP client")?;
241        Ok(Self {
242            base_url: profile.url,
243            auth_token: profile.token,
244            http: client,
245        })
246    }
247
248    pub fn resolve(
249        &self,
250        req: &DevResolveRequest,
251    ) -> Result<DevResolveResponse, DevDistributorError> {
252        let url = format!("{}/v1/resolve", self.base_url);
253        let mut builder = self.http.post(url).header(CONTENT_TYPE, "application/json");
254        if let Some(token) = &self.auth_token {
255            builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
256        }
257        let response = builder.json(req).send()?;
258        if response.status().as_u16() == 402 {
259            let body: DevLicenseRequiredErrorBody = response
260                .json()
261                .map_err(|err| DevDistributorError::InvalidResponse(err.into()))?;
262            return Err(DevDistributorError::LicenseRequired(body));
263        }
264        if !response.status().is_success() {
265            let status = response.status();
266            let body = response.text().ok();
267            return Err(DevDistributorError::Status(status, body));
268        }
269        response
270            .json::<DevResolveResponse>()
271            .map_err(|err| DevDistributorError::InvalidResponse(err.into()))
272    }
273
274    pub fn download_artifact(
275        &self,
276        download_path: &str,
277    ) -> Result<bytes::Bytes, DevDistributorError> {
278        let trimmed_base = self.base_url.trim_end_matches('/');
279        let trimmed_path = download_path.trim_start_matches('/');
280        let url = format!("{trimmed_base}/{trimmed_path}");
281        let mut builder = self.http.get(url);
282        if let Some(token) = &self.auth_token {
283            builder = builder.header(AUTHORIZATION, format!("Bearer {token}"));
284        }
285        let response = builder.send()?;
286        if !response.status().is_success() {
287            let status = response.status();
288            let body = response.text().ok();
289            return Err(DevDistributorError::Status(status, body));
290        }
291        response.bytes().map_err(DevDistributorError::Http)
292    }
293}