use std::path::Path;
use std::sync::atomic::AtomicBool;
use anyhow::Context;
use super::progress::duration_words;
use super::{GpuArgs, PodCommand};
use crate::config::{DEFAULT_RUNPOD_BASE_URL, EnvSource, Settings};
use crate::events::EventBus;
use crate::runpod::{
GpuFilter, PodCtx, PodError, RunpodClient, Timing, data_center_table, gpu_table, pod_rows,
remove_run_pods, select_gpus, table, template_table, volume_table,
};
use crate::runs::{JobKind, Runs};
pub async fn run(project_dir: &Path, command: &PodCommand) -> anyhow::Result<()> {
let settings = crate::config::load(project_dir, EnvSource::Process)?;
let client = client(&settings).await?;
match command {
PodCommand::Gpus(args) => gpus(&client, args).await,
PodCommand::Datacenters => datacenters(&client).await,
PodCommand::Volumes => volumes(&client).await,
PodCommand::Templates => templates(&client).await,
PodCommand::Ls => with_pods(project_dir, &client, Pods::Ls).await,
PodCommand::Rm { run_id, force } => {
let rm = Pods::Rm {
run_id,
force: *force,
};
with_pods(project_dir, &client, rm).await
},
}
}
enum Pods<'a> {
Ls,
Rm { run_id: &'a str, force: bool },
}
async fn with_pods(
project_dir: &Path,
client: &RunpodClient,
command: Pods<'_>,
) -> anyhow::Result<()> {
let runs = Runs::new(project_dir);
let runs = match command {
Pods::Rm { run_id, .. } => runs.holding(run_id),
Pods::Ls => runs,
};
let bus = EventBus::new();
let renderer = tokio::spawn(super::progress::render(bus.subscribe()));
let timing = Timing::standard();
let interrupted = AtomicBool::new(false);
let ctx = PodCtx {
client,
runs: &runs,
bus: &bus,
timing: &timing,
interrupted: &interrupted,
};
let result = match command {
Pods::Ls => ls(&ctx).await,
Pods::Rm { run_id, force } => rm(&ctx, run_id, force).await,
};
drop(bus);
renderer.await.ok();
result
}
pub(crate) async fn client(settings: &Settings) -> anyhow::Result<RunpodClient> {
let key = settings
.runpod
.api_key
.as_ref()
.context("no Runpod API key: set OVERBRAINER_RUNPOD__API_KEY")?;
let key = super::resolver()
.resolve(key)
.await
.context("cannot resolve runpod.api_key")?;
let base_url = settings
.runpod
.base_url
.as_deref()
.unwrap_or(DEFAULT_RUNPOD_BASE_URL);
Ok(RunpodClient::new(base_url, &key)?.with_secrets(job_secrets(settings).await))
}
async fn job_secrets(settings: &Settings) -> Vec<secrecy::SecretString> {
let Some(token) = &settings.hf_token else {
return Vec::new();
};
match super::resolver().resolve(token).await {
Ok(token) => vec![token],
Err(error) => {
tracing::debug!("cannot resolve hf_token for the pod log redaction: {error}");
Vec::new()
},
}
}
async fn ls(ctx: &PodCtx<'_>) -> anyhow::Result<()> {
let rows = pod_rows(ctx).await?;
if rows.is_empty() {
println!("pod: no overbrainer pod on this account");
}
for line in table(&rows) {
println!("{line}");
}
Ok(())
}
async fn rm(ctx: &PodCtx<'_>, run_id: &str, force: bool) -> anyhow::Result<()> {
let removal = remove_run_pods(ctx, run_id, force).await;
if removal.removed.is_empty() && removal.result.is_ok() {
println!("pod: no pod for run {run_id}");
}
for pod in &removal.removed {
let after = pod.uptime.map_or_else(String::new, |uptime| {
format!(" after {}", duration_words(uptime))
});
let spend = pod
.estimated_spend
.map_or_else(String::new, |spend| format!(", about ${spend:.2}"));
println!("pod: {} deleted{after}{spend}", pod.pod_id);
}
match (removal.result, ctx.runs.job_of()) {
(Err(error), Some((run, kind))) => Err(job_refusal(error, run_id, &run, kind)),
(result, _) => Ok(result?),
}
}
fn job_refusal(error: PodError, id: &str, run: &str, kind: JobKind) -> anyhow::Error {
let noun = kind.noun();
let command = match kind {
JobKind::Export => format!("overbrainer export {run}"),
JobKind::Compare => format!("overbrainer compare --run {run}"),
};
match error {
PodError::RunStillRunning { kept, .. } => anyhow::anyhow!(
"{noun} {id} of run {run} is still running{kept}; stop the `{command}` following it \
with Ctrl-C, which cancels it, or pass --force"
),
PodError::StillStarting(_) => anyhow::anyhow!(
"{noun} {id} of run {run} is still starting its pod; wait for it, or stop the \
`{command}` starting it with Ctrl-C, or use `pod rm {id} --force`"
),
other => other.into(),
}
}
async fn gpus(client: &RunpodClient, args: &GpuArgs) -> anyhow::Result<()> {
let gpus = client.list_gpu_types(1).await?;
let filter = GpuFilter {
min_vram_gb: args.min_vram,
max_price: args.max_price,
data_center: args.data_center.clone(),
in_stock: args.in_stock,
gpu_count: None,
};
let selected = select_gpus(&gpus, &filter);
if selected.is_empty() {
println!("pod: no GPU type matches");
}
for line in gpu_table(&selected, args.data_center.as_deref()) {
println!("{line}");
}
Ok(())
}
async fn datacenters(client: &RunpodClient) -> anyhow::Result<()> {
let data_centers = client.list_data_centers().await?;
if data_centers.is_empty() {
println!("pod: no data center found");
}
for line in data_center_table(&data_centers) {
println!("{line}");
}
Ok(())
}
async fn volumes(client: &RunpodClient) -> anyhow::Result<()> {
let volumes = client.list_network_volumes().await?;
if volumes.is_empty() {
println!("pod: no network volume on this account");
}
for line in volume_table(&volumes) {
println!("{line}");
}
Ok(())
}
async fn templates(client: &RunpodClient) -> anyhow::Result<()> {
let templates = client.list_templates().await?;
if templates.is_empty() {
println!("pod: no pod template on this account");
}
for line in template_table(&templates) {
println!("{line}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pod_rm_of_a_running_export_says_how_to_stop_it() {
let running = |id: &str| PodError::RunStillRunning {
run_id: id.into(),
kept: ": kept pod p1".into(),
};
assert_eq!(
job_refusal(running("e1"), "e1", "r1", JobKind::Export).to_string(),
"export e1 of run r1 is still running: kept pod p1; stop the `overbrainer export r1` \
following it with Ctrl-C, which cancels it, or pass --force"
);
let starting = job_refusal(
PodError::StillStarting("e1".into()),
"e1",
"r1",
JobKind::Export,
);
assert!(!starting.to_string().contains("train cancel"), "{starting}");
assert_eq!(
job_refusal(running("c1"), "c1", "r1", JobKind::Compare).to_string(),
"compare c1 of run r1 is still running: kept pod p1; stop the \
`overbrainer compare --run r1` following it with Ctrl-C, which cancels it, or pass \
--force"
);
}
}