use std::fs;
use std::io;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use secrecy::SecretString;
use crate::events::Event;
use crate::exec::{Executor, SshExecutor};
use crate::runs::{Outcome, RunCtx, RunError, RunRecord, RunState, Runs, watch};
use crate::train::Trainer;
use super::provision::note_strays;
use super::{
CLIENT_KEY, DeleteReason, DeletedBy, Pod, PodCtx, PodError, PodId, PodKeys, PodPlan, PodRecord,
PodState, PodStatus, Provisioned, RemoteStatus, RunpodTarget, SSH_DIR, SshEndpoint, alias,
provision, remove, sweep, write_config,
};
pub const DEADLINE_MARGIN: Duration = Duration::from_secs(5 * 60);
pub const RETRIEVED_MARKER: &str = ".pod/retrieved";
const GONE_LOOKS: u32 = 3;
pub async fn start_pod(
ctx: &PodCtx<'_>,
target: &RunpodTarget,
mut run: RunRecord,
keep: bool,
) -> Result<(PodRecord, Provisioned), PodError> {
let result = provision_run(ctx, target, &run, keep).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);
}
if no_pod_left(ctx.runs, &run.id) {
forget_client_key(ctx.runs, &run.id);
}
}
result
}
fn no_pod_left(runs: &Runs, run_id: &str) -> bool {
match PodRecord::load(runs, run_id) {
Ok(Some(record)) => {
record.stray_pods.is_empty()
&& (record.pod_id.is_none() || record.state == PodState::Deleted)
},
Ok(None) => true,
Err(error) => {
tracing::warn!("cannot read the pod record of run {run_id}: {error}");
false
},
}
}
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,
) -> Result<(PodRecord, Provisioned), PodError> {
let ssh_dir = ctx.runs.run_dir(&run.id)?.join(SSH_DIR);
let keys = PodKeys::generate(&ssh_dir, &alias(&run.id))?;
let mut record = PodRecord::new(&run.id, keep, target.gpu_count, &keys.host_public);
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(),
};
let provisioned = provision(ctx, &plan, &mut record).await?;
Ok((record, provisioned))
}
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 = watch_on_pod(run_ctx, trainer, record, pod).await;
settle_watch(ctx, pod, &id, watched).await
}
#[derive(Debug)]
pub enum Watched {
Ended(Box<Result<Outcome, RunError>>),
DeadlinePassed,
}
pub async fn watch_on_pod<E: Executor, T: Trainer>(
run_ctx: &RunCtx<'_, E>,
trainer: &T,
record: RunRecord,
pod: &PodRecord,
) -> Watched {
match until_deadline(pod) {
Some(wait) => tokio::select! {
outcome = watch(run_ctx, trainer, record) => Watched::Ended(Box::new(outcome)),
() = tokio::time::sleep(wait) => Watched::DeadlinePassed,
},
None => Watched::Ended(Box::new(watch(run_ctx, trainer, record).await)),
}
}
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),
}
}
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;
}
fail_run(
ctx.runs,
run_id,
"max_hours reached: the pod was deleted before the job ended",
);
PodError::DeadlineReached
}
async fn unreachable(
ctx: &PodCtx<'_>,
pod: &mut PodRecord,
run_id: &str,
error: PodError,
) -> PodError {
match gone(ctx, pod).await {
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, DeletedBy::Unknown)?;
Ok(true)
}
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) 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_client_key(ctx.runs, &pod.run_id);
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.runs, executor, run).await;
if !retrieved {
return unretrieved(ctx, pod, &run.id);
}
let marker = format!("{}/{RETRIEVED_MARKER}", run.remote_dir);
if let Err(error) = executor.write_marker(&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?;
let stray = sweep(ctx, &run.id, None).await;
note_strays(ctx, pod, stray);
forget_client_key(ctx.runs, &run.id);
Ok(Ending::Deleted)
}
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!(
"the results of run {run_id} were not retrieved: pod {name} {stays}; retrieve them with `overbrainer train attach {run_id}`, or remove the pod with `overbrainer pod rm {run_id}`"
);
Ok(ending)
}
pub const WATCHDOG_LOG: &str = ".pod/watchdog.log";
async fn fetch_watchdog_log(runs: &Runs, executor: &SshExecutor, run: &RunRecord) {
let Ok(local) = runs.run_dir(&run.id) else {
return;
};
let entries = [WATCHDOG_LOG.to_string()];
if let Err(error) = executor
.download(&run.remote_dir, &local, &entries, &[])
.await
{
tracing::warn!("cannot copy the watchdog's log of run {}: {error}", run.id);
}
}
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,
) -> 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, DeletedBy::Unknown)?;
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 alias = alias(&run.id);
let config = write_config(&ssh_dir, &alias, &endpoint, &keys)?;
let workdir = run
.remote_dir
.rsplit_once('/')
.map_or(run.remote_dir.as_str(), |(parent, _)| parent);
let executor = SshExecutor::connect(&alias, workdir, Some(&config)).await?;
pod.ssh = Some(endpoint);
pod.save(ctx.runs)?;
Ok(Some(executor))
}
#[must_use]
pub fn ssh_command(run_id: &str) -> String {
format!(
"ssh -F runs/{run_id}/{SSH_DIR}/{} {}",
super::SSH_CONFIG,
alias(run_id)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runpod::ApiError;
#[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"
);
}
}