Split src/ into an api/cli/engine/config module hierarchy
- src/api/: HTTP router + DTOs + the MCP server (the HTTP/MCP-facing surface): mod.rs (formerly api.rs), dto.rs, mcp_server.rs. - src/cli/: argument parsing and process entry (formerly inline in main.rs) plus session registry: mod.rs, sessions.rs. main.rs is now a thin entry point that just declares modules and calls cli::run(). - src/engine/: the actor + everything it depends on to parse/index/ query log lines: mod.rs (formerly engine.rs), alerts.rs, promql.rs, rate.rs, ingest.rs, logfmt.rs, filter_parser.rs/.pest, filter_datetime.rs, index_key.rs, data_value.rs, metric_primitive.rs, timestamp.rs. - src/config/: persisted configuration: mod.rs (formerly settings.rs), profile.rs. - src/consts.rs stays at the crate root, used from every module above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A1ZJ3SrayzLzVndhVq9BmS
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
311713986a
commit
a810ecf396
+205
@@ -0,0 +1,205 @@
|
||||
pub mod sessions;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::config::Settings;
|
||||
|
||||
static SETTINGS_PATH_HELP: LazyLock<String> = LazyLock::new(|| {
|
||||
format!(
|
||||
"Path to the settings file. Defaults to {}.",
|
||||
crate::config::default_settings_path()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "a per-OS config location".to_string())
|
||||
)
|
||||
});
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
#[arg(long, default_value_t = 0)]
|
||||
port: u16,
|
||||
|
||||
#[arg(long, help = SETTINGS_PATH_HELP.as_str())]
|
||||
settings_path: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
file: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
metrics_url: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
Mcp {
|
||||
#[arg(long)]
|
||||
session: Option<String>,
|
||||
#[arg(long)]
|
||||
port: Option<u16>,
|
||||
},
|
||||
Sessions,
|
||||
}
|
||||
|
||||
pub async fn run() {
|
||||
let args = Args::parse();
|
||||
|
||||
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
|
||||
crate::config::default_settings_path()
|
||||
.expect("could not determine a default settings path; pass --settings-path")
|
||||
});
|
||||
|
||||
match args.command {
|
||||
Some(Command::Mcp { session, port }) => {
|
||||
run_mcp(&settings_path, session, port).await;
|
||||
return;
|
||||
}
|
||||
Some(Command::Sessions) => {
|
||||
print_sessions(&settings_path);
|
||||
return;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
|
||||
|
||||
let engine_tx = crate::engine::spawn(settings.clone(), crate::consts::ENGINE_CHANNEL_CAPACITY);
|
||||
{
|
||||
let max_logs = settings.lock().unwrap().max_logs;
|
||||
let _ = engine_tx.tx.send(crate::engine::EngineMessage::SetMaxLogs(max_logs)).await;
|
||||
}
|
||||
|
||||
{
|
||||
let (active_profile_name, active_profile) = {
|
||||
let mut settings_guard = settings.lock().unwrap();
|
||||
settings_guard
|
||||
.ensure_default_profile(&settings_path)
|
||||
.expect("failed to ensure the Default profile exists");
|
||||
let active_profile_name = settings_guard.active_profile.clone();
|
||||
let active_profile = active_profile_name
|
||||
.as_deref()
|
||||
.and_then(|name| settings_guard.profile(name).cloned());
|
||||
(active_profile_name, active_profile)
|
||||
};
|
||||
match (active_profile_name, active_profile) {
|
||||
(Some(_), Some(active_profile)) => {
|
||||
active_profile.load_into_engine(&engine_tx).await;
|
||||
}
|
||||
(Some(profile_name), None) => {
|
||||
eprintln!(
|
||||
"warning: active profile '{profile_name}' not found in {}; skipping",
|
||||
settings_path.display()
|
||||
);
|
||||
}
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
match args.file.clone() {
|
||||
Some(path) => {
|
||||
tokio::spawn(crate::engine::ingest::run_file(engine_tx.clone(), path));
|
||||
}
|
||||
None => {
|
||||
tokio::spawn(crate::engine::ingest::run(engine_tx.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(metrics_url) = args.metrics_url.clone() {
|
||||
tokio::spawn(crate::engine::ingest::run_metrics_poll(engine_tx.clone(), metrics_url));
|
||||
}
|
||||
|
||||
let app = crate::api::router(engine_tx, settings, settings_path.clone());
|
||||
let listener = TcpListener::bind((crate::consts::DEFAULT_BIND_HOST, args.port))
|
||||
.await
|
||||
.expect("failed to bind listener");
|
||||
let bound_port = listener
|
||||
.local_addr()
|
||||
.expect("failed to read back bound address")
|
||||
.port();
|
||||
|
||||
let source = match &args.file {
|
||||
Some(path) => format!("tailing {}", path.display()),
|
||||
None => "reading logs from stdin".to_string(),
|
||||
};
|
||||
let session_name = args.name.clone().unwrap_or_else(sessions::generate_name);
|
||||
match sessions::register_session(&settings_path, &session_name, bound_port) {
|
||||
Ok(_) => eprintln!(
|
||||
"ottyl serving on http://localhost:{} ({}), session name: {}",
|
||||
bound_port, source, session_name
|
||||
),
|
||||
Err(err) => eprintln!(
|
||||
"ottyl serving on http://localhost:{} ({}); warning: failed to register session '{}': {}",
|
||||
bound_port, source, session_name, err
|
||||
),
|
||||
}
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
|
||||
async fn run_mcp(settings_path: &std::path::Path, session: Option<String>, port: Option<u16>) {
|
||||
use rmcp::ServiceExt;
|
||||
|
||||
let port = match (port, session) {
|
||||
(Some(port), _) => port,
|
||||
(None, Some(name)) => match sessions::resolve_session(settings_path, &name) {
|
||||
Some(info) => info.port,
|
||||
None => {
|
||||
eprintln!("error: no running ottyl session named '{name}'");
|
||||
std::process::exit(1);
|
||||
}
|
||||
},
|
||||
(None, None) => {
|
||||
eprintln!("error: `ottyl mcp` requires --session <name> or --port <port>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let base_url = format!("http://localhost:{port}");
|
||||
let service = crate::api::mcp_server::OttylMcp::new(base_url)
|
||||
.serve(rmcp::transport::stdio())
|
||||
.await
|
||||
.expect("failed to start MCP server");
|
||||
service.waiting().await.expect("MCP server error");
|
||||
}
|
||||
|
||||
fn print_sessions(settings_path: &std::path::Path) {
|
||||
let dir = sessions::sessions_dir(settings_path);
|
||||
let sessions = sessions::list_sessions(&dir);
|
||||
if sessions.is_empty() {
|
||||
println!("no known ottyl sessions");
|
||||
return;
|
||||
}
|
||||
for session in sessions {
|
||||
println!("{}\tport={}\tpid={}", session.name, session.port, session.pid);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn settings_path_defaults_to_none() {
|
||||
let args = Args::parse_from(["ottyl"]);
|
||||
assert_eq!(args.settings_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_path_flag_overrides_default() {
|
||||
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
|
||||
assert_eq!(
|
||||
args.settings_path,
|
||||
Some(PathBuf::from("/tmp/custom-settings.json"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub name: String,
|
||||
pub port: u16,
|
||||
pub pid: u32,
|
||||
}
|
||||
|
||||
pub fn sessions_dir(settings_path: &Path) -> PathBuf {
|
||||
settings_path
|
||||
.parent()
|
||||
.map(|parent| parent.join("sessions"))
|
||||
.unwrap_or_else(|| PathBuf::from("sessions"))
|
||||
}
|
||||
|
||||
fn session_file_path(dir: &Path, name: &str) -> PathBuf {
|
||||
dir.join(format!("{name}.json"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_pid_alive(pid: u32) -> bool {
|
||||
process::Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.stdout(process::Stdio::null())
|
||||
.stderr(process::Stdio::null())
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn is_pid_alive(_pid: u32) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn cleanup_stale(dir: &Path) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(contents) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(info) = serde_json::from_str::<SessionInfo>(&contents) else {
|
||||
continue;
|
||||
};
|
||||
if !is_pid_alive(info.pid) {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_name() -> String {
|
||||
petname::petname(2, "-").unwrap_or_else(|| format!("session-{}", process::id()))
|
||||
}
|
||||
|
||||
pub fn register_session(settings_path: &Path, name: &str, port: u16) -> io::Result<PathBuf> {
|
||||
let dir = sessions_dir(settings_path);
|
||||
fs::create_dir_all(&dir)?;
|
||||
cleanup_stale(&dir);
|
||||
let info = SessionInfo {
|
||||
name: name.to_string(),
|
||||
port,
|
||||
pid: process::id(),
|
||||
};
|
||||
let path = session_file_path(&dir, name);
|
||||
fs::write(&path, serde_json::to_string_pretty(&info)?)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn resolve_session(settings_path: &Path, name: &str) -> Option<SessionInfo> {
|
||||
let dir = sessions_dir(settings_path);
|
||||
let contents = fs::read_to_string(session_file_path(&dir, name)).ok()?;
|
||||
let info: SessionInfo = serde_json::from_str(&contents).ok()?;
|
||||
is_pid_alive(info.pid).then_some(info)
|
||||
}
|
||||
|
||||
pub fn list_sessions(dir: &Path) -> Vec<SessionInfo> {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut sessions: Vec<SessionInfo> = entries
|
||||
.flatten()
|
||||
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("json"))
|
||||
.filter_map(|entry| fs::read_to_string(entry.path()).ok())
|
||||
.filter_map(|contents| serde_json::from_str(&contents).ok())
|
||||
.collect();
|
||||
sessions.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
sessions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn register_session_writes_correct_port() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
|
||||
let path = register_session(&settings_path, "foo", 4242).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
let info = resolve_session(&settings_path, "foo").unwrap();
|
||||
assert_eq!(info.port, 4242);
|
||||
assert_eq!(info.name, "foo");
|
||||
assert_eq!(info.pid, process::id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_session_returns_none_for_unknown_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
assert!(resolve_session(&settings_path, "does-not-exist").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_session_does_not_block_reregistration_under_same_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
let sessions_dir_path = sessions_dir(&settings_path);
|
||||
fs::create_dir_all(&sessions_dir_path).unwrap();
|
||||
|
||||
let stale = SessionInfo { name: "foo".to_string(), port: 1111, pid: 999_999 };
|
||||
fs::write(
|
||||
session_file_path(&sessions_dir_path, "foo"),
|
||||
serde_json::to_string(&stale).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
register_session(&settings_path, "foo", 5555).unwrap();
|
||||
let info = resolve_session(&settings_path, "foo").unwrap();
|
||||
assert_eq!(info.port, 5555);
|
||||
assert_eq!(info.pid, process::id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_sessions_returns_all_entries_sorted_by_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings_path = dir.path().join("settings.json");
|
||||
register_session(&settings_path, "zeta", 1).unwrap();
|
||||
register_session(&settings_path, "alpha", 2).unwrap();
|
||||
|
||||
let sessions = list_sessions(&sessions_dir(&settings_path));
|
||||
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["alpha", "zeta"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user