use crate::cli::{ImageRef, InstanceCreateArgs, parse_image_ref};
use crate::commands::instance::{
gpu_filter_groups, load_gpu_options, resolve_node_gpu_props, validate_ssh_pubkey,
};
use aleph_sdk::aggregate_models::pricing::{ComputeUnitSpec, GpuModel, PricingPerEntity};
use aleph_sdk::aggregate_models::vm_images::VmImagesData;
use aleph_sdk::client::AlephAggregateClient;
use aleph_sdk::crns_list::{CrnFilter, CrnListEntry, CrnListResponse};
use aleph_sdk::ssh::AlephSshClient;
use aleph_types::chain::Address;
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,
aggregates: &impl AlephAggregateClient,
ssh_client: &impl AlephSshClient,
owner_address: &Address,
) -> Result<()> {
let crn_list_fut = args.crn_hash.is_none().then(spawn_crn_list_fetch);
if args.image.is_none() {
let vm_images = aggregates
.get_vm_images_aggregate()
.await
.map_err(|e| {
anyhow!(
"failed to fetch vm-images aggregate: {e}. \
As a fallback, run without -i and pass --image with a raw item hash or IPFS CID."
)
})?
.vm_images;
args.image = Some(prompt_image(&vm_images)?);
}
let mut gpu_selected = false;
if args.gpu.is_none() {
gpu_selected = prompt_gpu(args, aggregates).await?;
}
if !gpu_selected && args.size.is_none() && args.disk_size.is_none() {
args.size = Some(prompt_size(aggregates).await?);
}
if args.crn_hash.is_none() && prompt_pick_specific_crn()? {
let crn_list = crn_list_fut
.expect("CRN list fetch is spawned whenever crn_hash is None")
.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, aggregates).await?;
let gpu_options = match &args.gpu {
Some(_) => Some(load_gpu_options(aggregates).await?),
None => None,
};
let gpu_filter = match (&gpu_options, &args.gpu) {
(Some(options), Some(model_ids)) => Some(gpu_filter_groups(options, model_ids)?),
_ => None,
};
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: gpu_filter,
};
let filtered = crn_list.filter(&filter);
if filtered.is_empty() {
let gpu_desc = match &args.gpu {
Some(names) => names.join(","),
None => "none".into(),
};
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,
gpu_desc
);
}
let chosen = prompt_crn(&filtered)?;
accept_terms_and_conditions(chosen).await?;
if let (Some(options), Some(model_ids)) = (&gpu_options, &args.gpu) {
args.resolved_gpus = Some(resolve_node_gpu_props(options, model_ids, chosen)?);
}
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_key.is_empty() {
let registered = ssh_client
.list_ssh_keys(owner_address)
.await
.unwrap_or_default();
if registered.is_empty() {
args.ssh_pubkey_file = vec![prompt_ssh_pubkey_path()?];
} else {
eprintln!(
"Using {} registered SSH key(s). Pass --ssh-pubkey-file to override.",
registered.len()
);
}
}
Ok(())
}
async fn prompt_size(aleph_client: &impl AlephAggregateClient) -> 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 gpu_eligible_compute_units(entity: &PricingPerEntity, min_cu: u32) -> Vec<u32> {
let mut cus: Vec<u32> = entity
.tiers
.iter()
.map(|t| t.compute_units)
.filter(|&cu| cu >= min_cu)
.collect();
cus.sort_unstable();
cus.dedup();
cus
}
fn specs_for_cu(cu: u32, spec: &ComputeUnitSpec) -> (u32, u64, u64) {
(
cu * spec.vcpus,
cu as u64 * spec.memory_mib,
cu as u64 * spec.disk_mib,
)
}
fn prompt_gpu_size(model: &GpuModel, entity: &PricingPerEntity) -> Result<u32> {
let cus = gpu_eligible_compute_units(entity, model.compute_units);
if cus.is_empty() {
bail!(
"no compute-unit sizes >= GPU minimum ({}) found for '{}'",
model.compute_units,
model.name
);
}
let items: Vec<String> = cus
.iter()
.map(|&cu| {
let slug = entity.slug_for_compute_units(cu);
let (vcpus, memory_mib, disk_mib) = specs_for_cu(cu, &entity.compute_unit);
let suffix = if cu == model.compute_units {
" (minimum)"
} else {
""
};
format!(
"{:<14} {} vCPU · {} MiB RAM · {} MiB disk{}",
slug, vcpus, memory_mib, disk_mib, suffix,
)
})
.collect();
let idx = Select::new()
.with_prompt(format!(
"Size for {} (min {} CU)",
model.name, model.compute_units
))
.items(&items)
.default(0)
.interact()?;
Ok(cus[idx])
}
async fn prompt_gpu(
args: &mut InstanceCreateArgs,
aleph_client: &impl AlephAggregateClient,
) -> Result<bool> {
let pricing = aleph_client
.get_pricing_aggregate()
.await
.map_err(|e| anyhow!("failed to fetch pricing tiers: {e}"))?;
let models = pricing.pricing.available_gpu_models();
if models.is_empty() {
return Ok(false);
}
let settings = aleph_client
.get_settings_aggregate()
.await
.map_err(|e| anyhow!("failed to fetch network settings: {e}"))?;
let mut items: Vec<String> = vec!["No GPU".to_string()];
items.extend(models.iter().map(|m| {
let vram = match m.vram_mib {
Some(v) => format!("{} MiB VRAM", v),
None => "VRAM n/a".to_string(),
};
format!("{} ({}, {} tier)", m.name, vram, m.tier)
}));
let idx = Select::new()
.with_prompt("GPU")
.items(&items)
.default(0)
.interact()?;
if idx == 0 {
return Ok(false);
}
let model = &models[idx - 1];
let entity = pricing.pricing.for_instance(false, Some(&model.name));
let cu = prompt_gpu_size(model, entity)?;
let (vcpus, memory_mib, disk_mib) = specs_for_cu(cu, &entity.compute_unit);
args.gpu = Some(vec![settings.settings.model_id_for_name(&model.name)]);
args.vcpus = Some(vcpus);
args.memory = Some(memory_mib);
args.disk_size = Some(disk_mib);
args.size = None;
eprintln!(
"Selected GPU: {} ({} tier) -> {} vCPU, {} MiB RAM, {} MiB disk",
model.name, model.tier, vcpus, memory_mib, disk_mib,
);
Ok(true)
}
fn spawn_crn_list_fetch() -> JoinHandle<Result<CrnListResponse, String>> {
tokio::spawn(async move {
crate::commands::instance::fetch_crn_list()
.await
.map_err(|e| e.to_string())
})
}
async fn resolve_specs_for_filter(
args: &InstanceCreateArgs,
aleph_client: &impl AlephAggregateClient,
) -> 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(vm_images: &VmImagesData) -> Result<ImageRef> {
let active = vm_images.active_rootfs();
if active.is_empty() {
eprintln!("No rootfs presets available; enter a raw item hash or IPFS CID.");
return prompt_custom_image();
}
let mut items: Vec<String> = active
.iter()
.map(|(slug, entry)| match &entry.display_name {
Some(d) => format!("{slug} {d}"),
None => slug.to_string(),
})
.collect();
items.push("custom hash or IPFS CID...".into());
let default_idx = vm_images
.defaults
.rootfs
.as_deref()
.and_then(|d| active.iter().position(|(slug, _)| *slug == d))
.unwrap_or(0);
let idx = Select::new()
.with_prompt("Image")
.items(&items)
.default(default_idx)
.interact()?;
if idx < active.len() {
Ok(ImageRef::Hash(active[idx].1.hash.clone()))
} else {
prompt_custom_image()
}
}
fn prompt_custom_image() -> Result<ImageRef> {
let raw: String = Input::new()
.with_prompt("Image (item hash or IPFS CID)")
.validate_with(|s: &String| -> std::result::Result<(), String> {
parse_image_ref(s).map(|_| ())
})
.interact_text()?;
parse_image_ref(&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_pick_specific_crn() -> Result<bool> {
let idx = Select::new()
.with_prompt("Node placement")
.items(&["Automatic", "Choose a specific node"])
.default(0)
.interact()?;
Ok(idx == 1)
}
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")
);
}
use aleph_sdk::aggregate_models::pricing::{ComputeUnitSpec, PricingPerEntity, Tier};
use std::collections::HashMap;
fn gpu_tier(model_name: &str, compute_units: u32) -> Tier {
Tier {
id: format!("tier-{model_name}"),
compute_units,
model: Some(model_name.into()),
vram: Some(20480),
}
}
fn standard_gpu_entity(tiers: Vec<Tier>) -> PricingPerEntity {
PricingPerEntity {
compute_unit: ComputeUnitSpec {
vcpus: 1,
memory_mib: 6144,
disk_mib: 61440,
},
tiers,
price: HashMap::new(),
}
}
#[test]
fn specs_for_cu_multiplies_cu_by_per_cu_resources() {
let spec = ComputeUnitSpec {
vcpus: 1,
memory_mib: 6144,
disk_mib: 61440,
};
assert_eq!(specs_for_cu(3, &spec), (3, 18432, 184320));
assert_eq!(specs_for_cu(16, &spec), (16, 98304, 983040));
}
#[test]
fn gpu_eligible_cus_includes_min_and_larger_only() {
let entity = standard_gpu_entity(vec![
gpu_tier("RTX 4000 ADA", 3),
gpu_tier("RTX 3090", 4),
gpu_tier("RTX 4090", 6),
gpu_tier("RTX 5090", 8),
gpu_tier("L40S", 12),
gpu_tier("RTX A5000", 3),
gpu_tier("RTX A6000", 4),
gpu_tier("RTX 6000 ADA", 11),
]);
assert_eq!(
gpu_eligible_compute_units(&entity, 3),
vec![3, 4, 6, 8, 11, 12]
);
}
#[test]
fn gpu_eligible_cus_filters_out_smaller_models() {
let entity = standard_gpu_entity(vec![
gpu_tier("RTX 4000 ADA", 3),
gpu_tier("RTX 3090", 4),
gpu_tier("RTX 4090", 6),
gpu_tier("L40S", 12),
]);
assert_eq!(gpu_eligible_compute_units(&entity, 6), vec![6, 12]);
}
#[test]
fn gpu_eligible_cus_minimum_alone_when_no_larger_tiers() {
let entity = standard_gpu_entity(vec![gpu_tier("RTX 4000 ADA", 3)]);
assert_eq!(gpu_eligible_compute_units(&entity, 3), vec![3]);
}
}