use std::collections::{BTreeSet, HashMap, HashSet};
use anyhow::{Context, Result};
use indexmap::IndexMap;
use tracing::{info, warn};
use crate::clever::Clever;
use crate::model::{Addon, App, NetworkGroup, Project, Source};
#[derive(Debug, Clone)]
pub struct LiveSnapshot {
pub apps: IndexMap<String, App>,
pub addons: IndexMap<String, Addon>,
pub network_groups: IndexMap<String, NetworkGroup>,
pub default_region: String,
pub live_app_names: BTreeSet<String>,
pub live_addon_names: BTreeSet<String>,
pub live_ng_names: BTreeSet<String>,
pub app_id_by_name: HashMap<String, String>,
pub addon_lookup_by_name: HashMap<String, AddonLookup>,
}
#[derive(Debug, Clone)]
pub struct AddonLookup {
pub addon_id: String,
pub real_id: String,
pub provider_id: String,
}
pub fn snapshot(clever: &Clever, org: &str, project: &Project) -> Result<LiveSnapshot> {
info!("listing live resources in org `{org}`");
let all_apps = clever
.list_apps(org)
.with_context(|| format!("listing applications in org `{org}`"))?;
let all_addons = clever
.list_addons(org)
.with_context(|| format!("listing addons in org `{org}`"))?;
let all_ngs = clever
.list_network_groups(org)
.with_context(|| format!("listing network groups in org `{org}`"))?;
let default_region = pick_default_region(&all_apps, &all_addons);
let project_app_names: HashSet<&str> = project.apps.values().map(|a| a.name.as_str()).collect();
let project_addon_names: HashSet<&str> =
project.addons.values().map(|a| a.name.as_str()).collect();
let project_ng_names: HashSet<&str> = project
.network_groups
.values()
.map(|n| n.name.as_str())
.collect();
let app_name_by_id: HashMap<String, String> = all_apps
.iter()
.map(|a| (a.app_id.clone(), a.name.clone()))
.collect();
let addon_name_by_id: HashMap<String, String> = all_addons
.iter()
.map(|a| (a.addon_id.clone(), a.name.clone()))
.collect();
let addon_name_by_real_id: HashMap<String, String> = all_addons
.iter()
.map(|a| (a.real_id.clone(), a.name.clone()))
.collect();
let mut apps: IndexMap<String, App> = IndexMap::new();
for listed in &all_apps {
if !project_app_names.contains(listed.name.as_str()) {
continue;
}
let details = clever
.get_app_details(org, &listed.app_id)
.with_context(|| format!("reading details of app `{}`", listed.name))?;
let services = clever
.get_services(&listed.app_id)
.with_context(|| format!("reading services of app `{}`", listed.name))?;
let mut dependencies: Vec<String> = Vec::new();
for s in services.addons {
match addon_name_by_id.get(&s.id) {
Some(name) => dependencies.push(name.clone()),
None => warn!(
"addon dependency `{}` of app `{}` not found in org listing",
s.id, listed.name
),
}
}
for s in services.applications {
match app_name_by_id.get(&s.id) {
Some(name) => dependencies.push(name.clone()),
None => warn!(
"app dependency `{}` of app `{}` not found in org listing",
s.id, listed.name
),
}
}
let env: IndexMap<String, String> =
details.env.into_iter().map(|v| (v.name, v.value)).collect();
let user_domains: Vec<String> = details
.vhosts
.into_iter()
.filter(|h| !h.ends_with(".cleverapps.io"))
.collect();
let source = listed.deploy_url.clone().map(|from| Source {
from,
branch: details.branch.clone(),
});
apps.insert(
listed.name.clone(),
App {
name: listed.name.clone(),
kind: listed.kind.clone(),
region: (listed.zone != default_region).then(|| listed.zone.clone()),
source,
domains: user_domains,
scalability: Some(details.scalability),
build: details.build,
dependencies,
config: IndexMap::new(),
env,
hooks: None,
},
);
}
let addons_with_managed_features: HashSet<&str> = project
.addons
.values()
.filter(|a| !a.env.is_empty() || !a.domains.is_empty())
.map(|a| a.name.as_str())
.collect();
let mut addons: IndexMap<String, Addon> = IndexMap::new();
for listed in &all_addons {
if !project_addon_names.contains(listed.name.as_str()) {
continue;
}
let (env, domains) = if addons_with_managed_features.contains(listed.name.as_str()) {
fetch_addon_entrypoint_details(clever, org, listed).unwrap_or_default()
} else {
(IndexMap::new(), Vec::new())
};
addons.insert(
listed.name.clone(),
Addon {
name: listed.name.clone(),
kind: strip_addon_suffix(&listed.provider_id).to_string(),
size: Some(listed.plan_slug.clone()),
crypted: false,
region: (listed.region != default_region).then(|| listed.region.clone()),
version: None,
backup_path: None,
env,
domains,
},
);
}
let mut network_groups: IndexMap<String, NetworkGroup> = IndexMap::new();
for listed in &all_ngs {
if !project_ng_names.contains(listed.label.as_str()) {
continue;
}
let mut link: Vec<String> = Vec::new();
for m in &listed.members {
if let Some(name) = app_name_by_id.get(&m.id) {
link.push(name.clone());
} else if let Some(name) = addon_name_by_real_id.get(&m.id) {
link.push(name.clone());
} else {
warn!(
"network group `{}` member `{}` not found in org apps/addons",
listed.label, m.id
);
}
}
network_groups.insert(
listed.label.clone(),
NetworkGroup {
name: listed.label.clone(),
description: None,
link,
},
);
}
let live_app_names: BTreeSet<String> = all_apps.iter().map(|a| a.name.clone()).collect();
let live_addon_names: BTreeSet<String> = all_addons.iter().map(|a| a.name.clone()).collect();
let live_ng_names: BTreeSet<String> = all_ngs.iter().map(|n| n.label.clone()).collect();
let app_id_by_name: HashMap<String, String> = all_apps
.iter()
.map(|a| (a.name.clone(), a.app_id.clone()))
.collect();
let addon_lookup_by_name: HashMap<String, AddonLookup> = all_addons
.iter()
.map(|a| {
(
a.name.clone(),
AddonLookup {
addon_id: a.addon_id.clone(),
real_id: a.real_id.clone(),
provider_id: a.provider_id.clone(),
},
)
})
.collect();
Ok(LiveSnapshot {
apps,
addons,
network_groups,
default_region,
live_app_names,
live_addon_names,
live_ng_names,
app_id_by_name,
addon_lookup_by_name,
})
}
fn pick_default_region(
apps: &[crate::clever::ListedApp],
addons: &[crate::clever::ListedAddon],
) -> String {
let mut counts: HashMap<String, usize> = HashMap::new();
for a in apps {
*counts.entry(a.zone.clone()).or_default() += 1;
}
for a in addons {
*counts.entry(a.region.clone()).or_default() += 1;
}
counts
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|(k, _)| k)
.unwrap_or_else(|| "par".to_string())
}
fn strip_addon_suffix(provider_id: &str) -> &str {
provider_id.strip_suffix("-addon").unwrap_or(provider_id)
}
fn fetch_addon_entrypoint_details(
clever: &Clever,
org: &str,
listed: &crate::clever::ListedAddon,
) -> Option<(IndexMap<String, String>, Vec<String>)> {
let entrypoint = match clever.get_addon_entrypoint(&listed.provider_id, &listed.real_id) {
Ok(Some(id)) => id,
Ok(None) => return None,
Err(e) => {
warn!(
"failed to fetch entrypoint metadata for addon `{}` ({}): {e:#}",
listed.name, listed.real_id
);
return None;
}
};
let details = match clever.get_app_details(org, &entrypoint) {
Ok(d) => d,
Err(e) => {
warn!(
"failed to fetch entrypoint app details for addon `{}` (entrypoint {entrypoint}): {e:#}",
listed.name
);
return None;
}
};
let env: IndexMap<String, String> =
details.env.into_iter().map(|v| (v.name, v.value)).collect();
let domains: Vec<String> = details
.vhosts
.into_iter()
.filter(|h| !h.ends_with(".cleverapps.io") && !h.ends_with(".clever-cloud.com"))
.collect();
Some((env, domains))
}