use std::fs;
use std::io;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use secrecy::SecretString;
use tokio::sync::broadcast::Receiver;
use tokio::sync::broadcast::error::RecvError;
use crate::config::{SshClient, process_client};
use crate::events::Event;
use crate::exec::{ExecError, Executor, PodEndpoint, SshDestination, SshExecutor};
use crate::runs::{Outcome, RunCtx, RunError, RunRecord, RunState, Runs, SnapshotReason, watch};
use crate::train::{Pace, SNAPSHOT_REQUEST, Trainer};
use super::disk::{DiskWatch, VolumeDisk};
use super::provision::note_strays;
use super::{
BOOTSTRAP_LOG, CLIENT_KEY, CostCap, DeleteReason, DeletedBy, Pod, PodCtx, PodError, PodId,
PodKeys, PodPlan, PodRecord, PodState, PodStatus, Provisioned, RemoteStatus, RunpodTarget,
SSH_DIR, SshEndpoint, VOLUME_MOUNT, VOLUME_WORKDIR, alias, forget_keys, keep_file,
pod_endpoint, provision, remove, short_cap_warning, sweep, with_pod_logs,
};
use crate::secrets::Redactor;
pub const DEADLINE_MARGIN: Duration = Duration::from_mins(5);
pub const RETRIEVED_MARKER: &str = ".pod/retrieved";
pub const MAX_HOURS_REACHED: &str = "max_hours reached: the pod was deleted before the job ended";
pub const MAX_COST_REACHED: &str = "max_cost_usd reached: the pod was deleted before the job ended";
pub const SNAPSHOT_AT_FILE: &str = ".pod/snapshot_at";
pub const COST_CAP_FILE: &str = ".pod/cost_cap_at";
pub const LEASE_FILE: &str = ".pod/lease";
pub const LEASE_TTL: Duration = Duration::from_mins(15);
const LEASE_RENEW: Duration = Duration::from_mins(5);
const STALL: Duration = Duration::from_mins(30);
const LEASE_TICK: Duration = Duration::from_secs(60);
const GONE_LOOKS: u32 = 3;
pub async fn start_pod(
ctx: &PodCtx<'_>,
target: &RunpodTarget,
mut run: RunRecord,
keep: bool,
vram_floor_gb: Option<u32>,
) -> Result<(PodRecord, Provisioned), PodError> {
let result = provision_run(ctx, target, &run, keep, vram_floor_gb).await;
if let Err(error) = &result {
run.state = RunState::Failed;
run.message = Some(run_message(error));
if let Err(save_error) = ctx.runs.save(&run) {
tracing::warn!("cannot record run {} as failed: {save_error}", run.id);
}
forget_keys(ctx, &run.id).await;
}
result
}
fn run_message(error: &PodError) -> String {
match error {
PodError::Api(api) => match api.status() {
Some(status) => format!("Runpod answered {status}"),
None => error.to_string(),
},
_ => error.to_string(),
}
}
async fn provision_run(
ctx: &PodCtx<'_>,
target: &RunpodTarget,
run: &RunRecord,
keep: bool,
vram_floor_gb: Option<u32>,
) -> Result<(PodRecord, Provisioned), PodError> {
let ssh_dir = ctx.runs.run_dir(&run.id)?.join(SSH_DIR);
let client = process_client(target.ssh_client).map_err(PodError::SshClient)?;
let keys = PodKeys::generate(&ssh_dir, &alias(&run.id), client)?;
let mut record = PodRecord::new(&run.id, keep, target.gpu_count, &keys.host_public);
record.max_cost_usd = target.max_cost_usd.filter(|_| !keep);
record
.network_volume_id
.clone_from(&target.network_volume_id);
record.max_volume_gb = target.max_volume_gb;
record.save(ctx.runs)?;
let plan = PodPlan {
run_id: &run.id,
target,
keys: &keys,
ssh_dir: &ssh_dir,
workdir: target.workdir(),
api_url: ctx.client.base_url(),
vram_floor_gb,
volume_gb: None,
};
let provisioned = provision(ctx, &plan, &mut record).await?;
Ok((record, provisioned))
}
pub async fn arm_cost_cap<E: Executor>(
executor: &E,
pod: &PodRecord,
run: &RunRecord,
) -> Result<(), PodError> {
if let (Some(usd), Some(rate), false) = (pod.max_cost_usd, pod.cost_per_hour, pod.keep)
&& let Some(warning) = short_cap_warning(usd, rate)
{
tracing::warn!("{warning}");
}
match pod.cost_cap() {
Some(cap) => write_cost_cap(executor, cap, &run.remote_dir)
.await
.map_err(|error| {
PodError::CostCapUnset(format!("cannot write it on the pod: {error}"))
}),
None if pod.max_cost_usd.is_some() && !pod.keep => Err(PodError::CostCapUnset(
"Runpod gave no hourly rate for the pod".to_string(),
)),
None => Ok(()),
}
}
async fn write_cost_cap<E: Executor>(
executor: &E,
cap: CostCap,
remote_dir: &str,
) -> Result<(), ExecError> {
for (file, at) in [
(SNAPSHOT_AT_FILE, cap.snapshot_at),
(COST_CAP_FILE, cap.delete_at),
] {
executor
.put_file(&format!("{remote_dir}/{file}"), &at.to_string())
.await?;
}
Ok(())
}
pub fn job_started(runs: &Runs, pod: &mut PodRecord) -> Result<(), PodError> {
pod.state = PodState::Running;
pod.save(runs)?;
Ok(())
}
pub async fn follow<E: Executor, T: Trainer>(
ctx: &PodCtx<'_>,
run_ctx: &RunCtx<'_, E>,
trainer: &T,
record: RunRecord,
pod: &mut PodRecord,
) -> Result<Outcome, PodError> {
let id = record.id.clone();
let watched = with_pod_logs(ctx, pod, watch_on_pod(ctx, run_ctx, trainer, record, pod)).await;
settle_watch(ctx, pod, &id, watched).await
}
#[derive(Debug)]
pub enum Watched {
Ended(Box<Result<Outcome, RunError>>),
DeadlinePassed,
CostCapPassed,
}
pub async fn watch_on_pod<E: Executor, T: Trainer>(
ctx: &PodCtx<'_>,
run_ctx: &RunCtx<'_, E>,
trainer: &T,
record: RunRecord,
pod: &PodRecord,
) -> Watched {
let run = record.clone();
let disk = Box::pin(disk_watch(ctx, run_ctx.executor, &run, pod).run(run_ctx.bus.subscribe()));
let Some(wait) = until_deadline(pod) else {
return tokio::select! {
outcome = watch(run_ctx, trainer, record) => Watched::Ended(Box::new(outcome)),
never = disk => match never {},
};
};
let events = run_ctx.bus.subscribe();
tokio::select! {
outcome = watch(run_ctx, trainer, record) => Watched::Ended(Box::new(outcome)),
() = tokio::time::sleep(wait) => Watched::DeadlinePassed,
() = warn_overrun(events, pod) => Watched::DeadlinePassed,
never = disk => match never {},
}
}
fn disk_watch<'a, E: Executor>(
ctx: &'a PodCtx<'_>,
executor: &'a E,
run: &'a RunRecord,
pod: &'a PodRecord,
) -> DiskWatch<'a, E> {
let volume = pod.network_volume_id.as_deref().map(|id| VolumeDisk {
id,
mount: VOLUME_MOUNT,
max_gb: pod.max_volume_gb,
});
let watch = DiskWatch::new(executor, ctx.client, run, volume);
if volume.is_none() && run.remote_dir.starts_with(VOLUME_WORKDIR) {
return watch.idle();
}
watch
}
pub async fn watch_leased<E: Executor, T: Trainer>(
ctx: &PodCtx<'_>,
run_ctx: &RunCtx<'_, E>,
trainer: &T,
record: RunRecord,
pod: &PodRecord,
) -> Watched {
let run = record.clone();
let disk = Box::pin(disk_watch(ctx, run_ctx.executor, &run, pod).run(run_ctx.bus.subscribe()));
if until_deadline(pod).is_none() {
return tokio::select! {
outcome = watch(run_ctx, trainer, record) => Watched::Ended(Box::new(outcome)),
never = disk => match never {},
};
}
let events = run_ctx.bus.subscribe();
let remote_dir = record.remote_dir.clone();
tokio::select! {
outcome = watch(run_ctx, trainer, record) => Watched::Ended(Box::new(outcome)),
watched = Box::pin(hold_lease(run_ctx.executor, &remote_dir, events, pod)) => watched,
never = disk => match never {},
}
}
async fn hold_lease<E: Executor>(
executor: &E,
remote_dir: &str,
mut events: Receiver<Event>,
pod: &PodRecord,
) -> Watched {
let mut lease = Lease::new(pod, remote_dir);
let mut open = true;
let mut tick = tokio::time::interval(LEASE_TICK);
loop {
tokio::select! {
event = events.recv(), if open => match event {
Ok(event) => lease.saw(&event),
Err(RecvError::Lagged(_)) => {},
Err(RecvError::Closed) => open = false,
},
_ = tick.tick() => {
if let Some(guard) = lease.tick(executor).await {
return guard;
}
},
}
}
}
struct Lease<'a> {
pod: &'a PodRecord,
remote_dir: &'a str,
pace: Pace,
last_metric: SystemTime,
renewed: Option<SystemTime>,
warned: bool,
capped: bool,
}
impl<'a> Lease<'a> {
fn new(pod: &'a PodRecord, remote_dir: &'a str) -> Self {
Self {
pod,
remote_dir,
pace: Pace::default(),
last_metric: SystemTime::now(),
renewed: None,
warned: false,
capped: false,
}
}
fn saw(&mut self, event: &Event) {
let Event::Metric(metric) = event else {
return;
};
self.pace.add(metric);
self.last_metric = SystemTime::now();
if self.warned {
return;
}
if let Some(note) = overrun(&self.pace, self.pod, self.last_metric, true) {
tracing::warn!("{note}");
self.warned = true;
}
}
async fn tick<E: Executor>(&mut self, executor: &E) -> Option<Watched> {
let now = SystemTime::now();
if renewal_due(self.renewed, self.last_metric, now) {
self.renew(executor, now).await;
}
if !self.capped && cap_snapshot_due(self.pod, now) {
self.cap(executor).await;
}
if past_cost_cap(self.pod, now) {
return Some(Watched::CostCapPassed);
}
guard_fires(self.pod, self.renewed, now).then_some(Watched::DeadlinePassed)
}
async fn renew<E: Executor>(&mut self, executor: &E, now: SystemTime) {
let path = format!("{}/{LEASE_FILE}", self.remote_dir);
match executor.put_file(&path, "").await {
Ok(()) => self.renewed = Some(now),
Err(error) => tracing::debug!("cannot renew the pod's lease: {error}"),
}
}
async fn cap<E: Executor>(&mut self, executor: &E) {
let path = format!("{}/{SNAPSHOT_REQUEST}", self.remote_dir);
let written = executor.put_file(&path, SnapshotReason::Cost.name()).await;
self.capped = written.is_ok();
let note = match written {
Ok(()) => cap_snapshot_note(self.pod, unix_secs(SystemTime::now())),
Err(error) => format!("cannot ask for the cost cap's snapshot: {error}"),
};
tracing::warn!("{note}");
}
}
fn cap_snapshot_note(pod: &PodRecord, now: u64) -> String {
let (Some(cap), Some(usd), Some(spent)) = (pod.cost_cap(), pod.max_cost_usd, pod.spent_at(now))
else {
return "the pod neared max_cost_usd: its job is stopped with a snapshot".to_string();
};
let lead = super::SNAPSHOT_LEAD.as_secs();
if cap.delete_at.saturating_sub(cap.snapshot_at) > lead {
return format!(
"the pod spent ${spent:.2} of max_cost_usd ${usd:.2} ({:.0}%): its job is stopped \
with a snapshot",
super::SNAPSHOT_SHARE * 100.0
);
}
format!(
"the pod reaches max_cost_usd ${usd:.2} in {} min (${spent:.2} spent so far): its job \
is stopped with a snapshot {} min before the cap",
cap.delete_at.saturating_sub(now).div_ceil(60),
lead / 60
)
}
fn cap_snapshot_due(pod: &PodRecord, now: SystemTime) -> bool {
pod.cost_cap()
.is_some_and(|cap| unix_secs(now) >= cap.snapshot_at)
}
fn unix_secs(now: SystemTime) -> u64 {
now.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs())
}
fn renewal_due(renewed: Option<SystemTime>, last_metric: SystemTime, now: SystemTime) -> bool {
let since = |at: SystemTime| now.duration_since(at).unwrap_or_default();
since(last_metric) < STALL && renewed.is_none_or(|at| since(at) >= LEASE_RENEW)
}
fn guard_fires(pod: &PodRecord, renewed: Option<SystemTime>, now: SystemTime) -> bool {
let held = renewed.is_some_and(|at| now.duration_since(at).unwrap_or_default() < LEASE_TTL);
!held && until_deadline_at(pod, now).is_some_and(|left| left.is_zero())
}
async fn warn_overrun(mut events: Receiver<Event>, pod: &PodRecord) {
let mut pace = Pace::default();
loop {
match events.recv().await {
Ok(Event::Metric(metric)) => {
pace.add(&metric);
if let Some(warning) = overrun(&pace, pod, SystemTime::now(), false) {
tracing::warn!("{warning}");
break;
}
},
Ok(_) | Err(RecvError::Lagged(_)) => {},
Err(RecvError::Closed) => break,
}
}
std::future::pending::<()>().await;
}
fn overrun(pace: &Pace, pod: &PodRecord, now: SystemTime, leased: bool) -> Option<String> {
if pod.keep {
return None;
}
let deadline = pod.deadline_unix?;
let left = pace.eta()?;
let end = now.checked_add(left)?.duration_since(UNIX_EPOCH).ok()?;
if end.as_secs() <= deadline {
return None;
}
let hours = left.as_secs_f64() / 3600.0;
let at = pod.deadline.as_deref().unwrap_or("its deadline");
if leased {
return Some(format!(
"at this pace the job needs about {hours:.1}h more, past max_hours ({at}): \
the pod stays while overbrainer follows the job; if it stops following, \
the pod's watchdog deletes the pod"
));
}
Some(format!(
"at this pace the job needs about {hours:.1}h more, but the pod's watchdog deletes it by {at} (max_hours): \
raise the target's max_hours, or train with --keep-pod, to let it finish"
))
}
pub async fn settle_watch(
ctx: &PodCtx<'_>,
pod: &mut PodRecord,
run_id: &str,
watched: Watched,
) -> Result<Outcome, PodError> {
match watched {
Watched::Ended(ended) => match *ended {
Ok(outcome) => Ok(outcome),
Err(error) => Err(unreachable(ctx, pod, run_id, error.into()).await),
},
Watched::DeadlinePassed => Err(deadline_reached(ctx, pod, run_id).await),
Watched::CostCapPassed => Err(cost_cap_reached(ctx, pod, run_id).await),
}
}
fn until_deadline(pod: &PodRecord) -> Option<Duration> {
until_deadline_at(pod, SystemTime::now())
}
pub(super) fn until_deadline_at(pod: &PodRecord, now: SystemTime) -> Option<Duration> {
if pod.keep {
return None;
}
let deadline = pod.deadline_unix?.saturating_add(DEADLINE_MARGIN.as_secs());
let now = now
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs());
Some(Duration::from_secs(deadline.saturating_sub(now)))
}
async fn deadline_reached(ctx: &PodCtx<'_>, pod: &mut PodRecord, run_id: &str) -> PodError {
if let Err(error) = remove(ctx, pod, DeleteReason::Deadline, DeletedBy::Client).await {
return error;
}
forget_keys(ctx, run_id).await;
fail_run(ctx.runs, run_id, MAX_HOURS_REACHED);
PodError::DeadlineReached
}
async fn cost_cap_reached(ctx: &PodCtx<'_>, pod: &mut PodRecord, run_id: &str) -> PodError {
if let Err(error) = remove(ctx, pod, DeleteReason::CostCap, DeletedBy::Client).await {
return error;
}
forget_keys(ctx, run_id).await;
fail_run(ctx.runs, run_id, MAX_COST_REACHED);
PodError::CostCapReached
}
async fn unreachable(
ctx: &PodCtx<'_>,
pod: &mut PodRecord,
run_id: &str,
error: PodError,
) -> PodError {
match gone(ctx, pod).await {
Ok(true) if past_cost_cap(pod, SystemTime::now()) => {
fail_run(ctx.runs, run_id, MAX_COST_REACHED);
PodError::CostCapReached
},
Ok(true) if past_deadline(pod, SystemTime::now()) => {
fail_run(ctx.runs, run_id, MAX_HOURS_REACHED);
PodError::DeadlineReached
},
Ok(true) => {
let id = pod.pod_id.clone();
if let Some(id) = &id {
fail_run(ctx.runs, run_id, &format!("pod {id} no longer exists"));
}
id.map_or(error, PodError::PodGone)
},
Ok(false) => error,
Err(check_error) => {
tracing::warn!("cannot look the pod up: {check_error}");
error
},
}
}
async fn gone(ctx: &PodCtx<'_>, pod: &mut PodRecord) -> Result<bool, PodError> {
let Some(id) = pod.pod_id.clone() else {
return Ok(true);
};
if pod.state == PodState::Deleted {
return Ok(true);
}
if ctx.client.get_pod(&id).await?.is_some() || look_again(ctx, &id).await?.is_some() {
return Ok(false);
}
mark_gone(ctx, pod, id, gone_by(pod, SystemTime::now())).await?;
Ok(true)
}
#[must_use]
pub fn past_deadline(pod: &PodRecord, now: SystemTime) -> bool {
let now = now
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs());
!pod.keep && pod.deadline_unix.is_some_and(|deadline| now >= deadline)
}
fn past_cost_cap(pod: &PodRecord, now: SystemTime) -> bool {
pod.cost_cap()
.is_some_and(|cap| unix_secs(now) >= cap.delete_at)
}
#[must_use]
pub fn limit_reached(pod: &PodRecord, now: SystemTime) -> Option<&'static str> {
if past_cost_cap(pod, now) {
Some(MAX_COST_REACHED)
} else if past_deadline(pod, now) {
Some(MAX_HOURS_REACHED)
} else {
None
}
}
fn gone_by(pod: &PodRecord, now: SystemTime) -> DeletedBy {
if limit_reached(pod, now).is_some() {
DeletedBy::Watchdog
} else {
DeletedBy::Unknown
}
}
pub(super) async fn look_up(ctx: &PodCtx<'_>, id: &PodId) -> Result<Option<Pod>, PodError> {
match ctx.client.get_pod(id).await? {
Some(pod) => Ok(Some(pod)),
None => look_again(ctx, id).await,
}
}
async fn look_again(ctx: &PodCtx<'_>, id: &PodId) -> Result<Option<Pod>, PodError> {
for _ in 1..GONE_LOOKS {
tokio::time::sleep(ctx.timing.gone_interval).await;
if let Some(pod) = ctx.client.get_pod(id).await? {
return Ok(Some(pod));
}
}
Ok(ctx
.client
.list_pods()
.await?
.into_iter()
.find(|listed| listed.id == *id))
}
#[derive(Debug)]
pub(super) enum Look {
Found(Box<Pod>),
Gone,
Failed(String),
}
pub(super) async fn look_up_all(ctx: &PodCtx<'_>, ids: &[PodId]) -> Vec<Look> {
let mut looks: Vec<Option<Look>> = ids.iter().map(|_| None).collect();
let mut pending: Vec<usize> = (0..ids.len()).collect();
for round in 0..GONE_LOOKS {
if pending.is_empty() {
break;
}
if round > 0 {
tokio::time::sleep(ctx.timing.gone_interval).await;
}
pending = look_round(ctx, ids, &pending, &mut looks).await;
}
look_again_all(ctx, ids, &pending, &mut looks).await;
looks
.into_iter()
.map(|look| {
look.unwrap_or_else(|| Look::Failed("the pod was not looked at".to_string()))
})
.collect()
}
async fn look_round(
ctx: &PodCtx<'_>,
ids: &[PodId],
pending: &[usize],
looks: &mut [Option<Look>],
) -> Vec<usize> {
let mut missing = Vec::new();
for &index in pending {
match ctx.client.get_pod(&ids[index]).await {
Ok(Some(pod)) => looks[index] = Some(Look::Found(Box::new(pod))),
Ok(None) => missing.push(index),
Err(error) => looks[index] = Some(Look::Failed(error.to_string())),
}
}
missing
}
async fn look_again_all(
ctx: &PodCtx<'_>,
ids: &[PodId],
pending: &[usize],
looks: &mut [Option<Look>],
) {
if pending.is_empty() {
return;
}
match ctx.client.list_pods().await {
Ok(listed) => {
for &index in pending {
looks[index] = Some(match listed.iter().find(|pod| pod.id == ids[index]) {
Some(pod) => Look::Found(Box::new(pod.clone())),
None => Look::Gone,
});
}
},
Err(error) => {
for &index in pending {
looks[index] = Some(Look::Failed(error.to_string()));
}
},
}
}
pub(super) async fn mark_gone(
ctx: &PodCtx<'_>,
pod: &mut PodRecord,
id: PodId,
by: DeletedBy,
) -> Result<(), PodError> {
let now = SystemTime::now();
let uptime = pod.uptime(now);
pod.deleted(by, now);
pod.save(ctx.runs)?;
forget_keys(ctx, &pod.run_id).await;
ctx.bus.publish(Event::PodStatus(PodStatus::Deleted {
pod_id: id,
uptime,
estimated_spend: pod.estimated_spend,
}));
Ok(())
}
fn fail_run(runs: &Runs, run_id: &str, message: &str) {
let result = runs.load(run_id).and_then(|mut run| {
run.state = RunState::Failed;
run.message = Some(message.to_string());
runs.save(&run)
});
if let Err(error) = result {
tracing::warn!("cannot record run {run_id} as failed: {error}");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ending {
Deleted,
Kept,
AwaitingRetrieval,
}
pub async fn end_pod(
ctx: &PodCtx<'_>,
pod: &mut PodRecord,
executor: &SshExecutor,
run: &RunRecord,
retrieved: bool,
) -> Result<Ending, PodError> {
fetch_watchdog_log(ctx, executor, run).await;
if !retrieved {
return unretrieved(ctx, pod, &run.id);
}
let marker = format!("{}/{RETRIEVED_MARKER}", run.remote_dir);
if let Err(error) = executor.put_file(&marker, "").await {
tracing::warn!(
"cannot mark the results of run {} retrieved on the pod: {error}",
run.id
);
}
if pod.keep {
pod.state = PodState::Kept;
pod.save(ctx.runs)?;
if let Some(id) = &pod.pod_id {
ctx.bus
.publish(Event::PodStatus(PodStatus::Kept { pod_id: id.clone() }));
}
return Ok(Ending::Kept);
}
remove(ctx, pod, DeleteReason::Retrieved, DeletedBy::Client).await?;
sweep_then_forget(ctx, pod, &run.id).await;
Ok(Ending::Deleted)
}
async fn sweep_then_forget(ctx: &PodCtx<'_>, pod: &mut PodRecord, run_id: &str) {
match sweep(ctx, run_id, None).await {
Some(stray) if stray.is_empty() => forget_keys(ctx, run_id).await,
Some(stray) => note_strays(ctx, pod, stray),
None => tracing::debug!(
"keeping the keys of run {run_id}: its pods could not be listed; the startup sweep deletes the secret later"
),
}
}
fn unretrieved(ctx: &PodCtx<'_>, pod: &mut PodRecord, run_id: &str) -> Result<Ending, PodError> {
let name = pod
.pod_id
.as_ref()
.map_or_else(|| "(none)".to_string(), ToString::to_string);
let (stays, ending) = if pod.keep {
pod.state = PodState::Kept;
(
"stays (--keep-pod), nothing deletes it automatically",
Ending::Kept,
)
} else {
pod.state = PodState::AwaitingRetrieval;
(
"stays until its watchdog's retrieve grace ends",
Ending::AwaitingRetrieval,
)
};
pod.save(ctx.runs)?;
tracing::warn!("{}", unretrieved_line(ctx.runs, run_id, &name, stays));
Ok(ending)
}
fn unretrieved_line(runs: &Runs, run_id: &str, name: &str, stays: &str) -> String {
let subject = runs.subject(run_id);
let retry = match runs.job_of() {
Some((run, kind)) => kind.retry(&run),
None => format!("retrieve them with `overbrainer train attach {run_id}`"),
};
format!(
"the results of {subject} were not retrieved: pod {name} {stays}; {retry}, or remove the \
pod with `overbrainer pod rm {run_id}`"
)
}
pub const WATCHDOG_LOG: &str = ".pod/watchdog.log";
const MAX_POD_FILE_LOG: u64 = 4 * 1024 * 1024;
async fn fetch_watchdog_log(ctx: &PodCtx<'_>, executor: &SshExecutor, run: &RunRecord) {
let Ok(local) = ctx.runs.run_dir(&run.id) else {
return;
};
for (entry, what) in [(WATCHDOG_LOG, "watchdog"), (BOOTSTRAP_LOG, "bootstrap")] {
if let Err(error) = copy_pod_file(ctx, executor, &run.remote_dir, &local, entry).await {
tracing::warn!("cannot copy the {what}'s log of run {}: {error}", run.id);
}
}
}
async fn copy_pod_file(
ctx: &PodCtx<'_>,
executor: &SshExecutor,
remote_dir: &str,
local: &Path,
entry: &str,
) -> Result<(), PodError> {
let remote = format!("{remote_dir}/{entry}");
let bytes = executor.read_from(&remote, 0, MAX_POD_FILE_LOG).await?;
if bytes.is_empty() {
return Ok(());
}
let mut redactor = Redactor::new(ctx.client.log_secrets());
let mut text = String::new();
for line in String::from_utf8_lossy(&bytes).lines() {
text.push_str(&redactor.line(line));
text.push('\n');
}
keep_file(local, entry, text.as_bytes()).map_err(|source| PodError::Io {
path: local.join(entry),
source,
})
}
pub fn forget_client_key(runs: &Runs, run_id: &str) {
let Ok(dir) = runs.run_dir(run_id) else {
return;
};
let key = dir.join(SSH_DIR).join(CLIENT_KEY);
match fs::remove_file(&key) {
Ok(()) => {},
Err(e) if e.kind() == io::ErrorKind::NotFound => {},
Err(error) => tracing::warn!("cannot remove {}: {error}", key.display()),
}
}
pub async fn reconnect(
ctx: &PodCtx<'_>,
pod: &mut PodRecord,
run: &RunRecord,
client: SshClient,
) -> Result<Option<SshExecutor>, PodError> {
let Some(id) = pod.pod_id.clone() else {
return Ok(None);
};
if pod.state == PodState::Deleted {
return Ok(None);
}
let Some(remote) = look_up(ctx, &id).await? else {
mark_gone(ctx, pod, id, gone_by(pod, SystemTime::now())).await?;
return Ok(None);
};
if remote.status == RemoteStatus::Exited {
return Err(PodError::PodStopped {
pod_id: id,
run_id: run.id.clone(),
});
}
let direct = remote
.direct()
.ok_or_else(|| PodError::NoEndpoint(id.clone(), remote.status.name().to_string()))?;
let endpoint = SshEndpoint {
host: direct.host.clone(),
port: direct.port,
user: direct.username.clone(),
};
let ssh_dir = ctx.runs.run_dir(&run.id)?.join(SSH_DIR);
let keys = PodKeys::new(
ssh_dir.join(CLIENT_KEY),
String::new(),
pod.host_key.clone(),
SecretString::from(String::new()),
);
let target = pod_endpoint(&ssh_dir, &alias(&run.id), &endpoint, &keys)?;
let executor =
SshExecutor::connect(&SshDestination::Pod(&target), pod_workdir(run), client).await?;
pod.ssh = Some(endpoint);
pod.save(ctx.runs)?;
Ok(Some(executor))
}
pub async fn connect_followed(
runs: &Runs,
pod: &PodRecord,
run: &RunRecord,
client: SshClient,
) -> Result<Option<SshExecutor>, PodError> {
if pod.pod_id.is_none() || pod.state == PodState::Deleted {
return Ok(None);
}
let ssh_dir = runs.run_dir(&run.id)?.join(SSH_DIR);
let config = ssh_dir.join(super::SSH_CONFIG);
let (true, Some(endpoint)) = (config.is_file(), pod.ssh.as_ref()) else {
return Ok(None);
};
let target = PodEndpoint {
alias: alias(&run.id),
config,
host: endpoint.host.clone(),
port: endpoint.port,
user: endpoint.user.clone(),
key: ssh_dir.join(CLIENT_KEY),
host_key: pod.host_key.clone(),
};
let executor =
SshExecutor::connect(&SshDestination::Pod(&target), pod_workdir(run), client).await?;
Ok(Some(executor))
}
fn pod_workdir(run: &RunRecord) -> &str {
run.remote_dir
.rsplit_once('/')
.map_or(run.remote_dir.as_str(), |(parent, _)| parent)
}
#[must_use]
pub fn ssh_command(runs: &Runs, run_id: &str) -> String {
format!(
"ssh -F {}/{SSH_DIR}/{} {}",
runs.relative_dir(run_id),
super::SSH_CONFIG,
alias(run_id)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::EventBus;
use crate::runpod::ApiError;
use crate::runpod::{RunpodClient, Timing};
use crate::train::TrainMetric;
#[test]
fn an_export_s_pod_messages_name_the_export_and_its_recovery()
-> Result<(), Box<dyn std::error::Error>> {
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let exports = runs.exports("r1")?;
assert_eq!(
unretrieved_line(&runs, "r1", "p1", "stays"),
"the results of run r1 were not retrieved: pod p1 stays; retrieve them with \
`overbrainer train attach r1`, or remove the pod with `overbrainer pod rm r1`"
);
assert_eq!(
unretrieved_line(&exports, "e1", "p1", "stays"),
"the results of export e1 of run r1 were not retrieved: pod p1 stays; export again \
with `overbrainer export r1`, or remove the pod with `overbrainer pod rm e1`"
);
assert_eq!(
ssh_command(&exports, "e1"),
"ssh -F runs/r1/exports/e1/ssh/config overbrainer-e1"
);
assert_eq!(
ssh_command(&runs, "r1"),
"ssh -F runs/r1/ssh/config overbrainer-r1"
);
Ok(())
}
fn slow_pace() -> Pace {
let mut pace = Pace::default();
for (time, step) in [(0.0, 90), (100.0, 100)] {
pace.add(&TrainMetric {
time,
step,
epoch: None,
max_steps: Some(7300),
loss: Some(1.0),
eval_loss: None,
learning_rate: None,
grad_norm: None,
});
}
pace
}
fn pod_due_in(hours: u64, keep: bool, now: SystemTime) -> PodRecord {
let mut pod = PodRecord::new("r1", keep, 1, "ssh-ed25519 AAAAhost");
let deadline = now + Duration::from_secs(hours * 3600);
pod.deadline_unix = deadline
.duration_since(UNIX_EPOCH)
.ok()
.map(|since| since.as_secs());
pod.deadline = Some("2026-09-29T19:54:50Z".into());
pod
}
#[test]
fn a_job_that_cannot_end_before_the_deadline_is_warned_about() {
let now = SystemTime::now();
assert_eq!(
overrun(&slow_pace(), &pod_due_in(6, false, now), now, false).as_deref(),
Some(
"at this pace the job needs about 20.0h more, but the pod's watchdog deletes it by \
2026-09-29T19:54:50Z (max_hours): raise the target's max_hours, or train with \
--keep-pod, to let it finish"
)
);
}
#[test]
fn a_followed_job_past_the_deadline_is_only_noted() {
let now = SystemTime::now();
let note = overrun(&slow_pace(), &pod_due_in(6, false, now), now, true);
assert_eq!(
note.as_deref(),
Some(
"at this pace the job needs about 20.0h more, past max_hours \
(2026-09-29T19:54:50Z): the pod stays while overbrainer follows the job; \
if it stops following, the pod's watchdog deletes the pod"
)
);
}
#[test]
fn the_watchdog_holds_the_lease_as_long_as_the_client_counts_on() {
let default = format!("OVERBRAINER_LEASE_TTL:-{}}}", LEASE_TTL.as_secs());
assert!(super::super::watchdog_script().contains(&default));
}
#[test]
fn the_lease_is_renewed_every_few_minutes_while_the_job_progresses() {
let now = SystemTime::now();
let ago = |minutes: u64| now - Duration::from_secs(minutes * 60);
assert!(renewal_due(None, now, now));
assert!(!renewal_due(Some(ago(1)), ago(1), now));
assert!(renewal_due(Some(ago(5)), ago(1), now));
assert!(!renewal_due(Some(ago(5)), ago(30), now));
assert!(!renewal_due(None, ago(31), now));
}
#[test]
fn the_client_guard_waits_for_the_lease_to_run_out() {
let now = SystemTime::now();
let ago = |minutes: u64| Some(now - Duration::from_secs(minutes * 60));
let late = pod_due_in(0, false, now - Duration::from_secs(3600));
assert!(!guard_fires(&late, ago(1), now));
assert!(!guard_fires(&late, ago(14), now));
assert!(guard_fires(&late, ago(15), now));
assert!(guard_fires(&late, None, now));
assert!(!guard_fires(&pod_due_in(1, false, now), None, now));
assert!(!guard_fires(
&pod_due_in(0, true, now - Duration::from_secs(3600)),
None,
now
));
}
#[test]
fn no_overrun_in_time_for_a_kept_pod_or_without_a_pace() {
let now = SystemTime::now();
assert_eq!(
overrun(&slow_pace(), &pod_due_in(21, false, now), now, false),
None
);
assert_eq!(
overrun(&slow_pace(), &pod_due_in(6, true, now), now, false),
None
);
assert_eq!(
overrun(&Pace::default(), &pod_due_in(6, false, now), now, false),
None
);
}
#[test]
fn the_cost_cap_asks_for_a_snapshot_then_names_why_the_pod_went() {
let now = SystemTime::now();
let mut pod = pod_due_in(6, false, now);
pod.created_unix = Some(unix_secs(now) - 3600);
pod.cost_per_hour = Some(1.0);
assert!(!cap_snapshot_due(&pod, now), "no cap");
pod.max_cost_usd = Some(2.0);
let hours = |h: f64| now + Duration::from_secs_f64(h * 3600.0);
assert!(!cap_snapshot_due(&pod, hours(0.7)));
assert!(cap_snapshot_due(&pod, hours(0.75)));
assert_eq!(limit_reached(&pod, hours(0.95)), None);
assert_eq!(limit_reached(&pod, hours(1.0)), Some(MAX_COST_REACHED));
assert_eq!(gone_by(&pod, hours(1.0)), DeletedBy::Watchdog);
assert_eq!(limit_reached(&pod, hours(6.0)), Some(MAX_COST_REACHED));
pod.max_cost_usd = None;
assert_eq!(limit_reached(&pod, hours(6.0)), Some(MAX_HOURS_REACHED));
assert_eq!(gone_by(&pod, hours(1.0)), DeletedBy::Unknown);
}
#[test]
fn the_cost_snapshot_says_why_it_is_due_with_the_amounts() {
let now = SystemTime::now();
let secs = unix_secs(now);
let mut pod = billed_pod(now, Some(0.05));
pod.cost_per_hour = Some(0.24);
pod.created_unix = Some(secs - 90);
assert_eq!(
cap_snapshot_note(&pod, secs),
"the pod reaches max_cost_usd $0.05 in 11 min ($0.01 spent so far): its job is \
stopped with a snapshot 15 min before the cap"
);
pod.max_cost_usd = Some(20.0);
pod.cost_per_hour = Some(1.0);
pod.created_unix = Some(secs - 68_400);
assert_eq!(
cap_snapshot_note(&pod, secs),
"the pod spent $19.00 of max_cost_usd $20.00 (95%): its job is stopped with a \
snapshot"
);
pod.cost_per_hour = None;
assert_eq!(
cap_snapshot_note(&pod, secs),
"the pod neared max_cost_usd: its job is stopped with a snapshot"
);
}
#[test]
fn the_watchdog_asks_for_a_snapshot_as_early_as_the_client_counts_on() {
let default = format!(
"OVERBRAINER_SNAPSHOT_LEAD:-{}}}",
super::super::SNAPSHOT_LEAD.as_secs()
);
assert!(super::super::watchdog_script().contains(&default));
}
fn billed_pod(now: SystemTime, cap: Option<f64>) -> PodRecord {
let mut pod = pod_due_in(6, false, now);
pod.created_unix = Some(unix_secs(now) - 3600);
pod.cost_per_hour = Some(1.0);
pod.max_cost_usd = cap;
pod
}
fn run_in(remote_dir: &str) -> RunRecord {
RunRecord {
id: "r1".into(),
target: "gpu".into(),
created: "2026-09-22T14:30:05Z".into(),
remote_dir: remote_dir.into(),
job: None,
state: RunState::Preparing,
message: None,
snapshot: None,
resumed_from: None,
snapshots: true,
}
}
#[tokio::test]
async fn a_cost_cap_that_cannot_be_armed_refuses_the_run()
-> Result<(), Box<dyn std::error::Error>> {
let root = tempfile::tempdir()?;
let executor = crate::exec::LocalExecutor::new(root.path())?;
let now = SystemTime::now();
let dir = root.path().join("r1");
let run = run_in(&dir.to_string_lossy());
arm_cost_cap(&executor, &billed_pod(now, Some(2.0)), &run).await?;
let written = std::fs::read_to_string(dir.join(COST_CAP_FILE))?;
assert_eq!(written, (unix_secs(now) + 3600).to_string());
assert!(dir.join(SNAPSHOT_AT_FILE).is_file());
arm_cost_cap(&executor, &billed_pod(now, None), &run).await?;
let mut unrated = billed_pod(now, Some(2.0));
unrated.cost_per_hour = None;
let refused = arm_cost_cap(&executor, &unrated, &run).await;
assert!(
matches!(refused, Err(PodError::CostCapUnset(_))),
"{refused:?}"
);
let blocked = root.path().join("blocked");
std::fs::write(&blocked, "")?;
let refused = arm_cost_cap(
&executor,
&billed_pod(now, Some(2.0)),
&run_in(&blocked.to_string_lossy()),
)
.await;
assert!(
matches!(refused, Err(PodError::CostCapUnset(_))),
"{refused:?}"
);
Ok(())
}
#[tokio::test]
async fn the_client_deletes_the_pod_at_its_cost_cap_whatever_the_lease()
-> Result<(), Box<dyn std::error::Error>> {
let root = tempfile::tempdir()?;
let executor = crate::exec::LocalExecutor::new(root.path())?;
let now = SystemTime::now();
let remote = root.path().join("r1").to_string_lossy().into_owned();
let spent = billed_pod(now, Some(0.5));
let mut lease = Lease::new(&spent, &remote);
let fired = lease.tick(&executor).await;
assert!(matches!(fired, Some(Watched::CostCapPassed)), "{fired:?}");
assert_eq!(
std::fs::read_to_string(root.path().join("r1").join(SNAPSHOT_REQUEST))?,
"cost"
);
let within = billed_pod(now, Some(20.0));
let mut lease = Lease::new(&within, &remote);
assert!(lease.tick(&executor).await.is_none());
Ok(())
}
#[test]
fn a_failed_run_keeps_only_the_status_of_an_api_answer() {
let answered = PodError::Api(ApiError::Status {
status: 500,
message: "server text".into(),
retry_after: None,
capacity: false,
});
assert_eq!(run_message(&answered), "Runpod answered 500");
assert_eq!(
run_message(&PodError::Interrupted),
"interrupted before the job started"
);
}
async fn secret_calls(server: &wiremock::MockServer) -> Vec<String> {
server
.received_requests()
.await
.unwrap_or_default()
.iter()
.filter(|request| request.url.path().starts_with("/v2/account/secrets"))
.map(|request| format!("{} {}", request.method, request.url.path()))
.collect()
}
async fn forget_after_sweep(
listed: wiremock::ResponseTemplate,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v2/pods"))
.respond_with(listed)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v2/account/secrets"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"secrets": [{"id": "s1", "name": "overbrainer_host_key_r1",
"createdAt": "2026-10-01T00:00:00Z"}]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/v2/account/secrets/s1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = RunpodClient::new(
&format!("{}/v2", server.uri()),
&secrecy::SecretString::from("rp_key"),
)?
.with_policy(crate::retry::RetryPolicy {
max_retries: 0,
base: Duration::from_millis(1),
cap: Duration::from_millis(2),
});
let project = tempfile::tempdir()?;
let runs = Runs::new(project.path());
let (bus, interrupted) = (EventBus::new(), std::sync::atomic::AtomicBool::new(false));
let timing = Timing {
poll: Duration::from_millis(5),
ready_timeout: Duration::from_millis(400),
preflight_timeout: Duration::from_millis(300),
reconcile_waits: [Duration::from_millis(5); 2],
delete_timeout: Duration::from_millis(200),
gone_interval: Duration::from_millis(5),
};
let ctx = PodCtx {
client: &client,
runs: &runs,
bus: &bus,
timing: &timing,
interrupted: &interrupted,
};
let mut pod = PodRecord::new("r1", false, 1, "ssh-ed25519 AAAAhost");
sweep_then_forget(&ctx, &mut pod, "r1").await;
Ok(secret_calls(&server).await)
}
#[tokio::test]
async fn the_end_of_a_run_drops_its_secret_only_once_its_pods_are_listed()
-> Result<(), Box<dyn std::error::Error>> {
let empty =
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({"pods": []}));
let calls = forget_after_sweep(empty).await?;
assert!(
calls
.iter()
.any(|call| call == "DELETE /v2/account/secrets/s1"),
"{calls:?}"
);
let calls = forget_after_sweep(wiremock::ResponseTemplate::new(500)).await?;
assert_eq!(calls, Vec::<String>::new());
Ok(())
}
}