[WIP] Daemon & CLI
This commit is contained in:
parent
56aad3a023
commit
8b158108bb
100 changed files with 6519 additions and 1596 deletions
|
|
@ -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
154
iota/src/cli_args.rs
Normal 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 });
|
||||
}
|
||||
}
|
||||
241
iota/src/daemon_setup_flow.rs
Normal file
241
iota/src/daemon_setup_flow.rs
Normal 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
120
iota/src/local_daemon.rs
Normal 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(" | ")
|
||||
)
|
||||
}
|
||||
}
|
||||
307
iota/src/main.rs
307
iota/src/main.rs
|
|
@ -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
92
iota/src/startup_error.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue