[Wip] CLI & Daemon
This commit is contained in:
parent
3bc5cc959a
commit
6a535099bb
44 changed files with 4417 additions and 303 deletions
|
|
@ -1,11 +1,14 @@
|
|||
pub mod protocol;
|
||||
pub mod text_commands;
|
||||
pub mod transport;
|
||||
|
||||
pub use protocol::{
|
||||
ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode,
|
||||
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
|
||||
LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase,
|
||||
StateSnapshot, SupervisorKind,
|
||||
ClientMessage, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse,
|
||||
ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent,
|
||||
HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntry,
|
||||
LogEntriesResponse, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope,
|
||||
ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind,
|
||||
TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, CommunitySummary,
|
||||
};
|
||||
pub use transport::{read_msg, write_msg};
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,25 @@ pub enum LocalRequest {
|
|||
RestartDaemon,
|
||||
#[serde(skip)]
|
||||
StopDaemon,
|
||||
GetConfig,
|
||||
SetConfig {
|
||||
key: String,
|
||||
value: String,
|
||||
},
|
||||
ReloadConfig,
|
||||
GetOmikronStatus,
|
||||
ListComponents,
|
||||
GetUser {
|
||||
user_id: i64,
|
||||
},
|
||||
ImportUser {
|
||||
username: String,
|
||||
},
|
||||
GetLogs {
|
||||
limit: usize,
|
||||
},
|
||||
CheckUpdate,
|
||||
ListCommunities,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
|
|
@ -107,10 +126,102 @@ pub struct ResponseEnvelope {
|
|||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
pub enum ResponseResult {
|
||||
Ok(String),
|
||||
Ok(ResponsePayload),
|
||||
Error(IpcErrorCode),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
pub enum ResponsePayload {
|
||||
Status(StatusResponse),
|
||||
Tasks(Vec<TaskSummary>),
|
||||
Users(Vec<UserSummary>),
|
||||
UserCreated {
|
||||
user_id: i64,
|
||||
username: String,
|
||||
},
|
||||
UserRemoved {
|
||||
user_id: i64,
|
||||
},
|
||||
Acknowledged {
|
||||
message: String,
|
||||
},
|
||||
DaemonStatus(DaemonStatusResponse),
|
||||
Config(ConfigResponse),
|
||||
OmikronStatus(OmikronStatusResponse),
|
||||
Components(Vec<ComponentStatusResponse>),
|
||||
UserDetail(UserDetailResponse),
|
||||
LogEntries(LogEntriesResponse),
|
||||
UpdateStatus(UpdateStatusResponse),
|
||||
Communities(Vec<CommunitySummary>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ConfigResponse {
|
||||
pub yaml: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct OmikronStatusResponse {
|
||||
pub connected: bool,
|
||||
pub iota_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct ComponentStatusResponse {
|
||||
pub id: ComponentId,
|
||||
pub status: HealthStatus,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct UserDetailResponse {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub trusted_apps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct LogEntriesResponse {
|
||||
pub entries: Vec<LogEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct UpdateStatusResponse {
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct CommunitySummary {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct StatusResponse {
|
||||
pub phase: String,
|
||||
pub tasks: Vec<String>,
|
||||
pub degraded_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct TaskSummary {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct UserSummary {
|
||||
pub user_id: i64,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DaemonStatusResponse {
|
||||
pub formatted: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IpcErrorCode {
|
||||
|
|
@ -128,6 +239,36 @@ pub enum IpcErrorCode {
|
|||
InternalFailure,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IpcErrorCode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InvalidRequest => "the daemon rejected the request",
|
||||
Self::NotFound => "the requested resource was not found",
|
||||
Self::Conflict => "the request conflicts with current daemon state",
|
||||
Self::StorageFailure => "the daemon could not access local storage",
|
||||
Self::OmikronUnavailable => "Omikron is unavailable",
|
||||
Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible",
|
||||
Self::NotReady => "the daemon is not ready yet",
|
||||
Self::Disconnected => "the daemon connection was lost",
|
||||
Self::Timeout => "the daemon did not respond in time",
|
||||
Self::Cancelled => "the daemon cancelled the request",
|
||||
Self::Unauthorized => "the daemon denied this operation",
|
||||
Self::InternalFailure => "the daemon encountered an internal failure",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod error_tests {
|
||||
use super::IpcErrorCode;
|
||||
|
||||
#[test]
|
||||
fn error_codes_have_operator_facing_messages() {
|
||||
assert_eq!(IpcErrorCode::NotReady.to_string(), "the daemon is not ready yet");
|
||||
assert!(!IpcErrorCode::InternalFailure.to_string().contains("InternalFailure"));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
pub enum LifecycleEvent {
|
||||
|
|
|
|||
324
iota-ipc/src/text_commands.rs
Normal file
324
iota-ipc/src/text_commands.rs
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
use crate::LocalRequest;
|
||||
|
||||
pub const COMMANDS: &[&str] = &[
|
||||
"status",
|
||||
"tasks",
|
||||
"users list",
|
||||
"users show ",
|
||||
"users add ",
|
||||
"users remove ",
|
||||
"users import ",
|
||||
"omikron status",
|
||||
"reconnect",
|
||||
"identity rotate",
|
||||
"daemon status",
|
||||
"config get",
|
||||
"config set ",
|
||||
"config reload",
|
||||
"components",
|
||||
"logs",
|
||||
"update check",
|
||||
"community list",
|
||||
"restart",
|
||||
"stop",
|
||||
];
|
||||
|
||||
pub fn completions(prefix: &str) -> Vec<&'static str> {
|
||||
let normalized = prefix.trim_start_matches('/');
|
||||
COMMANDS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|command| command.starts_with(normalized))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn validation_error(line: &str) -> Option<String> {
|
||||
let normalized = line.trim_start_matches('/').trim();
|
||||
if normalized == "help" || parse(normalized).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(format!("Unknown command `{normalized}`. Use /help or Tab completion."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a text command string into a typed IPC request.
|
||||
///
|
||||
/// Both the CLI console and the TUI command palette use this single parser.
|
||||
/// Commands are case-insensitive and support an optional leading `/`.
|
||||
pub fn parse(line: &str) -> Option<LocalRequest> {
|
||||
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
|
||||
match parts.as_slice() {
|
||||
["help"] => None,
|
||||
["status"] => Some(LocalRequest::GetStatus),
|
||||
["tasks"] => Some(LocalRequest::ListTasks),
|
||||
["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers),
|
||||
["user" | "users", "show", id_str] => {
|
||||
let user_id = id_str.parse::<i64>().ok()?;
|
||||
Some(LocalRequest::GetUser { user_id })
|
||||
}
|
||||
["user" | "users", "add", username] => Some(LocalRequest::CreateUser {
|
||||
username: username.to_string(),
|
||||
}),
|
||||
["user" | "users", "remove", id_str] => {
|
||||
let user_id = id_str.parse::<i64>().ok()?;
|
||||
Some(LocalRequest::RemoveUser { user_id })
|
||||
}
|
||||
["user" | "users", "import", username] => Some(LocalRequest::ImportUser {
|
||||
username: username.to_string(),
|
||||
}),
|
||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
||||
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
|
||||
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
||||
intent: crate::ExitIntent::Restart,
|
||||
}),
|
||||
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
||||
intent: crate::ExitIntent::Stop,
|
||||
}),
|
||||
["daemon", "status"] => Some(LocalRequest::GetDaemonStatus),
|
||||
["config", "get"] => Some(LocalRequest::GetConfig),
|
||||
["config", "set", key, value] => Some(LocalRequest::SetConfig {
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
}),
|
||||
["config", "reload"] => Some(LocalRequest::ReloadConfig),
|
||||
["omikron", "status"] => Some(LocalRequest::GetOmikronStatus),
|
||||
["components"] => Some(LocalRequest::ListComponents),
|
||||
["logs"] => Some(LocalRequest::GetLogs { limit: 100 }),
|
||||
["update", "check"] => Some(LocalRequest::CheckUpdate),
|
||||
["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_status() {
|
||||
assert!(matches!(parse("status"), Some(LocalRequest::GetStatus)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_tasks() {
|
||||
assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_list_shortcuts() {
|
||||
assert!(matches!(parse("users"), Some(LocalRequest::ListUsers)));
|
||||
assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_add() {
|
||||
let req = parse("user add alice").unwrap();
|
||||
match req {
|
||||
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
|
||||
_ => panic!("expected CreateUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_headless_cli_user_vocabulary() {
|
||||
assert!(matches!(
|
||||
parse("users list"),
|
||||
Some(LocalRequest::ListUsers)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("users add alice"),
|
||||
Some(LocalRequest::CreateUser { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("users remove 42"),
|
||||
Some(LocalRequest::RemoveUser { user_id: 42 })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("identity rotate"),
|
||||
Some(LocalRequest::RotateIotaIdentity)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_user_remove_by_id() {
|
||||
let req = parse("user remove 42").unwrap();
|
||||
match req {
|
||||
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42),
|
||||
_ => panic!("expected RemoveUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_remove_requires_numeric_id() {
|
||||
assert!(parse("user remove alice").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_reconnect() {
|
||||
assert!(matches!(
|
||||
parse("reconnect"),
|
||||
Some(LocalRequest::ReconnectOmikron)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_regenerate_keys() {
|
||||
assert!(matches!(
|
||||
parse("regenerate keys"),
|
||||
Some(LocalRequest::RotateIotaIdentity)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_restart_aliases() {
|
||||
assert!(matches!(
|
||||
parse("restart"),
|
||||
Some(LocalRequest::RequestProcessExit { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("reload"),
|
||||
Some(LocalRequest::RequestProcessExit { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_stop_aliases() {
|
||||
assert!(matches!(
|
||||
parse("stop"),
|
||||
Some(LocalRequest::RequestProcessExit { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
parse("shutdown"),
|
||||
Some(LocalRequest::RequestProcessExit { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_daemon_status() {
|
||||
assert!(matches!(
|
||||
parse("daemon status"),
|
||||
Some(LocalRequest::GetDaemonStatus)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_config_get() {
|
||||
assert!(matches!(
|
||||
parse("config get"),
|
||||
Some(LocalRequest::GetConfig)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_config_reload() {
|
||||
assert!(matches!(
|
||||
parse("config reload"),
|
||||
Some(LocalRequest::ReloadConfig)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_omikron_status() {
|
||||
assert!(matches!(
|
||||
parse("omikron status"),
|
||||
Some(LocalRequest::GetOmikronStatus)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_components() {
|
||||
assert!(matches!(
|
||||
parse("components"),
|
||||
Some(LocalRequest::ListComponents)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_users_show() {
|
||||
let req = parse("users show 42").unwrap();
|
||||
match req {
|
||||
LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42),
|
||||
_ => panic!("expected GetUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_show_requires_numeric_id() {
|
||||
assert!(parse("users show alice").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_config_set() {
|
||||
let req = parse("config set port 8080").unwrap();
|
||||
match req {
|
||||
LocalRequest::SetConfig { key, value } => {
|
||||
assert_eq!(key, "port");
|
||||
assert_eq!(value, "8080");
|
||||
}
|
||||
_ => panic!("expected SetConfig"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_logs() {
|
||||
assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_check() {
|
||||
assert!(matches!(
|
||||
parse("update check"),
|
||||
Some(LocalRequest::CheckUpdate)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_community_list() {
|
||||
assert!(matches!(
|
||||
parse("community list"),
|
||||
Some(LocalRequest::ListCommunities)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_communities_alias() {
|
||||
assert!(matches!(
|
||||
parse("communities"),
|
||||
Some(LocalRequest::ListCommunities)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_users_import() {
|
||||
let req = parse("users import alice").unwrap();
|
||||
match req {
|
||||
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
|
||||
_ => panic!("expected ImportUser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_with_slash_prefix() {
|
||||
assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus)));
|
||||
assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_returns_none() {
|
||||
assert!(parse("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_is_prefix_based_and_deterministic() {
|
||||
assert_eq!(completions("identity r"), vec!["identity rotate"]);
|
||||
assert_eq!(completions("/users a"), vec!["users add "]);
|
||||
assert!(completions("definitely-unknown").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_distinguishes_help_and_unknown_commands() {
|
||||
assert_eq!(validation_error("/help"), None);
|
||||
assert!(validation_error("status").is_none());
|
||||
assert!(validation_error("statuz").unwrap().contains("Unknown command"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue