[Wip] CLI & Daemon

This commit is contained in:
Alex 2026-07-25 18:23:36 +02:00
commit 6a535099bb
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
44 changed files with 4417 additions and 303 deletions

View file

@ -9,12 +9,14 @@ iota-ipc = { path = "../iota-ipc" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage" }
iota-updater = { path = "../iota-updater" }
iota-util = { path = "../iota-util" }
omikron-connector = { path = "../omikron-connector" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
dashmap = "6.1.0"
libc = "0.2"
sysinfo = "0.38.3"
serde_yaml = "0.9"
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
uuid = { version = "*", features = ["v4"] }

View file

@ -1,10 +1,16 @@
use crate::{DaemonRuntime, DaemonServices};
use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult};
use crate::log_buffer::LogBuffer;
use iota_ipc::{
ComponentStatusResponse, CommunitySummary, ConfigResponse, ExitIntent, IpcErrorCode,
LogEntriesResponse, LocalRequest, OmikronStatusResponse, ResponseEnvelope, ResponsePayload,
ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse,
UserSummary,
};
use iota_logger::{log, log_command};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::modify_config;
use iota_storage::util::config_util::{self, modify_config};
use mtp::codec::{CommunicationType, CommunicationValue};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::daemon_state::{ShutdownReason, StartupPhase};
@ -13,11 +19,12 @@ use crate::daemon_state::{ShutdownReason, StartupPhase};
pub struct CommandRouter {
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_buffer: Arc<Mutex<LogBuffer>>,
}
impl CommandRouter {
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>) -> Self {
Self { runtime, services }
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>, log_buffer: Arc<Mutex<LogBuffer>>) -> Self {
Self { runtime, services, log_buffer }
}
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
@ -26,35 +33,6 @@ impl CommandRouter {
ResponseEnvelope { request_id, result }
}
/// Parse a legacy console command string into a typed request.
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
match parts.as_slice() {
["help"] => None,
["tasks"] => Some(LocalRequest::ListTasks),
["ping", _] | ["ping"] => None,
["user", "add", username] => Some(LocalRequest::CreateUser {
username: username.to_string(),
}),
["user", "remove", username] => {
let user = user_manager::get_user_by_username(username)?;
Some(LocalRequest::RemoveUser {
user_id: user.user_id,
})
}
["user", "list"] => Some(LocalRequest::ListUsers),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
intent: ExitIntent::Restart,
}),
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
intent: ExitIntent::Stop,
}),
_ => None,
}
}
async fn execute(&self, request: LocalRequest) -> ResponseResult {
let needs_omikron = matches!(
request,
@ -83,28 +61,33 @@ impl CommandRouter {
.iter()
.map(|task| task.to_string())
.collect();
let mut info = format!("Phase: {:?}, Tasks: {}", phase, tasks.join(", "));
if let Some(reason) = degraded {
info.push_str(&format!(", Degraded: {}", reason));
}
ResponseResult::Ok(info)
ResponseResult::Ok(ResponsePayload::Status(StatusResponse {
phase: format!("{:?}", phase),
tasks: tasks.clone(),
degraded_reason: degraded,
}))
}
LocalRequest::ListTasks => {
let tasks: Vec<String> = self
let tasks: Vec<TaskSummary> = self
.runtime
.state
.active_tasks
.iter()
.map(|task| task.to_string())
.map(|task| TaskSummary {
name: task.to_string(),
})
.collect();
ResponseResult::Ok(tasks.join(", "))
ResponseResult::Ok(ResponsePayload::Tasks(tasks))
}
LocalRequest::ListUsers => {
let users: Vec<String> = user_manager::get_users()
let users: Vec<UserSummary> = user_manager::get_users()
.into_iter()
.map(|user| format!("{} ({})", user.username, user.user_id))
.map(|user| UserSummary {
user_id: user.user_id,
username: user.username,
})
.collect();
ResponseResult::Ok(users.join("\n"))
ResponseResult::Ok(ResponsePayload::Users(users))
}
LocalRequest::CreateUser { username } => {
match omikron_connector::user_ops::create_user(
@ -113,7 +96,12 @@ impl CommandRouter {
)
.await
{
(Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)),
(Some(user), _) => {
ResponseResult::Ok(ResponsePayload::UserCreated {
user_id: user.user_id,
username: user.username,
})
}
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
}
}
@ -128,10 +116,12 @@ impl CommandRouter {
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
}
user_manager::remove_user(user.user_id);
ResponseResult::Ok(format!("Removed user {}", user.user_id))
ResponseResult::Ok(ResponsePayload::UserRemoved { user_id })
}
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()),
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Reconnected to Omikron server".into(),
}),
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
},
LocalRequest::RotateIotaIdentity => {
@ -141,9 +131,9 @@ impl CommandRouter {
config.iota_id = None;
});
match self.services.omikron.reconnect().await {
Ok(()) => ResponseResult::Ok(
"Key pair regenerated and Omikron reconnection requested".into(),
),
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Key pair regenerated and Omikron reconnection requested".into(),
}),
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
}
}
@ -160,18 +150,120 @@ impl CommandRouter {
ExitIntent::Stop => ShutdownReason::Stop,
ExitIntent::Restart => ShutdownReason::Restart,
});
ResponseResult::Ok("process exit accepted".into())
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "process exit accepted".into(),
})
}
LocalRequest::GetDaemonStatus => {
ResponseResult::Ok(format!("{:?}", self.runtime.snapshot()))
ResponseResult::Ok(ResponsePayload::DaemonStatus(
iota_ipc::DaemonStatusResponse {
formatted: format!("{:?}", self.runtime.snapshot()),
},
))
}
LocalRequest::RestartDaemon => {
self.runtime.shutdown(ShutdownReason::Restart);
ResponseResult::Ok("Daemon restart requested".into())
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Daemon restart requested".into(),
})
}
LocalRequest::StopDaemon => {
self.runtime.shutdown(ShutdownReason::Stop);
ResponseResult::Ok("Daemon shutdown requested".into())
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Daemon shutdown requested".into(),
})
}
LocalRequest::GetConfig => {
let cfg = config_util::CONFIG.load();
let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default();
ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml }))
}
LocalRequest::SetConfig { key, value } => {
match config_util::modify_config_value(&key, &value) {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Set {key} = {value}"),
}),
Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest),
}
}
LocalRequest::ReloadConfig => {
config_util::load_config();
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Configuration reloaded".into(),
})
}
LocalRequest::GetOmikronStatus => {
let connected = self.services.omikron.is_connected().await;
let iota_id = config_util::CONFIG.load().iota_id;
ResponseResult::Ok(ResponsePayload::OmikronStatus(
OmikronStatusResponse {
connected,
iota_id,
},
))
}
LocalRequest::ListComponents => {
let snapshot = self.runtime.snapshot();
let components: Vec<ComponentStatusResponse> = snapshot
.components
.into_iter()
.map(|(id, health)| ComponentStatusResponse {
id,
status: health.status,
message: health.message,
})
.collect();
ResponseResult::Ok(ResponsePayload::Components(components))
}
LocalRequest::GetUser { user_id } => {
match user_manager::get_user(user_id) {
Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(
UserDetailResponse {
user_id: user.user_id,
username: user.username,
display_name: user.display_name,
created_at: user.created_at,
trusted_apps: user.trusted_apps.keys().cloned().collect(),
},
)),
None => ResponseResult::Error(IpcErrorCode::NotFound),
}
}
LocalRequest::ImportUser { username } => {
match user_manager::load_from_tu(&username).await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Imported user {username}"),
}),
Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure),
}
}
LocalRequest::GetLogs { limit } => {
let entries = if let Ok(buf) = self.log_buffer.lock() {
buf.recent(limit)
} else {
Vec::new()
};
ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries }))
}
LocalRequest::CheckUpdate => {
match iota_updater::check_update().await {
Ok(available) => ResponseResult::Ok(ResponsePayload::UpdateStatus(
UpdateStatusResponse { available },
)),
Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure),
}
}
LocalRequest::ListCommunities => {
let iota_id = config_util::CONFIG.load().iota_id.map(|id| id as i64).unwrap_or(0);
let stored = iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id);
let summaries: Vec<CommunitySummary> = stored
.into_iter()
.map(|c| CommunitySummary {
name: c.address,
title: c.title,
})
.collect();
ResponseResult::Ok(ResponsePayload::Communities(summaries))
}
}
}

View file

@ -1,3 +1,4 @@
use crate::log_buffer::LogBuffer;
use crate::deployment::from_environment;
use crate::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_ipc::{
@ -8,7 +9,7 @@ use iota_logger::log;
use std::io::Result;
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, watch};
@ -22,11 +23,25 @@ const CLIENT_CHANNEL_SIZE: usize = 256;
const MAX_HANDSHAKE_RETRIES: u32 = 1;
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Minimum metric subscription interval to prevent excessive update rates.
const MIN_METRIC_INTERVAL_MS: u64 = 100;
/// Maximum metric subscription interval.
const MAX_METRIC_INTERVAL_MS: u64 = 60_000;
/// Default metric interval if the client does not specify one.
const DEFAULT_METRIC_INTERVAL_MS: u64 = 500;
/// Per-client subscription state.
struct ClientSubscription {
log_classes: Vec<String>,
metric_interval_ms: u64,
}
pub struct IpcServer {
listener: UnixListener,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
_instance_lock: File,
@ -38,6 +53,7 @@ impl IpcServer {
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
) -> Result<Self> {
let path = path.into();
@ -90,6 +106,7 @@ impl IpcServer {
runtime,
services,
log_tx,
log_buffer,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: lock,
@ -101,6 +118,7 @@ impl IpcServer {
runtime,
services,
log_tx,
log_buffer,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: File::options().read(true).open("/dev/null")?,
@ -114,11 +132,12 @@ impl IpcServer {
let runtime = self.runtime.clone();
let services = self.services.clone();
let log_tx = self.log_tx.clone();
let log_buffer = self.log_buffer.clone();
let state_rx = self.state_rx.clone();
let instance_id = self.instance_id.clone();
tokio::spawn(async move {
if let Err(error) =
handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await
handle_client(stream, runtime, services, log_tx, log_buffer, state_rx, instance_id).await
{
eprintln!("IPC client error: {error}");
}
@ -252,6 +271,7 @@ async fn handle_client(
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
) -> Result<()> {
@ -337,12 +357,18 @@ async fn handle_client(
// --- Writer task: merge directed responses + shared log events ---
let mut log_rx = log_tx.subscribe();
let (sub_tx, mut sub_rx) = tokio::sync::watch::channel(ClientSubscription {
log_classes: Vec::new(),
metric_interval_ms: DEFAULT_METRIC_INTERVAL_MS,
});
let writer_task = {
let runtime = runtime.clone();
let session_cancellation = session_cancellation.clone();
tokio::spawn(async move {
let mut directed_rx = directed_rx;
let mut last_metric_sent = tokio::time::Instant::now();
loop {
let metric_interval = sub_rx.borrow().metric_interval_ms;
tokio::select! {
// Directed messages (responses to this client's requests)
msg = directed_rx.recv() => {
@ -360,9 +386,35 @@ async fn handle_client(
// Shared log events
result = log_rx.recv() => {
match result {
Ok(DaemonMessage::LogEntry(entry)) => {
// Filter by subscribed log classes
let log_classes = sub_rx.borrow().log_classes.clone();
if log_classes.is_empty()
|| log_classes.iter().any(|c| entry.sender == *c)
{
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::LogEntry(entry)).await {
eprintln!("IPC client writer stopped while sending log message: {error}");
session_cancellation.cancel();
break;
}
}
}
Ok(DaemonMessage::MetricSample(sample)) => {
// Rate-limit metric samples based on subscription interval
let now = tokio::time::Instant::now();
if now.duration_since(last_metric_sent) >= std::time::Duration::from_millis(metric_interval) {
last_metric_sent = now;
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::MetricSample(sample)).await {
eprintln!("IPC client writer stopped while sending metric sample: {error}");
session_cancellation.cancel();
break;
}
}
}
Ok(message) => {
// Forward other broadcast messages as-is
if let Err(error) = write_client_message(&mut writer, &message).await {
eprintln!("IPC client writer stopped while sending log message: {error}");
eprintln!("IPC client writer stopped while sending broadcast message: {error}");
session_cancellation.cancel();
break;
}
@ -389,13 +441,14 @@ async fn handle_client(
break;
}
}
_ = sub_rx.changed() => {}
}
}
})
};
// --- Reader loop ---
let router = CommandRouter::new(runtime.clone(), services);
let router = CommandRouter::new(runtime.clone(), services, log_buffer);
loop {
let message = tokio::select! {
_ = session_cancellation.cancelled() => break,
@ -442,7 +495,17 @@ async fn handle_client(
break;
}
}
Ok(ClientMessage::Subscribe { .. }) => {
Ok(ClientMessage::Subscribe {
log_classes,
metric_interval_ms,
}) => {
let interval = metric_interval_ms
.unwrap_or(DEFAULT_METRIC_INTERVAL_MS)
.clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS);
let _ = sub_tx.send(ClientSubscription {
log_classes,
metric_interval_ms: interval,
});
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(snapshot).await;
let _ = directed_tx.send(DaemonMessage::Subscribed).await;

View file

@ -3,6 +3,7 @@ pub mod daemon_state;
pub mod deployment;
pub mod ipc_server;
pub mod log_broadcaster;
pub mod log_buffer;
pub mod services;
pub mod task_registry;

View file

@ -1,21 +1,30 @@
use crate::log_buffer::LogBuffer;
use iota_ipc::{DaemonMessage, LogEntry};
use iota_logger::subscribe;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
/* The daemon adapts logger output to the wire protocol so the logger stays
* independent from both the socket implementation and TUI state. */
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>) {
pub fn spawn(
message_tx: broadcast::Sender<DaemonMessage>,
buffer: Arc<Mutex<LogBuffer>>,
) {
let Some(mut logs) = subscribe() else {
return;
};
tokio::spawn(async move {
while let Ok(entry) = logs.recv().await {
let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry {
let entry = LogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
}));
};
if let Ok(mut buf) = buffer.lock() {
buf.push(entry.clone());
}
let _ = message_tx.send(DaemonMessage::LogEntry(entry));
}
});
}

View file

@ -0,0 +1,36 @@
use iota_ipc::LogEntry;
use std::collections::VecDeque;
pub struct LogBuffer {
entries: VecDeque<LogEntry>,
capacity: usize,
}
impl LogBuffer {
pub fn new(capacity: usize) -> Self {
Self {
entries: VecDeque::with_capacity(capacity),
capacity,
}
}
pub fn push(&mut self, entry: LogEntry) {
if self.entries.len() == self.capacity {
self.entries.pop_front();
}
self.entries.push_back(entry);
}
pub fn recent(&self, limit: usize) -> Vec<LogEntry> {
let _len = self.entries.len();
self.entries
.iter()
.rev()
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
}

View file

@ -1,10 +1,11 @@
use async_trait::async_trait;
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_daemon_lib::log_buffer::LogBuffer;
use iota_ipc::{LocalRequest, ResponseResult};
use mtp::codec::CommunicationValue;
use omikron_connector::{OmikronClient, OmikronError};
use std::sync::{
Arc,
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
};
use std::time::Duration;
@ -43,7 +44,7 @@ async fn reconnect_uses_the_injected_client() {
users: Default::default(),
config: Default::default(),
});
let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services);
let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services, Arc::new(Mutex::new(LogBuffer::new(100))));
assert!(matches!(
router.route(1, LocalRequest::ReconnectOmikron).await.result,
ResponseResult::Ok(_)