Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

//! Oxide Support Shell API

use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;

use dropshot::{
Expand Down Expand Up @@ -147,7 +147,7 @@ pub trait SushApi {
params: PathParams<JobTargetParams>,
query: QueryParams<JobStartParams>,
body: TypedBody<SignedJob>,
) -> Result<HttpResponseUpdatedNoContent, HttpError>;
) -> Result<HttpResponseOk<JobStarted>, HttpError>;

/// Stop a (running) job.
#[endpoint { method = POST, path = "/jobs/{job_id}/stop" }]
Expand Down Expand Up @@ -287,6 +287,13 @@ pub struct SessionStartBody {
pub seq: SessionSeq,
}

/// Acknowledgement that a job was accepted.
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
pub struct JobStarted {
/// Which sleds the job will run on.
pub sleds: BTreeSet<BaseboardId>,
}

/// Job parameters _not_ specified in the signed job request.
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(default)]
Expand Down
30 changes: 22 additions & 8 deletions client/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

//! Possibly-interactive command-line interface.

use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io::{self, BufRead as _, ErrorKind, Read as _, Write as _, stderr, stdin, stdout};
Expand Down Expand Up @@ -35,7 +35,7 @@ use sush_common::jobs::{
SignedJob, job_status_to_json_map,
};
use sush_common::keys::{KeyId, Signature, SshPublicKey};
use sush_common::targets::{MAX_CUBBY, SledHealth, SledId, SledVersion};
use sush_common::targets::{MAX_CUBBY, SledHealth, SledId, SledVersion, Target};
use sush_common::version::VersionInfo;

use crate::AuthzSigner;
Expand Down Expand Up @@ -481,7 +481,7 @@ impl CommandContext for Cli {

// Job management

fn job_started(&mut self, job: &SignedJob, show: bool) {
fn job_started(&mut self, job: &SignedJob, sleds: Option<&BTreeSet<BaseboardId>>, show: bool) {
if let Some(session) = self.session.lock().unwrap().as_mut() {
session.job_started(job.to_owned());
self.save_session(Some(session));
Expand All @@ -491,12 +491,26 @@ impl CommandContext for Cli {
}
let job_id = job.job_id();
match self.get_output_format() {
OutputFormat::Json => println!("{}", json!({"job_started": job_id})),
OutputFormat::Json => println!("{}", json!({"job_started": job_id, "sleds": sleds})),
OutputFormat::Text => {
if let Some(watch) = self.watch.lock().unwrap().as_ref() {
let _ = watch.multi.println(format!("✅ Started job `{job_id}`"));
} else {
println!("✅ Started job `{job_id}`");
let watch = self.watch.lock().unwrap();
let print = |line: String| match watch.as_ref() {
Some(watch) => {
let _ = watch.multi.println(line);
}
None => println!("{line}"),
};
print(format!("✅ Started job `{job_id}`"));
if let (Target::Sleds(named), Some(sleds)) = (job.payload().target(), sleds) {
for sled in named {
if let SledId::Baseboard(baseboard) = sled
&& !sleds.contains(baseboard)
{
print(format!(
"❗ Job `{job_id}` will not run on unreachable sled `{baseboard}`"
));
}
}
}
}
}
Expand Down
112 changes: 62 additions & 50 deletions client/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//!
//! May be executed via either the main CLI or the interactive REPL.

use std::collections::BTreeSet;
use std::fs::{File, OpenOptions, read};
use std::io::ErrorKind;
use std::io::{Seek as _, SeekFrom, Write as _, stdin};
Expand Down Expand Up @@ -302,56 +303,49 @@ fn watch_settling() {
time_started: Utc::now(),
};

let all = Target::All;
let mut settling = Settling::default();
let mut status = JobStatusMap::new();
assert!(!settling.done(&all, None, &status), "empty never settles");
assert!(!settling.done(None, None, &status), "empty never settles");

status.insert(sled("A"), terminal.clone());
assert!(!settling.done(&all, None, &status), "new sled resets");
assert!(!settling.done(&all, None, &status), "first quiet poll");
assert!(!settling.done(None, None, &status), "new sled resets");
assert!(!settling.done(None, None, &status), "first quiet poll");
assert!(
!settling.done(&all, None, &status),
!settling.done(None, None, &status),
"quiet but too young for gossip"
);
assert!(settling.done(&all, None, &status), "old enough, quiet");
assert!(settling.done(None, None, &status), "old enough, quiet");

let mut settling = Settling::default();
status.insert(sled("B"), running.clone());
assert!(!settling.done(&all, None, &status), "running sled holds");
assert!(!settling.done(None, None, &status), "running sled holds");
status.insert(sled("B"), terminal.clone());
assert!(
!settling.done(&all, Some(2), &status),
!settling.done(None, Some(2), &status),
"the rack count must hold"
);
assert!(
settling.done(&all, Some(2), &status),
settling.done(None, Some(2), &status),
"the rack count held twice"
);

let named: Target = format!("{},{}", sled("A"), sled("B")).parse().unwrap();
let known = BTreeSet::from([sled("A"), sled("B")]);
let mut settling = Settling::default();
assert!(
settling.done(&named, None, &status),
"named sleds settle on the first terminal poll"
);
let duplicates: Target = format!("{},{}", sled("A"), sled("A")).parse().unwrap();
let mut settling = Settling::default();
assert!(
settling.done(&duplicates, None, &status),
"duplicate baseboards collapse"
settling.done(Some(&known), None, &status),
"known sleds settle on the first terminal poll"
);
status.insert(sled("B"), running);
assert!(
!settling.done(&named, None, &status),
"a running named sled holds"
!settling.done(Some(&known), None, &status),
"a running known sled holds"
);
status.insert(sled("B"), terminal);
let missing: Target = format!("{},{}", sled("A"), sled("C")).parse().unwrap();
let missing = BTreeSet::from([sled("A"), sled("C")]);
let mut settling = Settling::default();
assert!(
!settling.done(&missing, None, &status),
"a missing named sled holds"
!settling.done(Some(&missing), None, &status),
"a missing known sled holds"
);
}

Expand Down Expand Up @@ -1376,7 +1370,7 @@ async fn job(
job_start(ctx, client, job, start_args.to_owned()).await
} else {
ctx.job_signed(&job, true);
ctx.job_started(&job, false);
ctx.job_started(&job, None, false);
Ok(())
}
}
Expand Down Expand Up @@ -1418,7 +1412,7 @@ async fn job(
StatusDisplayStyle::Short
};
if wait {
let status = job_watch(ctx, client, &job_id, &Target::All).await?;
let status = job_watch(ctx, client, &job_id, None).await?;
ctx.job_status(&job_id, &status, style);
Ok(())
} else {
Expand Down Expand Up @@ -1535,8 +1529,8 @@ async fn job_start(

// Start the job.
if interactive {
start.await?;
ctx.job_started(&job, true);
let sleds = start.await?.into_inner().sleds;
ctx.job_started(&job, Some(&sleds), true);
match job_attach(ctx, client, &job_id, &target).await {
Ok(()) | Err(CommandError::NotFound(_)) => {
job_status(ctx, client, &job_id, StatusDisplayStyle::Short).await?
Expand All @@ -1557,10 +1551,10 @@ async fn job_start(
let file = options
.open(&path)
.map_err(|error| CommandError::io(&path, error))?;
start.await?;
ctx.job_started(&job, true);
let sleds = start.await?.into_inner().sleds;
ctx.job_started(&job, Some(&sleds), true);
let hasher = job_stream(ctx, client, &job_id, &target, &path, file).await?;
let status = job_watch(ctx, client, &job_id, &target.clone().into()).await?;
let status = job_watch(ctx, client, &job_id, Some(sleds)).await?;
ctx.job_status(&job_id, &status, StatusDisplayStyle::Short);
let Some(JobStatus::Stopped {
result: Ok(0),
Expand Down Expand Up @@ -1593,6 +1587,7 @@ async fn job_start(
// watching until the job settles everywhere.
ctx.job_watch_started(&job_id);
let job_target = job.payload().target().clone();
let mut sleds = None;
let mut ticker = interval(WATCH_INTERVAL);
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut last = JobStatusMap::new();
Expand All @@ -1606,7 +1601,12 @@ async fn job_start(
// Wait for the start request to finish.
start_result = &mut start, if !started => {
match start_result {
Ok(_) | Err(CommandError::TimedOut) => ctx.job_started(&job, true),
Ok(response) => {
let started = response.into_inner().sleds;
ctx.job_started(&job, Some(&started), true);
sleds = Some(started);
}
Err(CommandError::TimedOut) => ctx.job_started(&job, None, true),
Err(error) => {
ctx.job_watch_finished(&job_id);
return Err(error);
Expand All @@ -1627,8 +1627,11 @@ async fn job_start(
}
};
ctx.job_watch_update(&last);
let rack = rack_sleds(client, &job_target).await;
if settling.done(&job_target, rack, &last) && started {
let rack = match sleds {
Some(_) => None,
None => rack_sleds(client).await,
};
if settling.done(sleds.as_ref(), rack, &last) && started {
break last;
}
if !stalled && last.is_empty() && settling.polls >= WATCH_STALL_POLLS {
Expand Down Expand Up @@ -1708,8 +1711,8 @@ async fn job_start(
}
}
} else {
start.await?;
ctx.job_started(&job, true);
let sleds = start.await?.into_inner().sleds;
ctx.job_started(&job, Some(&sleds), true);
}
Ok(())
}
Expand Down Expand Up @@ -1918,26 +1921,34 @@ const WATCH_INTERVAL: Duration = Duration::from_secs(1);

/// Watch a job's status across the rack, one live line per sled, until
/// it settles or the user interrupts. Returns the last status map.
/// `sleds`, if known, are the sleds the job runs on.
async fn job_watch(
ctx: &mut impl CommandContext,
client: &Client,
job_id: &JobId,
target: &Target,
sleds: Option<BTreeSet<BaseboardId>>,
) -> Result<JobStatusMap, CommandError> {
ctx.job_watch_started(job_id);
let via = sleds
.as_ref()
.filter(|sleds| sleds.len() == 1)
.and_then(BTreeSet::first);
let mut settling = Settling::default();
let mut sigint = signal(SignalKind::interrupt())?;
let status = loop {
let status = match job_status_map(ctx, client, job_id, target.single_baseboard()).await {
let status = match job_status_map(ctx, client, job_id, via).await {
Ok(status) => status,
Err(error) => {
ctx.job_watch_finished(job_id);
return Err(error);
}
};
ctx.job_watch_update(&status);
let rack = rack_sleds(client, target).await;
if settling.done(target, rack, &status) {
let rack = match sleds {
Some(_) => None,
None => rack_sleds(client).await,
};
if settling.done(sleds.as_ref(), rack, &status) {
break status;
}
select! {
Expand Down Expand Up @@ -1969,12 +1980,17 @@ struct Settling {

impl Settling {
/// A watched job has settled once every sled it runs on reports a
/// terminal status. A target naming only baseboards is settled as
/// soon as they all report. Against the rack inventory, the count
/// terminal status. When those `sleds` are known, the job is settled
/// as soon as they all report. Against the rack inventory, the count
/// must match on consecutive polls. Either way, the watch is done
/// once it is old enough for gossip to have named every sled and
/// the set of sleds has been stable for a couple of polls.
fn done(&mut self, target: &Target, rack: Option<usize>, status: &JobStatusMap) -> bool {
fn done(
&mut self,
sleds: Option<&BTreeSet<BaseboardId>>,
rack: Option<usize>,
status: &JobStatusMap,
) -> bool {
self.polls += 1;
let terminal = !status.is_empty() && status.values().all(JobStatus::is_terminal);
if terminal && status.len() == self.sleds {
Expand All @@ -1988,9 +2004,9 @@ impl Settling {
} else {
self.met = 0;
}
if let Some(named) = target.named_baseboards()
&& named
.into_iter()
if let Some(sleds) = sleds
&& sleds
.iter()
.all(|sled| status.get(sled).is_some_and(JobStatus::is_terminal))
{
return true;
Expand All @@ -2000,11 +2016,7 @@ impl Settling {
}

/// The number of sleds in the rack's own inventory, where available.
/// Skips the fetch for targets whose sleds are named.
async fn rack_sleds(client: &Client, target: &Target) -> Option<usize> {
if target.named_baseboards().is_some() {
return None;
}
async fn rack_sleds(client: &Client) -> Option<usize> {
let sleds = client.versions().send().await.ok()?.into_inner().len();
(sleds > 0).then_some(sleds)
}
Expand Down
3 changes: 2 additions & 1 deletion client/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

//! Command context tracking.

use std::collections::BTreeSet;
use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
Expand Down Expand Up @@ -149,7 +150,7 @@ pub trait CommandContext: Clone + Send + Sync {

// Job management
fn really_target(&mut self, sled: &SledId) -> Result<(), CommandError>;
fn job_started(&mut self, job: &SignedJob, show: bool);
fn job_started(&mut self, job: &SignedJob, sleds: Option<&BTreeSet<BaseboardId>>, show: bool);
fn job_stopped(&mut self, id: &JobId);
fn job_skipped(&mut self, id: &JobId) -> bool;
fn job_error(&mut self, error: CommandError) -> CommandError;
Expand Down
1 change: 1 addition & 0 deletions client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ progenitor::generate_api!(
JobLimits = sush_common::jobs::JobLimits,
JobOutputHash = sush_common::jobs::JobOutputHash,
JobOutputStream = sush_common::jobs::JobOutputStream,
JobStarted = sush_api::JobStarted,
JobStatus = sush_common::jobs::JobStatus,
JobWait = sush_api::JobWait,
KeyId = sush_common::keys::KeyId,
Expand Down
5 changes: 3 additions & 2 deletions client/src/repl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//!
//! Inherits most of its behavior from the (non-interactive) CLI.

use std::collections::BTreeSet;
use std::env;
use std::ffi::OsString;
use std::fs::{Permissions, set_permissions};
Expand Down Expand Up @@ -297,9 +298,9 @@ impl CommandContext for Repl {

// Job management

fn job_started(&mut self, job: &SignedJob, show: bool) {
fn job_started(&mut self, job: &SignedJob, sleds: Option<&BTreeSet<BaseboardId>>, show: bool) {
self.set_job_id(Some(job.job_id().to_owned()));
self.cli.job_started(job, show);
self.cli.job_started(job, sleds, show);
}

fn job_stopped(&mut self, job_id: &JobId) {
Expand Down
Loading