Skip to content

Commit c3513fd

Browse files
authored
Merge pull request #60 from oxidecomputer/availability-fixes
Availability fixes
2 parents 7559b62 + f7b7c40 commit c3513fd

15 files changed

Lines changed: 311 additions & 119 deletions

File tree

api/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ pub trait SushApi {
189189
ctx: RequestContext<Self::Context>,
190190
headers: Header<Authorization>,
191191
params: PathParams<JobIdParam>,
192+
query: QueryParams<RoutingParam>,
192193
) -> Result<HttpResponseOk<JsonJobStatusMap>, HttpError>;
193194

194195
/// Get (a subset of) the standard output or standard error of a job.
@@ -387,6 +388,8 @@ impl JobWait {
387388
pub struct JobStopParams {
388389
/// Wait for the job process to end.
389390
pub wait: JobWait,
391+
/// Where a proxy should route this request. Sleds ignore it.
392+
pub via: Option<String>,
390393
}
391394

392395
/// Simple pagination for history list.

client/src/cli.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use sush_common::jobs::{
2727
SignedJob, job_status_to_json_map,
2828
};
2929
use sush_common::keys::{KeyId, Signature, SshPublicKey};
30-
use sush_common::targets::{MAX_CUBBY, SledVersion};
30+
use sush_common::targets::{MAX_CUBBY, SledId, SledVersion};
3131
use sush_common::version::VersionInfo;
3232

3333
use crate::AuthzSigner;
@@ -463,6 +463,15 @@ impl CommandContext for Cli {
463463
}
464464
}
465465

466+
fn job_watch_stalled(&mut self, job_id: &JobId) {
467+
let guard = self.watch.lock().unwrap();
468+
if let Some(watch) = guard.as_ref() {
469+
let _ = watch.multi.println(format!(
470+
"❗ No sled has reported a status for job `{job_id}`"
471+
));
472+
}
473+
}
474+
466475
fn job_watch_finished(&mut self, _job_id: &JobId) {
467476
if let Some(watch) = self.watch.lock().unwrap().take() {
468477
for bar in watch.bars.values() {
@@ -757,6 +766,21 @@ impl CommandContext for Cli {
757766
Ok(())
758767
}
759768

769+
fn really_target(&mut self, sled: &SledId) -> Result<(), CommandError> {
770+
match self.get_output_format() {
771+
OutputFormat::Json => Ok(()),
772+
OutputFormat::Text => {
773+
let prompt =
774+
format!("❓ Sled `{sled}` is not in the rack inventory. Proceed (yes/no)? ");
775+
if read_bool(&prompt)? {
776+
Ok(())
777+
} else {
778+
Err(CommandError::Canceled)
779+
}
780+
}
781+
}
782+
}
783+
760784
fn really_revoke(&mut self, what: &str, key_id: KeyId) -> Result<KeyId, CommandError> {
761785
match self.get_output_format() {
762786
OutputFormat::Json => Ok(key_id),

client/src/commands.rs

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,6 +1169,9 @@ async fn job(
11691169
return Err(CommandError::io(path, ErrorKind::AlreadyExists.into()));
11701170
}
11711171
}
1172+
if let Some(client) = client.as_ref() {
1173+
preflight_target(ctx, client, &target).await?;
1174+
}
11721175
let streaming = if *streaming {
11731176
Streaming::Output
11741177
} else {
@@ -1211,7 +1214,7 @@ async fn job(
12111214
}
12121215

12131216
(JobCommand::Stop { job_id }, Some(client)) => {
1214-
job_stop(ctx, client, &job_id).await?;
1217+
job_stop(ctx, client, &job_id, None).await?;
12151218
ctx.job_stopped(&job_id);
12161219
Ok(())
12171220
}
@@ -1403,6 +1406,7 @@ async fn job_start(
14031406
let mut last = JobStatusMap::new();
14041407
let mut settling = Settling::default();
14051408
let mut started = false;
1409+
let mut stalled = false;
14061410
let mut stopped = false;
14071411
let mut sigint = signal(SignalKind::interrupt())?;
14081412
let status = loop {
@@ -1421,7 +1425,7 @@ async fn job_start(
14211425
}
14221426

14231427
_ = ticker.tick() => {
1424-
last = match job_status_map(ctx, client, &job_id).await {
1428+
last = match job_status_map(ctx, client, &job_id, job_target.single_baseboard()).await {
14251429
Ok(status) => status,
14261430
// The job may not be visible anywhere yet.
14271431
Err(CommandError::NotFound(_)) => JobStatusMap::new(),
@@ -1435,6 +1439,10 @@ async fn job_start(
14351439
if settling.done(&job_target, rack, &last) && started {
14361440
break last;
14371441
}
1442+
if !stalled && last.is_empty() && settling.polls >= WATCH_STALL_POLLS {
1443+
ctx.job_watch_stalled(&job_id);
1444+
stalled = true;
1445+
}
14381446
}
14391447

14401448
// While the job runs, an interrupt stops it but keeps
@@ -1448,7 +1456,7 @@ async fn job_start(
14481456
break last;
14491457
}
14501458
for _ in 0..3 {
1451-
match job_stop(ctx, client, &job_id).await {
1459+
match job_stop(ctx, client, &job_id, job_target.single_baseboard()).await {
14521460
Ok(_) => {
14531461
ctx.job_stopped(&job_id);
14541462
stopped = true;
@@ -1518,14 +1526,14 @@ async fn job_stop(
15181526
ctx: &mut impl CommandContext,
15191527
client: &Client,
15201528
job_id: &JobId,
1529+
via: Option<&BaseboardId>,
15211530
) -> Result<(), CommandError> {
1522-
with_login(ctx, client, async || {
1523-
client
1524-
.job_stop()
1525-
.job_id(job_id)
1526-
.wait(JobWait::Stop)
1527-
.send()
1528-
.await
1531+
with_login_via(ctx, client, via, async || {
1532+
let mut request = client.job_stop().job_id(job_id).wait(JobWait::Stop);
1533+
if let Some(via) = via {
1534+
request = request.via(via.to_string());
1535+
}
1536+
request.send().await
15291537
})
15301538
.await?;
15311539
Ok(())
@@ -1537,19 +1545,26 @@ async fn job_status(
15371545
job_id: &JobId,
15381546
style: StatusDisplayStyle,
15391547
) -> Result<(), CommandError> {
1540-
let status = job_status_map(ctx, client, job_id).await?;
1548+
let status = job_status_map(ctx, client, job_id, None).await?;
15411549
ctx.job_status(job_id, &status, style);
15421550
Ok(())
15431551
}
15441552

1545-
/// Fetch a job's rack-wide status map.
1553+
/// Fetch a job's rack-wide status map. Routing `via` a single-sled
1554+
/// target gets its authoritative status and keeps the login on the
1555+
/// sled that already knows it.
15461556
async fn job_status_map(
15471557
ctx: &mut impl CommandContext,
15481558
client: &Client,
15491559
job_id: &JobId,
1560+
via: Option<&BaseboardId>,
15501561
) -> Result<JobStatusMap, CommandError> {
1551-
let status = with_login(ctx, client, async || {
1552-
client.job_status().job_id(job_id).send().await
1562+
let status = with_login_via(ctx, client, via, async || {
1563+
let mut request = client.job_status().job_id(job_id);
1564+
if let Some(via) = via {
1565+
request = request.via(via.to_string());
1566+
}
1567+
request.send().await
15531568
})
15541569
.await?
15551570
.into_inner();
@@ -1571,7 +1586,7 @@ async fn job_watch(
15711586
let mut settling = Settling::default();
15721587
let mut sigint = signal(SignalKind::interrupt())?;
15731588
let status = loop {
1574-
let status = match job_status_map(ctx, client, job_id).await {
1589+
let status = match job_status_map(ctx, client, job_id, target.single_baseboard()).await {
15751590
Ok(status) => status,
15761591
Err(error) => {
15771592
ctx.job_watch_finished(job_id);
@@ -1597,6 +1612,10 @@ async fn job_watch(
15971612
/// is likely still missing sleds.
15981613
const WATCH_MIN_POLLS: usize = 5;
15991614

1615+
/// How many polls a watch may go without any sled reporting a status
1616+
/// before warning that the job may never run.
1617+
const WATCH_STALL_POLLS: usize = 15;
1618+
16001619
/// Rolling settlement state for a watched job.
16011620
#[derive(Default)]
16021621
struct Settling {
@@ -1707,7 +1726,7 @@ async fn job_output(
17071726
}
17081727

17091728
// Fetch output from every sled with a recorded status.
1710-
let status = job_status_map(ctx, client, &args.job_id).await?;
1729+
let status = job_status_map(ctx, client, &args.job_id, None).await?;
17111730
if status.is_empty() {
17121731
return Err(CommandError::NotFound(format!(
17131732
"Job `{}` not found",
@@ -1738,14 +1757,7 @@ async fn job_output_from(
17381757
}: JobOutput,
17391758
) -> Result<(), CommandError> {
17401759
// Fetch job status for output length and hash.
1741-
let status = job_status_try_from_json_map(
1742-
with_login_via(ctx, client, Some(target), async || {
1743-
client.job_status().job_id(job_id).send().await
1744-
})
1745-
.await?
1746-
.into_inner(),
1747-
)
1748-
.map_err(CommandError::BaseboardIdParseError)?;
1760+
let status = job_status_map(ctx, client, &job_id, Some(target)).await?;
17491761

17501762
let JobOutputState {
17511763
stdout_len,
@@ -2081,6 +2093,32 @@ impl FromStr for TargetArg {
20812093
}
20822094
}
20832095

2096+
/// Confirm before signing a job for a sled that doesn't appear in inventory.
2097+
#[cfg(feature = "permslip")]
2098+
async fn preflight_target(
2099+
ctx: &mut impl CommandContext,
2100+
client: &Client,
2101+
target: &Target,
2102+
) -> Result<(), CommandError> {
2103+
let Target::Sleds(sleds) = target else {
2104+
return Ok(());
2105+
};
2106+
let Ok(inventory) = client.versions().send().await else {
2107+
return Ok(());
2108+
};
2109+
let inventory = inventory.into_inner();
2110+
for sled in sleds {
2111+
let known = match sled {
2112+
SledId::Baseboard(baseboard) => inventory.iter().any(|s| &s.baseboard == baseboard),
2113+
SledId::Cubby(cubby) => inventory.iter().any(|s| s.cubby == Some(*cubby)),
2114+
};
2115+
if !known {
2116+
ctx.really_target(sled)?;
2117+
}
2118+
}
2119+
Ok(())
2120+
}
2121+
20842122
/// Resolve a target argument to a target, matching bare serial
20852123
/// numbers against the rack's sled inventory.
20862124
async fn resolve_target_arg(client: &Client, target: &TargetArg) -> Result<Target, CommandError> {
@@ -2120,7 +2158,7 @@ async fn resolve_serial(
21202158
job_id: &JobId,
21212159
serial: &str,
21222160
) -> Result<BaseboardId, CommandError> {
2123-
let status = job_status_map(ctx, client, job_id).await?;
2161+
let status = job_status_map(ctx, client, job_id, None).await?;
21242162
let mut matches = status
21252163
.keys()
21262164
.filter(|b| b.serial_number.eq_ignore_ascii_case(serial));

client/src/context.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use sush_common::jobs::{
1818
Access, JobId, JobOutputStream, JobStatusMap, Session, SessionId, SignedJob,
1919
};
2020
use sush_common::keys::{KeyId, SshPublicKey};
21-
use sush_common::targets::SledVersion;
21+
use sush_common::targets::{SledId, SledVersion};
2222
use sush_common::version::VersionInfo;
2323

2424
use crate::AuthzSigner;
@@ -130,6 +130,7 @@ pub trait CommandContext: Clone + Send + Sync {
130130
fn cert_imported(&mut self, path: &Path, key_id: KeyId) -> Result<(), CommandError>;
131131

132132
// Job management
133+
fn really_target(&mut self, sled: &SledId) -> Result<(), CommandError>;
133134
fn job_started(&mut self, job: &SignedJob);
134135
fn job_stopped(&mut self, id: &JobId);
135136
fn job_error(&mut self, error: CommandError) -> CommandError;
@@ -146,6 +147,7 @@ pub trait CommandContext: Clone + Send + Sync {
146147
fn job_output_finished(&mut self, id: &JobId, stream: JobOutputStream, stage: Option<&str>);
147148
fn job_watch_started(&mut self, id: &JobId);
148149
fn job_watch_update(&mut self, status: &JobStatusMap);
150+
fn job_watch_stalled(&mut self, id: &JobId);
149151
fn job_watch_finished(&mut self, id: &JobId);
150152
fn job_attached(&mut self, id: &JobId);
151153
fn job_detached(&mut self, id: &JobId);

client/src/repl.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use sush_common::jobs::{
2323
Access, JobId, JobOutputStream, JobStatusMap, Session, SessionId, SignedJob,
2424
};
2525
use sush_common::keys::{KeyId, SshPublicKey};
26-
use sush_common::targets::SledVersion;
26+
use sush_common::targets::{SledId, SledVersion};
2727
use sush_common::version::VersionInfo;
2828

2929
use crate::cli::Cli;
@@ -338,6 +338,10 @@ impl CommandContext for Repl {
338338
self.cli.job_watch_update(status)
339339
}
340340

341+
fn job_watch_stalled(&mut self, job_id: &JobId) {
342+
self.cli.job_watch_stalled(job_id)
343+
}
344+
341345
fn job_watch_finished(&mut self, job_id: &JobId) {
342346
self.cli.job_watch_finished(job_id)
343347
}
@@ -365,6 +369,10 @@ impl CommandContext for Repl {
365369
self.cli.please_touch(identity)
366370
}
367371

372+
fn really_target(&mut self, sled: &SledId) -> Result<(), CommandError> {
373+
self.cli.really_target(sled)
374+
}
375+
368376
fn really_revoke(&mut self, what: &str, key_id: KeyId) -> Result<KeyId, CommandError> {
369377
self.cli.really_revoke(what, key_id)
370378
}

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ check:
44
cargo check --workspace --all-targets
55

66
lint:
7-
cargo fmt --check && cargo clippy --tests
7+
cargo fmt --check && cargo clippy --tests -- --no-deps --deny warnings
88

99
test *FILTER:
1010
cargo nextest run --workspace {{FILTER}}

server/src/executor.rs

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::sync::{Arc, RwLock};
1818
use chrono::Utc;
1919
use futures::Stream;
2020
use pwd::Passwd;
21-
use rustix::io::close;
21+
use rustix::io::{Errno, close};
2222
use rustix::process::{Pid, Signal, ioctl_tiocsctty, kill_process_group, setsid};
2323
use slog::{Logger, debug, error, o, warn};
2424
use tokio::fs::{DirBuilder, OpenOptions};
@@ -488,20 +488,24 @@ async fn send_error(
488488
}
489489
}
490490

491-
/// Kill a job's whole process group, of which `child` should be the leader.
492-
///
493-
/// TODO: 2-stage stop with `SIGTERM` and a grace period.
494-
pub fn kill_job(log: &Logger, child: &Child) {
495-
if let Some(pid) = child.id()
496-
&& let Ok(pid) = pid.try_into()
497-
&& let Some(pid) = Pid::from_raw(pid)
498-
{
499-
match kill_process_group(pid, Signal::KILL) {
500-
Ok(()) => debug!(log, "killed job processes"),
501-
Err(error) => error!(log, "unable to kill job"; "error" => %error),
491+
/// A job's process group ID, which its `child` leads. Capture it
492+
/// before the leader is reaped, after which `id` returns nothing.
493+
pub fn job_pgid(child: &Child) -> Option<Pid> {
494+
Pid::from_raw(child.id()?.try_into().ok()?)
495+
}
496+
497+
/// Send a signal to a job's whole process group.
498+
pub fn kill_job(log: &Logger, pgid: Option<Pid>, signal: Signal) {
499+
let Some(pgid) = pgid else {
500+
debug!(log, "job has no process group");
501+
return;
502+
};
503+
match kill_process_group(pgid, signal) {
504+
Ok(()) => debug!(log, "signalled job processes"; "signal" => ?signal),
505+
Err(Errno::SRCH) => debug!(log, "job processes are already dead"),
506+
Err(error) => {
507+
error!(log, "unable to signal job"; "signal" => ?signal, "error" => %error)
502508
}
503-
} else {
504-
debug!(log, "process is already dead or has an invalid PID");
505509
}
506510
}
507511

server/src/io.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ impl JobIo {
169169

170170
/// How long to keep reading output after the child dies.
171171
///
172-
/// For a pty there is no portable EOF signal; a short quiet period
173-
/// is the only way to know we've drained (OpenSSH does this too).
172+
/// For a pty there is no portable EOF signal; a short window
173+
/// after death is the best we can do (OpenSSH does this too).
174174
/// Pipes deliver EOF once every writer exits, so this is only a
175175
/// backstop against a descendant that escaped the process group
176176
/// while holding the inherited pipe open.

0 commit comments

Comments
 (0)