Skip to content
Merged
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
59 changes: 49 additions & 10 deletions rust/cli.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use crate::{
Error, Result, computer,
config::Config,
host, operation,
diagnostic, host, operation,
presence::Interaction,
report::FailureCode,
request::{Reply, Request},
source, supervisor, version,
};
Expand All @@ -13,6 +14,14 @@ use tokio::{
time::timeout,
};

pub fn failure_line() -> &'static str {
"The installation could not finish. Run the same install command again."
}

pub fn preserve_top_level_failure(error: &Error) {
diagnostic::preserve_top_level_failure(error);
}

const HELP: &str = "Raft Computer installation commands

install|upgrade [--version V | --channel main|alpha|NAME] [--yes] [--allow-downgrade] [--json]
Expand Down Expand Up @@ -115,10 +124,12 @@ async fn worker(cfg: &Config) -> Result<u8> {
request.validate()?;
let reply = match operation::execute(cfg, &request).await {
Ok(reply) => reply,
Err(Error::Locked(_)) => Reply::plain(
Err(Error::Locked(_)) => Reply::failure(
&request.id,
2,
"Another installation is running on this machine.",
FailureCode::OperationBusy,
"Another installation is running on this machine.",
),
Err(error) => {
// Inspect persistent state instead of reporting a write/read failure
Expand All @@ -132,14 +143,19 @@ async fn worker(cfg: &Config) -> Result<u8> {
operation: k_carrier::state::Operation { outcome: None, .. }
}
);
eprintln!("Installation: {error}");
Reply::plain(
let diagnostic = diagnostic::safe_error(&error);
Reply::failure(
&request.id,
if unresolved { 3 } else { 1 },
String::from(
"The installation could not finish. Run the same install command again to check and continue.",
),
failure_line(),
if unresolved {
FailureCode::RecoveryUnresolved
} else {
FailureCode::InstallFailed
},
"The installation could not finish.",
)
.with_diagnostic(diagnostic)
}
};
// execute() has released the operation gate. Cleanup reacquires it and
Expand Down Expand Up @@ -198,9 +214,19 @@ pub async fn run() -> Result<u8> {
// OS parent identity limits the exemption to this invocation.
args.request.waiting_caller = Some(parent);
} else if !remote {
return Err(invalid(
"Computer caller has no waiting declaration; run the install command directly",
));
let reply = Reply::failure(
&args.request.id,
1,
"This command is started by Raft Computer, not run by hand. To install or upgrade, use the install command from the setup page.",
FailureCode::CallerNotComputer,
"This command is started by Raft Computer, not run by hand.",
);
if args.json {
println!("{}", serde_json::to_string(&reply)?);
} else {
eprintln!("{}", reply.line);
}
return Ok(reply.exit_code);
}
}
}
Expand All @@ -212,3 +238,16 @@ pub async fn run() -> Result<u8> {
}
Ok(reply.exit_code)
}

#[cfg(test)]
mod failure_line_tests {
use super::*;

#[test]
fn failure_line_has_one_runnable_next_step() {
assert_eq!(
failure_line(),
"The installation could not finish. Run the same install command again."
);
}
}
2 changes: 1 addition & 1 deletion rust/computer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ fn redact_opaque_word(word: &str) -> String {
}
}

fn diagnostic_stderr(bytes: &[u8]) -> (String, bool) {
pub(crate) fn diagnostic_stderr(bytes: &[u8]) -> (String, bool) {
let original_truncated = bytes.len() > DIAGNOSTIC_STDERR_LIMIT;
let mut redacted = Vec::new();
let mut redact_next_nonempty_line = false;
Expand Down
90 changes: 90 additions & 0 deletions rust/diagnostic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! Private, bounded diagnostics for failures that have no durable operation
//! receipt. Human output must never depend on or render these records.
use crate::{Error, Result, computer, config::Config};
use k_carrier::storage::{ensure_dir, now_ms, sync_dir, write_json};
use serde::{Deserialize, Serialize};
use std::fs;

const CLI_DIAGNOSTIC_LIMIT: usize = 16;

#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CliDiagnostic {
format_version: u32,
kind: String,
diagnostic: String,
truncated: bool,
recorded_at_ms: u64,
}

pub fn safe_error(error: &Error) -> String {
computer::diagnostic_stderr(error.to_string().as_bytes()).0
}

/// Preserve a last-resort launcher failure when configuration is usable. A
/// configuration error may make the state root unknowable; that case remains
/// fail-closed instead of guessing another user's or profile's directory.
pub fn preserve_top_level_failure(error: &Error) {
let Ok(cfg) = Config::load() else {
return;
};
let _ = write_cli_diagnostic(&cfg, error);
}

fn write_cli_diagnostic(cfg: &Config, error: &Error) -> Result<()> {
let directory = cfg.installer_dir.join("diagnostics");
ensure_dir(&directory)?;
let raw = error.to_string();
let (diagnostic, truncated) = computer::diagnostic_stderr(raw.as_bytes());
let name = format!("cli-{}.json", uuid::Uuid::new_v4());
write_json(
&directory.join(&name),
&CliDiagnostic {
format_version: 1,
kind: "top-level-error".into(),
diagnostic,
truncated,
recorded_at_ms: now_ms(),
},
)?;
prune_cli_diagnostics(&directory)
}

fn prune_cli_diagnostics(directory: &std::path::Path) -> Result<()> {
let mut diagnostics = Vec::new();
for entry in fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Some(id) = name
.strip_prefix("cli-")
.and_then(|name| name.strip_suffix(".json"))
else {
continue;
};
if !entry.file_type()?.is_file() || uuid::Uuid::parse_str(id).is_err() {
continue;
}
let Ok(bytes) = fs::read(entry.path()) else {
continue;
};
if bytes.len() > 65536 {
continue;
}
let Ok(diagnostic) = serde_json::from_slice::<CliDiagnostic>(&bytes) else {
continue;
};
diagnostics.push((diagnostic.recorded_at_ms, entry.path()));
}
diagnostics.sort_by_key(|(recorded_at_ms, _)| *recorded_at_ms);
let excess = diagnostics.len().saturating_sub(CLI_DIAGNOSTIC_LIMIT);
for (_, path) in diagnostics.into_iter().take(excess) {
fs::remove_file(path)?;
}
if excess > 0 {
sync_dir(directory)?;
}
Ok(())
}
1 change: 1 addition & 0 deletions rust/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod cleanup;
pub mod cli;
pub mod computer;
pub mod config;
mod diagnostic;
pub mod host;
pub mod operation;
pub mod presence;
Expand Down
4 changes: 2 additions & 2 deletions rust/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ async fn main() {
let code = match raft_computer_installer::cli::run().await {
Ok(code) => code,
Err(error) => {
eprintln!("Installation: {error}");
println!("The installation could not finish.");
raft_computer_installer::cli::preserve_top_level_failure(&error);
eprintln!("{}", raft_computer_installer::cli::failure_line());
1
}
};
Expand Down
Loading
Loading