[WIP] Daemon & CLI

This commit is contained in:
Alex-Emmet 2026-07-23 23:13:02 +02:00
commit 8b158108bb
100 changed files with 6519 additions and 1596 deletions

View file

@ -5,5 +5,10 @@ edition = "2024"
[dependencies]
iota-cli = { path = "../iota-cli" }
iota-ipc = { path = "../iota-ipc" }
iota-installer = { path = "../iota-installer" }
iota-core = { path = "../iota-core" }
iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }

154
iota/src/cli_args.rs Normal file
View file

@ -0,0 +1,154 @@
use iota_cli::theme::ThemeName;
#[derive(Debug)]
pub struct CliInvocation {
pub theme_override: Option<ThemeName>,
pub command: Command,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Dashboard,
Help,
Install {
bundle: String,
operator: Option<String>,
},
Status,
Tasks,
UsersList,
DaemonRestart {
confirmed: bool,
},
DaemonStop {
confirmed: bool,
},
DaemonStopProcess,
DaemonEnable {
mode: String,
},
DaemonDisableStartup,
DaemonDaemonStatus,
}
impl CliInvocation {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut theme_override = None;
let mut command = Vec::new();
let mut args = args.into_iter();
while let Some(argument) = args.next() {
if argument == "--theme" {
let value = args.next().ok_or_else(|| {
format!(
"--theme requires a value ({})",
ThemeName::supported_names()
)
})?;
theme_override = Some(value.parse()?);
} else if let Some(value) = argument.strip_prefix("--theme=") {
theme_override = Some(value.parse()?);
} else {
command.push(argument);
}
}
let command = match command.as_slice() {
[] => Command::Dashboard,
[help] if help == "help" || help == "--help" => Command::Help,
[status] if status == "status" => Command::Status,
[tasks] if tasks == "tasks" => Command::Tasks,
[noun, verb] if noun == "users" && verb == "list" => Command::UsersList,
[noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart {
confirmed: flag == "--yes",
},
[noun, verb] if noun == "daemon" && verb == "restart" => {
Command::DaemonRestart { confirmed: false }
}
[noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop {
confirmed: flag == "--yes",
},
[noun, verb] if noun == "daemon" && verb == "stop" => {
Command::DaemonStop { confirmed: false }
}
[noun, verb] if noun == "daemon" && verb == "stop-process" => {
Command::DaemonStopProcess
}
[noun, verb] if noun == "daemon" && verb == "disable-startup" => {
Command::DaemonDisableStartup
}
[noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus,
[noun, verb, flag, mode]
if noun == "daemon" && verb == "enable" && flag == "--mode" =>
{
Command::DaemonEnable { mode: mode.clone() }
}
[noun, verb, bundle_flag, bundle]
if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" =>
{
Command::Install {
bundle: bundle.clone(),
operator: None,
}
}
[noun, verb, bundle_flag, bundle, operator_flag, operator]
if noun == "daemon"
&& verb == "install"
&& bundle_flag == "--bundle"
&& operator_flag == "--operator" =>
{
Command::Install {
bundle: bundle.clone(),
operator: Some(operator.clone()),
}
}
_ => return Err("Unknown command. Run `iota --help`.".into()),
};
Ok(Self {
theme_override,
command,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removes_global_theme_option() {
let invocation =
CliInvocation::parse(["--theme".into(), "binary".into(), "status".into()]).unwrap();
assert_eq!(invocation.theme_override, Some(ThemeName::Binary));
assert_eq!(invocation.command, Command::Status);
}
#[test]
fn reports_supported_names_for_invalid_theme() {
let error = CliInvocation::parse(["--theme=ultraviolet".into()]).unwrap_err();
assert!(error.contains(ThemeName::supported_names()));
}
#[test]
fn parses_install_operator_without_raw_slice_matching() {
let invocation = CliInvocation::parse([
"daemon".into(),
"install".into(),
"--bundle".into(),
"release.zip".into(),
"--operator".into(),
"alice".into(),
])
.unwrap();
assert_eq!(
invocation.command,
Command::Install {
bundle: "release.zip".into(),
operator: Some("alice".into()),
}
);
}
#[test]
fn parses_unconfirmed_destructive_commands_explicitly() {
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
}
}

View file

@ -0,0 +1,241 @@
use crate::startup_error::StartupError;
use iota_cli::{
ipc_client::IpcClient,
screens::daemon_setup::{
DaemonLaunchMode, DaemonSetupDecision, DaemonSetupScreen, DaemonStartingScreen,
LaunchOption,
},
theme::UiConfig,
ui::UI,
};
use iota_process_manager::ProcessManager;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tokio::sync::oneshot;
pub struct Capabilities {
pub executable: Result<PathBuf, StartupError>,
pub socket: Result<(), StartupError>,
pub system: Result<Arc<dyn ProcessManager>, StartupError>,
}
pub struct DaemonEndpoints {
pub local: PathBuf,
pub system: PathBuf,
}
pub struct ConnectionContext {
pub ipc: Arc<IpcClient>,
}
impl Capabilities {
fn options(&self) -> Vec<LaunchOption> {
let once = self
.executable
.as_ref()
.and_then(|_| self.socket.as_ref())
.map(|_| ())
.map_err(ToString::to_string);
let ui = once.clone();
let system = self
.system
.as_ref()
.map(|_| ())
.map_err(ToString::to_string);
vec![
LaunchOption {
mode: DaemonLaunchMode::Once,
enabled: once.is_ok(),
reason: once.err(),
},
LaunchOption {
mode: DaemonLaunchMode::WithUi,
enabled: ui.is_ok(),
reason: ui.err(),
},
LaunchOption {
mode: DaemonLaunchMode::WithSystem,
enabled: system.is_ok(),
reason: system.err(),
},
]
}
}
pub async fn run(
ui: Arc<UI>,
endpoints: &DaemonEndpoints,
caps: Capabilities,
) -> Result<ConnectionContext, StartupError> {
// Try connecting to an already-running daemon before starting a new one.
if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
return Ok(ConnectionContext { ipc });
}
let options = caps.options();
if !options.iter().any(|o| o.enabled) {
let _ = show(
ui,
options,
"Daemon cannot be started. Correct the reported problem, then Retry, or Exit.",
)
.await?;
return Err(StartupError::Cancelled);
}
if UiConfig::load()
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)
.unwrap_or(false)
&& options[0].enabled
{
if let Ok(context) = start_local_with_ui(
ui.clone(),
caps.executable.as_ref().unwrap(),
&endpoints.local,
)
.await
{
return Ok(context);
}
if ui.is_shutdown() {
return Err(StartupError::Cancelled);
}
}
loop {
let decision = show(
ui.clone(),
options.clone(),
"The daemon is not running. Choose how to start it.",
)
.await?;
let DaemonSetupDecision::Start(mode) = decision else {
return Err(StartupError::Cancelled);
};
ui.set_root_screen(Box::new(DaemonStartingScreen)).await;
let result = match mode {
DaemonLaunchMode::Once | DaemonLaunchMode::WithUi => {
start_local_with_ui(
ui.clone(),
caps.executable.as_ref().unwrap(),
&endpoints.local,
)
.await
}
DaemonLaunchMode::WithSystem => {
let manager = caps.system.as_ref().unwrap();
tokio::select! {
result = manager.set_iota_startup_mode(iota_process_manager::StartupMode::SocketActivated) => match result {
Ok(_) => tokio::select! {
result = IpcClient::connect_or_activate(&endpoints.system) => result.map(|ipc| ConnectionContext { ipc }).map_err(|e| StartupError::Other(e.to_string())),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
},
Err(e) => Err(map_process_manager_error(e)),
},
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
}
}
};
if ui.is_shutdown() {
return Err(StartupError::Cancelled);
}
match result {
Ok(ipc) => {
if mode == DaemonLaunchMode::WithUi {
let mut cfg =
UiConfig::load().map_err(|error| StartupError::Other(error.to_string()))?;
cfg.daemon_start_policy = iota_cli::theme::DaemonStartPolicy::WithUi;
cfg.save()
.map_err(|error| StartupError::Other(error.to_string()))?;
}
return Ok(ipc);
}
Err(error) => {
let retry = show(
ui.clone(),
options.clone(),
format!("Daemon startup failed: {error}. Select an option to retry, or Exit."),
)
.await?;
if matches!(retry, DaemonSetupDecision::Exit) {
return Err(StartupError::Cancelled);
}
}
}
}
}
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
use iota_process_manager::ProcessManagerErrorKind;
match error.kind() {
ProcessManagerErrorKind::PermissionDenied => {
StartupError::SystemPermissionDenied(error.to_string())
}
ProcessManagerErrorKind::TimedOut => StartupError::SystemCommandTimedOut(error.to_string()),
_ => StartupError::Other(error.to_string()),
}
}
async fn start_local_with_ui(
ui: Arc<UI>,
exe: &Path,
path: &Path,
) -> Result<ConnectionContext, StartupError> {
tokio::select! {
result = crate::local_daemon::launch(ui.clone(), exe, path) => result.map(|ipc| ConnectionContext { ipc }),
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
}
}
async fn show(
ui: Arc<UI>,
options: Vec<LaunchOption>,
message: impl Into<String>,
) -> Result<DaemonSetupDecision, StartupError> {
let (tx, rx) = oneshot::channel();
let screen = DaemonSetupScreen::new(options, message, tx).map_err(|error| {
StartupError::Other(format!("Cannot construct daemon setup screen: {error:?}"))
})?;
ui.set_root_screen(Box::new(screen)).await;
tokio::select! {
decision = rx => Ok(decision.unwrap_or(DaemonSetupDecision::Exit)),
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_systemd_unit_disables_only_the_system_option() {
let capabilities = Capabilities {
executable: Ok(PathBuf::from("iota-daemon")),
socket: Ok(()),
system: Err(StartupError::Other(
"systemd unit iota-daemon.service was not found".into(),
)),
};
let options = capabilities.options();
assert!(
options
.iter()
.any(|option| option.mode == DaemonLaunchMode::Once && option.enabled)
);
let system = options
.iter()
.find(|option| option.mode == DaemonLaunchMode::WithSystem)
.unwrap();
assert!(!system.enabled);
assert!(system.reason.as_deref().unwrap().contains("was not found"));
}
#[test]
fn system_only_capabilities_do_not_select_disabled_local_mode() {
let capabilities = Capabilities {
executable: Err(StartupError::DaemonExecutableMissing(PathBuf::from(
"iota-daemon",
))),
socket: Err(StartupError::LocalSocketNotWritable(
PathBuf::from("/tmp/iota.sock"),
std::io::Error::other("unavailable"),
)),
system: Err(StartupError::Other("manager unavailable".into())),
};
let options = capabilities.options();
assert!(options.iter().all(|option| !option.enabled));
}
}

120
iota/src/local_daemon.rs Normal file
View file

@ -0,0 +1,120 @@
use crate::startup_error::StartupError;
use iota_cli::{ipc_client::IpcClient, ui::UI};
use std::process::Stdio;
use std::{
collections::VecDeque,
path::Path,
sync::{Arc, Mutex},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::{Child, Command},
time::Instant,
};
struct LocalDaemonGuard {
child: Option<Child>,
committed: bool,
}
impl LocalDaemonGuard {
fn new(child: Child) -> Self {
Self {
child: Some(child),
committed: false,
}
}
fn commit(mut self) -> Child {
self.committed = true;
self.child.take().expect("local daemon child")
}
}
impl Drop for LocalDaemonGuard {
fn drop(&mut self) {
if !self.committed {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
tokio::spawn(async move {
let _ = child.wait().await;
});
}
}
}
}
pub async fn launch(
ui: Arc<UI>,
executable: &Path,
socket: &Path,
) -> Result<Arc<IpcClient>, StartupError> {
let mut child = Command::new(executable)
.env("IOTA_SOCKET", socket)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(false)
.spawn()
.map_err(|e| StartupError::DaemonExited {
message: format!("Could not start daemon: {e}"),
})?;
let diagnostics = Arc::new(Mutex::new(VecDeque::<String>::with_capacity(64)));
if let Some(stderr) = child.stderr.take() {
let diagnostics = diagnostics.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut recent = diagnostics.lock().unwrap();
if recent.len() == 64 {
recent.pop_front();
}
recent.push_back(line);
}
});
}
let mut guard = LocalDaemonGuard::new(child);
let deadline = Instant::now() + Duration::from_secs(20);
let cancellation = ui.cancellation_token();
loop {
let result = tokio::select! {
status = guard.child.as_mut().expect("child").wait() => {
let status = match status { Ok(status) => status.to_string(), Err(error) => format!("wait failed: {error}") };
return Err(StartupError::DaemonExited { message: format_diagnostic(format!("daemon exited with {status}"), &diagnostics) });
}
connection = IpcClient::connect(socket) => connection,
_ = cancellation.cancelled() => return Err(StartupError::Cancelled),
_ = tokio::time::sleep_until(deadline) => return Err(StartupError::DaemonExited { message: format_diagnostic("timed out waiting for IPC handshake".into(), &diagnostics) }),
};
match result {
Ok(client) => {
// The daemon was launched with kill_on_drop(false) so it
// survives after we release the child handle. Let it run
// independently; future CLI instances reconnect via IPC.
let _child = guard.commit();
return Ok(client);
}
Err(_error) if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(200)).await
}
Err(error) => {
return Err(StartupError::DaemonExited {
message: format_diagnostic(
format!("timed out waiting for IPC handshake: {error}"),
&diagnostics,
),
});
}
}
}
}
fn format_diagnostic(message: String, diagnostics: &Arc<Mutex<VecDeque<String>>>) -> String {
let lines = diagnostics.lock().unwrap();
if lines.is_empty() {
message
} else {
format!(
"{message}; daemon stderr: {}",
lines.iter().cloned().collect::<Vec<_>>().join(" | ")
)
}
}

View file

@ -1,31 +1,292 @@
use iota_cli::{ipc_client::IpcClient, screens::main_screen::MainScreen, ui::start_tui};
use std::path::PathBuf;
use iota_cli::{
ipc_client::IpcClient,
screens::main_screen::MainScreen,
theme,
ui::start_bootstrap_tui_with_theme,
};
use iota_ipc::{LocalRequest, ResponseResult};
use iota_process_manager::detect;
use std::{path::Path, process::ExitCode, sync::Arc};
fn socket_path() -> PathBuf {
std::env::var_os("IOTA_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock"))
}
mod cli_args;
mod daemon_setup_flow;
mod local_daemon;
mod startup_error;
use cli_args::{CliInvocation, Command};
use startup_error::StartupError;
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let path = socket_path();
let ipc = match IpcClient::connect_or_activate(&path).await {
Ok(client) => client,
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!(
"Cannot connect to iota-daemon at {}: {error}",
path.display()
);
eprintln!("Ensure iota-daemon.socket is enabled or iota-daemon is running.");
std::process::exit(1);
if !matches!(error, StartupError::Cancelled) {
eprintln!("{error}");
}
startup_error::exit_code(&error)
}
}
}
async fn run() -> Result<(), StartupError> {
let invocation =
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
let mut endpoints_iter = iota_paths::daemon_endpoints().into_iter();
let local_endpoint = endpoints_iter
.next()
.expect("path layer always returns an endpoint");
let system_endpoint = endpoints_iter
.next()
.unwrap_or_else(|| local_endpoint.clone());
let endpoints = daemon_setup_flow::DaemonEndpoints {
local: local_endpoint,
system: system_endpoint,
};
ipc.spawn_reconnector();
let ui = start_tui(ipc);
ui.set_screen(Box::new(MainScreen::new(ui.clone()).await))
.await;
while !ui.is_shutdown() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
match invocation.command {
Command::Help => {
print_help();
Ok(())
}
Command::Install { bundle, operator } => {
iota_installer::install_linux_bundle_with_operator(
Path::new(&bundle),
operator.as_deref(),
)
.map_err(|error| StartupError::Other(format!("Installation failed: {error}")))
}
command => {
if matches!(
command,
Command::DaemonEnable { .. } | Command::DaemonDisableStartup
) {
return run_startup_command(command).await;
}
if !matches!(command, Command::Dashboard) {
match iota_core::consent_state::non_interactive_consent() {
iota_core::consent_state::NonInteractiveConsent::Accepted => {}
iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => {
return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into()));
}
}
let ipc = tokio::select! {
result = connect_available(&endpoints) => result?,
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
};
return run_command(ipc, command).await;
}
run_dashboard(invocation.theme_override, endpoints).await
}
}
}
async fn run_startup_command(command: Command) -> Result<(), StartupError> {
let manager = iota_process_manager::detect()
.await
.ok_or_else(|| StartupError::Other("no supported process manager detected".into()))?;
let status = match command {
Command::DaemonEnable { mode } => {
let mode = match mode.as_str() {
"socket" | "socket-activated" => iota_process_manager::StartupMode::SocketActivated,
"always-on" => iota_process_manager::StartupMode::AlwaysOn,
_ => {
return Err(StartupError::InvalidCommand(
"--mode must be socket or always-on".into(),
));
}
};
manager
.enable_startup(mode)
.await
.map_err(|e| StartupError::Other(e.to_string()))?
}
Command::DaemonDisableStartup => manager
.disable_startup()
.await
.map_err(|e| StartupError::Other(e.to_string()))?,
_ => unreachable!(),
};
println!("deployment status: {:?}", status.detected);
Ok(())
}
async fn connect_available(
endpoints: &daemon_setup_flow::DaemonEndpoints,
) -> Result<Arc<IpcClient>, StartupError> {
match IpcClient::connect(&endpoints.local).await {
Ok(client) => Ok(client),
Err(local_error) => IpcClient::connect(&endpoints.system)
.await
.map_err(|system_error| {
if system_error.kind() == std::io::ErrorKind::TimedOut {
StartupError::IpcTimedOut(endpoints.system.clone())
} else if local_error.kind() == std::io::ErrorKind::PermissionDenied {
StartupError::SocketPermissionDenied(endpoints.local.clone())
} else {
StartupError::Other(format!(
"Could not connect to {} or {}: {local_error}; {system_error}",
endpoints.local.display(),
endpoints.system.display()
))
}
}),
}
}
async fn run_dashboard(
theme_override: Option<theme::ThemeName>,
endpoints: daemon_setup_flow::DaemonEndpoints,
) -> Result<(), StartupError> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err(StartupError::Terminal(
"stdin and stdout must be interactive terminals".into(),
));
}
if std::env::var("TERM").as_deref() == Ok("dumb") {
return Err(StartupError::Terminal(
"TERM=dumb does not support the interactive dashboard".into(),
));
}
let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme(
theme_override,
)))
.map_err(|error| StartupError::Terminal(error.to_string()))?;
let ui = session.ui();
let result = async {
let consent = iota_core::consent_state::check(ui.clone()).await
.map_err(StartupError::Consent)?;
if consent != (true, true) {
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
}
let initial = tokio::select! {
result = connect_available(&endpoints) => result,
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
};
let context = match initial {
Ok(client) => daemon_setup_flow::ConnectionContext { ipc: client },
Err(_) => {
let system = tokio::select! {
manager = detect() => manager.ok_or(StartupError::SystemManagerUnavailable),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
}?;
// A missing unit is expected before the system daemon has
// been installed. Keep bootstrap alive and expose that state
// as a disabled setup option instead of treating it as a
// fatal startup error.
let system_capability = tokio::select! {
status = system.iota_startup_status() => status.map(|_| system).map_err(map_process_manager_error),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
};
let caps = daemon_setup_flow::Capabilities {
executable: daemon_executable(),
socket: writable_socket_path(&endpoints.local),
system: system_capability,
};
daemon_setup_flow::run(ui.clone(), &endpoints, caps).await?
}
};
let ipc = context.ipc.clone();
ipc.spawn_reconnector();
ui.attach_daemon(ipc).await;
let main_screen = MainScreen::new(ui.clone()).await;
ui.set_root_screen(Box::new(main_screen)).await;
ui.render().await.map_err(|error| StartupError::Terminal(error.to_string()))?;
ui.wait_for_shutdown().await;
Ok(())
}.await;
let render_failure = session.shutdown().await;
// Terminal restoration comes first; then stop IPC background tasks with
// their own bounded shutdown so a lost daemon cannot retain the process.
if let Some(ipc) = ui.ipc().await {
ipc.shutdown().await;
}
match (result, render_failure) {
(Err(error), _) => Err(error),
(Ok(()), Some(error)) => Err(StartupError::Terminal(error)),
(Ok(()), None) => Ok(()),
}
}
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
use iota_process_manager::ProcessManagerErrorKind::*;
match error.kind() {
PermissionDenied => StartupError::SystemPermissionDenied(error.to_string()),
TimedOut => StartupError::SystemCommandTimedOut(error.to_string()),
_ => StartupError::Other(error.to_string()),
}
}
fn daemon_executable() -> Result<std::path::PathBuf, StartupError> {
let candidate = iota_paths::daemon_executable();
if candidate.is_file() {
Ok(candidate)
} else {
Err(StartupError::DaemonExecutableMissing(candidate))
}
}
fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
let parent = path.parent().ok_or_else(|| {
StartupError::LocalSocketNotWritable(
path.to_path_buf(),
std::io::Error::new(std::io::ErrorKind::InvalidInput, "socket has no parent"),
)
})?;
std::fs::create_dir_all(parent)
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
let probe = parent.join(format!(".iota-write-probe-{}", std::process::id()));
std::fs::File::create(&probe)
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
let _ = std::fs::remove_file(probe);
Ok(())
}
fn print_help() {
println!(
"Iota operator console\n\nUsage:\n iota [--theme <name>] Open the dashboard\n iota daemon install --bundle <release.zip> [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms."
);
}
async fn run_command(ipc: Arc<IpcClient>, command: Command) -> Result<(), StartupError> {
let request = match command {
Command::Status => LocalRequest::GetStatus,
Command::Tasks => LocalRequest::ListTasks,
Command::UsersList => LocalRequest::ListUsers,
Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
},
Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
},
Command::DaemonStopProcess => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
},
Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus,
Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => {
return Err(StartupError::InvalidCommand(
"Refusing destructive command without --yes.".into(),
));
}
_ => {
return Err(StartupError::InvalidCommand(
"Command cannot be run headlessly.".into(),
));
}
};
match ipc
.send_request(request)
.await
.map_err(|e| StartupError::Other(e.to_string()))?
{
ResponseResult::Ok(message) => {
println!("{message}");
Ok(())
}
ResponseResult::Error(code) => Err(StartupError::Other(format!(
"Daemon request failed: {code:?}"
))),
}
}

92
iota/src/startup_error.rs Normal file
View file

@ -0,0 +1,92 @@
use std::{fmt, io, path::PathBuf, process::ExitCode};
#[allow(dead_code)]
#[derive(Debug)]
pub enum StartupError {
Cancelled,
DaemonExecutableMissing(PathBuf),
LocalSocketNotWritable(PathBuf, io::Error),
SystemManagerUnavailable,
SystemPermissionDenied(String),
SystemCommandTimedOut(String),
SocketPermissionDenied(PathBuf),
IpcTimedOut(PathBuf),
ProtocolMismatch { daemon: u16, minimum: u16 },
DaemonExited { message: String },
IpcBindUnavailable(String),
Terminal(String),
Consent(String),
InvalidCommand(String),
Other(String),
}
impl StartupError {
pub fn exit_code(&self) -> u8 {
match self {
Self::Cancelled => 130,
Self::InvalidCommand(_) => 2,
_ => 1,
}
}
}
impl fmt::Display for StartupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Cancelled => f.write_str("Cancelled."),
Self::DaemonExecutableMissing(path) => write!(
f,
"Daemon executable is missing or not executable: {}",
path.display()
),
Self::LocalSocketNotWritable(path, error) => write!(
f,
"Local socket path is not writable ({}): {error}",
path.display()
),
Self::SystemManagerUnavailable => {
f.write_str("No supported system process manager is available.")
}
Self::SystemPermissionDenied(message) => {
write!(f, "System-level authorization is required: {message}")
}
Self::SystemCommandTimedOut(command) => {
write!(f, "System command timed out: {command}")
}
Self::SocketPermissionDenied(path) => {
write!(f, "Permission denied for IPC socket {}", path.display())
}
Self::IpcTimedOut(path) => write!(f, "IPC operation timed out for {}", path.display()),
Self::ProtocolMismatch { daemon, minimum } => write!(
f,
"Daemon protocol {daemon} is incompatible; minimum supported version is {minimum}"
),
Self::DaemonExited { message } => f.write_str(message),
Self::IpcBindUnavailable(message) => {
write!(f, "Daemon IPC listener is unavailable: {message}")
}
Self::Terminal(message) => write!(f, "Interactive terminal is unavailable: {message}"),
Self::Consent(message) | Self::InvalidCommand(message) | Self::Other(message) => {
f.write_str(message)
}
}
}
}
pub fn exit_code(error: &StartupError) -> ExitCode {
ExitCode::from(error.exit_code())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cancellation_and_invalid_commands_have_stable_codes() {
assert_eq!(StartupError::Cancelled.exit_code(), 130);
assert_eq!(StartupError::InvalidCommand("bad".into()).exit_code(), 2);
}
#[test]
fn administrative_errors_are_actionable() {
let error = StartupError::SystemPermissionDenied("run as an administrator".into());
assert!(error.to_string().contains("authorization"));
assert!(error.to_string().contains("administrator"));
}
}