use crate::cli::{IMAGE_PRESETS, InstanceCreateArgs, parse_image};
use crate::commands::instance::validate_ssh_pubkey;
use aleph_sdk::client::{AlephAggregateClient, AlephClient};
use aleph_sdk::crns_list::{
CrnFilter, CrnListEntry, CrnListResponse, DEFAULT_CRN_LIST_URL, fetch_crns_list,
};
use aleph_types::item_hash::ItemHash;
use anyhow::{Result, anyhow, bail};
use dialoguer::{Confirm, FuzzySelect, Input, Select};
use std::cmp::Ordering;
use tokio::task::JoinHandle;
pub async fn resolve_interactive(
args: &mut InstanceCreateArgs,
aleph_client: &AlephClient,
) -> Result<()> {
let crn_list_fut = spawn_crn_list_fetch();
if args.image.is_none() {
args.image = Some(prompt_image()?);
}
if args.size.is_none() && args.disk_size.is_none() {
args.size = Some(prompt_size(aleph_client).await?);
}
let crn_list = crn_list_fut
.await
.map_err(|e| anyhow!("background task error: {e}"))?
.map_err(anyhow::Error::msg)?;
let (vcpus, memory_mib, disk_mib) = resolve_specs_for_filter(args, aleph_client).await?;
let filter = CrnFilter {
ipv6: true,
min_vcpus: Some(vcpus),
min_memory_mib: Some(memory_mib),
min_disk_mib: Some(disk_mib),
confidential: args.confidential,
gpu: args.gpu.is_some(),
};
let filtered = crn_list.filter(&filter);
if filtered.is_empty() {
bail!(
"No CRN matches the requirements (vcpus={}, memory_mib={}, disk_mib={}, confidential={}, gpu={}). \
Try a smaller size or wait for capacity.",
vcpus,
memory_mib,
disk_mib,
filter.confidential,
filter.gpu
);
}
let chosen = prompt_crn(&filtered)?;
accept_terms_and_conditions(chosen).await?;
args.crn_hash = Some(chosen.hash.parse().map_err(|e| {
anyhow!(
"CRN list returned an invalid node hash '{}': {}",
chosen.hash,
e
)
})?);
if args.ssh_pubkey_file.is_empty() {
args.ssh_pubkey_file = vec![prompt_ssh_pubkey_path()?];
}
Ok(())
}
async fn prompt_size(aleph_client: &AlephClient) -> Result<String> {
let pricing = aleph_client
.get_pricing_aggregate()
.await
.map_err(|e| anyhow!("failed to fetch pricing tiers: {e}"))?;
let instance_pricing = &pricing.pricing.instance;
let mut tiers: Vec<_> = instance_pricing
.tiers
.iter()
.filter(|t| t.model.is_none())
.collect();
tiers.sort_by_key(|t| t.compute_units);
if tiers.is_empty() {
bail!("no instance tiers available in the pricing aggregate");
}
let cu = &instance_pricing.compute_unit;
let items: Vec<String> = tiers
.iter()
.map(|t| {
let slug = instance_pricing.tier_slug(t);
let vcpus = t.compute_units * cu.vcpus;
let memory_mib = t.compute_units as u64 * cu.memory_mib;
let disk_mib = t.compute_units as u64 * cu.disk_mib;
format!(
"{:<14} {} vCPU · {} MiB RAM · {} MiB disk",
slug, vcpus, memory_mib, disk_mib,
)
})
.collect();
let idx = Select::new()
.with_prompt("Size")
.items(&items)
.default(0)
.interact()?;
Ok(instance_pricing.tier_slug(tiers[idx]))
}
fn crn_list_url() -> Result<url::Url> {
let raw =
std::env::var("ALEPH_CRN_LIST_URL").unwrap_or_else(|_| DEFAULT_CRN_LIST_URL.to_string());
Ok(url::Url::parse(&raw)?)
}
fn spawn_crn_list_fetch() -> JoinHandle<Result<CrnListResponse, String>> {
tokio::spawn(async move {
let url = crn_list_url().map_err(|e| e.to_string())?;
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| e.to_string())?;
fetch_crns_list(&http, &url, true)
.await
.map_err(|e| format!("failed to fetch CRN list from {url}: {e}"))
})
}
async fn resolve_specs_for_filter(
args: &InstanceCreateArgs,
aleph_client: &AlephClient,
) -> Result<(u32, u64, u64)> {
if let Some(slug) = &args.size {
let pricing = aleph_client.get_pricing_aggregate().await?;
let tier = pricing
.pricing
.instance
.find_tier_by_slug(slug)
.ok_or_else(|| anyhow!("unknown size '{slug}'"))?;
Ok((
args.vcpus.unwrap_or(tier.vcpus),
args.memory.unwrap_or(tier.memory_mib),
args.disk_size.unwrap_or(tier.disk_mib),
))
} else {
crate::commands::instance::resolve_instance_specs_from_flags(
args.vcpus,
args.memory,
args.disk_size,
)
}
}
fn prompt_image() -> Result<ItemHash> {
let mut items: Vec<String> = IMAGE_PRESETS
.iter()
.map(|(name, _)| name.to_string())
.collect();
items.push("custom hash or IPFS CID…".into());
let idx = Select::new()
.with_prompt("Image")
.items(&items)
.default(0)
.interact()?;
if idx < IMAGE_PRESETS.len() {
IMAGE_PRESETS[idx].1.parse().map_err(anyhow::Error::msg)
} else {
let raw: String = Input::new()
.with_prompt("Image (item hash or IPFS CID)")
.validate_with(|s: &String| -> std::result::Result<(), String> {
parse_image(s).map(|_| ())
})
.interact_text()?;
parse_image(&raw).map_err(anyhow::Error::msg)
}
}
fn score_key(e: &CrnListEntry) -> Option<f64> {
e.score.filter(|s| !s.is_nan())
}
fn format_crn_table(entries: &[&CrnListEntry]) -> String {
let mut out = String::new();
out.push_str(&format!(
"{:>4} {:>6} {:<24} {:<9} {:>12} {:>12} {:<4} {:<3} {}\n",
"#", "Score", "Name", "Version", "Free RAM", "Free Disk", "Conf", "GPU", "URL",
));
for (i, e) in entries.iter().enumerate() {
let score = e
.score
.map(|s| format!("{:.1}%", s * 100.0))
.unwrap_or_else(|| "-".into());
let version = e.version.clone().unwrap_or_else(|| "-".into());
let (ram, disk) = match &e.system_usage {
Some(u) => (
format!("{} MiB", u.mem.available_kb / 1024),
format!("{} MiB", u.disk.available_kb / 1024),
),
None => ("-".into(), "-".into()),
};
let conf = if e.confidential_support { "✓" } else { " " };
let gpu = if e.gpu_support { "✓" } else { " " };
out.push_str(&format!(
"{:>4} {:>6} {:<24} {:<9} {:>12} {:>12} {:<4} {:<3} {}\n",
i + 1,
score,
truncate(&e.name, 24),
truncate(&version, 9),
ram,
disk,
conf,
gpu,
e.address,
));
}
out
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut t: String = s.chars().take(max.saturating_sub(1)).collect();
t.push('…');
t
}
}
fn prompt_crn<'a>(entries: &'a [&CrnListEntry]) -> Result<&'a CrnListEntry> {
let mut sorted: Vec<&CrnListEntry> = entries.to_vec();
sorted.sort_by(|a, b| {
score_key(b)
.partial_cmp(&score_key(a))
.unwrap_or(Ordering::Equal)
});
loop {
eprintln!("{}", format_crn_table(&sorted));
let labels: Vec<String> = sorted
.iter()
.map(|e| {
let score = e
.score
.map(|s| format!("{:.1}%", s * 100.0))
.unwrap_or("-".into());
format!("{:<6} {:<24} {}", score, truncate(&e.name, 24), e.address)
})
.collect();
let idx = FuzzySelect::new()
.with_prompt("Choose a CRN (type to search)")
.items(&labels)
.default(0)
.interact()?;
let chosen = sorted[idx];
eprintln!(
"\nSelected CRN:\n name: {}\n hash: {}\n url: {}\n score: {}\n version: {}\n",
chosen.name,
chosen.hash,
chosen.address,
chosen
.score
.map(|s| format!("{:.1}%", s * 100.0))
.unwrap_or("-".into()),
chosen.version.as_deref().unwrap_or("-"),
);
if Confirm::new()
.with_prompt("Deploy on this node?")
.default(true)
.interact()?
{
return Ok(chosen);
}
}
}
fn effective_tac_hash(chosen: &CrnListEntry) -> Option<&str> {
chosen
.terms_and_conditions
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
}
async fn accept_terms_and_conditions(chosen: &CrnListEntry) -> Result<()> {
let Some(tac_hash) = effective_tac_hash(chosen) else {
return Ok(());
};
eprintln!(
"\nThis CRN requires accepting terms & conditions.\n\
Document item hash: {tac_hash}\n\
Review with: `aleph file download --message-hash {tac_hash}`\n",
);
if !Confirm::new()
.with_prompt("Accept the CRN's terms & conditions?")
.default(false)
.interact()?
{
bail!("Terms & Conditions rejected: instance creation aborted.");
}
Ok(())
}
fn prompt_ssh_pubkey_path() -> Result<std::path::PathBuf> {
let default = default_ssh_pubkey_path();
loop {
let raw: String = Input::new()
.with_prompt("Path to SSH public key file")
.default(default.display().to_string())
.interact_text()?;
let path = std::path::PathBuf::from(expand_tilde(&raw));
match std::fs::read_to_string(&path) {
Ok(content) => {
if let Err(e) = validate_ssh_pubkey(content.trim(), &path) {
eprintln!(" ✗ {e}");
continue;
}
return Ok(path);
}
Err(e) => {
eprintln!(" ✗ failed to read '{}': {e}", path.display());
continue;
}
}
}
}
fn default_ssh_pubkey_path() -> std::path::PathBuf {
directories::UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.unwrap_or_default()
.join(".ssh/id_rsa.pub")
}
fn expand_tilde(s: &str) -> String {
if let Some(rest) = s.strip_prefix("~/")
&& let Some(u) = directories::UserDirs::new()
{
return u.home_dir().join(rest).display().to_string();
}
s.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_short_string_unchanged() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long_string_appends_ellipsis() {
assert_eq!(truncate("abcdefghij", 5), "abcd…");
}
fn entry_with_score(name: &str, score: Option<f64>) -> CrnListEntry {
use std::collections::HashMap;
CrnListEntry {
hash: name.into(),
name: name.into(),
address: "https://x.y".into(),
score,
version: None,
payment_receiver_address: None,
gpu_support: false,
confidential_support: false,
qemu_support: false,
ipv6_check: None,
system_usage: None,
compatible_available_gpus: None,
terms_and_conditions: None,
extra: HashMap::new(),
}
}
fn sort_by_score(entries: Vec<&CrnListEntry>) -> Vec<&str> {
let mut sorted = entries;
sorted.sort_by(|a, b| {
score_key(b)
.partial_cmp(&score_key(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
sorted.iter().map(|e| e.name.as_str()).collect()
}
#[test]
fn sort_puts_none_score_last() {
let a = entry_with_score("a", None);
let b = entry_with_score("b", Some(0.5));
let c = entry_with_score("c", Some(0.9));
assert_eq!(sort_by_score(vec![&a, &b, &c]), ["c", "b", "a"]);
}
#[test]
fn sort_puts_nan_score_last() {
let a = entry_with_score("a", Some(f64::NAN));
let b = entry_with_score("b", Some(0.5));
let c = entry_with_score("c", Some(0.9));
assert_eq!(sort_by_score(vec![&a, &b, &c]), ["c", "b", "a"]);
}
fn entry_with_tac(tac: Option<&str>) -> CrnListEntry {
use std::collections::HashMap;
CrnListEntry {
hash: "h".into(),
name: "n".into(),
address: "https://x.y".into(),
score: None,
version: None,
payment_receiver_address: None,
gpu_support: false,
confidential_support: false,
qemu_support: false,
ipv6_check: None,
system_usage: None,
compatible_available_gpus: None,
terms_and_conditions: tac.map(str::to_string),
extra: HashMap::new(),
}
}
#[test]
fn effective_tac_hash_none_when_absent() {
assert_eq!(effective_tac_hash(&entry_with_tac(None)), None);
}
#[test]
fn effective_tac_hash_none_when_empty_or_whitespace() {
assert_eq!(effective_tac_hash(&entry_with_tac(Some(""))), None);
assert_eq!(effective_tac_hash(&entry_with_tac(Some(" "))), None);
}
#[test]
fn effective_tac_hash_returns_trimmed() {
assert_eq!(
effective_tac_hash(&entry_with_tac(Some(" abc123 "))),
Some("abc123")
);
}
}