Enhance daemon functionality with instance support and CLI improvements

This commit introduces support for multiple daemon instances, allowing users to specify instance names for better management. The `DaemonConfig` struct is updated to include an optional `instance_name`, and new methods are added for creating and managing instances. The CLI is enhanced with commands to list, stop, and check the status of specific daemon instances. Additionally, the command handling logic is updated to accommodate instance-specific operations, improving user experience and flexibility in managing daemon processes. Documentation is updated to reflect these changes.
This commit is contained in:
Jamie Pine
2025-06-21 17:01:55 -07:00
parent 076c65851a
commit 2cdc3d0403
3 changed files with 459 additions and 51 deletions

View File

@@ -0,0 +1,162 @@
# Multi-Instance Daemon Support
Spacedrive CLI now supports running multiple daemon instances simultaneously, enabling local testing of device pairing and other multi-device features.
## Overview
Multiple daemon instances allow you to:
- Test device pairing locally by running two instances
- Simulate multi-device scenarios on a single machine
- Isolate different development/testing environments
- Run production and development daemons side-by-side
## Usage
### Starting Multiple Instances
```bash
# Start default instance
spacedrive start
# Start named instances
spacedrive start --instance alice
spacedrive start --instance bob
# Start with networking enabled
spacedrive start --instance alice --enable-networking
spacedrive start --instance bob --enable-networking
```
### Targeting Specific Instances
Use the `--instance` flag to target commands to specific daemon instances:
```bash
# Default instance
spacedrive library list
# Named instances
spacedrive --instance alice library list
spacedrive --instance bob library create "Bob's Library"
```
### Instance Management
```bash
# List all daemon instances
spacedrive instance list
# Stop specific instance
spacedrive instance stop alice
spacedrive --instance bob stop # Alternative syntax
# Check status of specific instance
spacedrive --instance alice daemon
```
### Device Pairing Example
Test device pairing locally using two instances:
```bash
# Terminal 1: Start Alice's daemon
spacedrive start --instance alice --enable-networking --foreground
# Terminal 2: Start Bob's daemon
spacedrive start --instance bob --enable-networking --foreground
# Terminal 3: Alice generates pairing code
spacedrive --instance alice network init --password "test123"
spacedrive --instance alice network pair generate --auto-accept
# Terminal 4: Bob joins using Alice's code
spacedrive --instance bob network init --password "test123"
spacedrive --instance bob network pair join "word1 word2 word3 ... word12"
```
## Architecture
### Instance Isolation
Each instance has completely isolated:
- **Socket paths**: `spacedrive.sock`, `spacedrive-alice.sock`, `spacedrive-bob.sock`
- **PID files**: `spacedrive.pid`, `spacedrive-alice.pid`, `spacedrive-bob.pid`
- **Data directories**: `data/spacedrive-cli-data/`, `data/spacedrive-cli-data/instance-alice/`
- **CLI state**: Separate `cli_state.json` per instance
### File Structure
```
$runtime_dir/ # /tmp or $XDG_RUNTIME_DIR
├── spacedrive.sock # Default instance socket
├── spacedrive.pid # Default instance PID
├── spacedrive-alice.sock # Alice instance socket
├── spacedrive-alice.pid # Alice instance PID
├── spacedrive-bob.sock # Bob instance socket
└── spacedrive-bob.pid # Bob instance PID
data/spacedrive-cli-data/ # Default instance data
├── spacedrive.json
├── libraries/
└── cli_state.json
data/spacedrive-cli-data/instance-alice/ # Alice instance data
├── spacedrive.json
├── libraries/
└── cli_state.json
data/spacedrive-cli-data/instance-bob/ # Bob instance data
├── spacedrive.json
├── libraries/
└── cli_state.json
```
## Development Workflow
### Testing Pairing Protocol
```bash
# Start two instances for pairing test
spacedrive start --instance initiator --enable-networking --foreground &
spacedrive start --instance joiner --enable-networking --foreground &
# Initialize networking
spacedrive --instance initiator network init --password "dev123"
spacedrive --instance joiner network init --password "dev123"
# Test pairing
CODE=$(spacedrive --instance initiator network pair generate --auto-accept | grep "Pairing code:" | cut -d' ' -f3-)
spacedrive --instance joiner network pair join "$CODE"
# Verify connection
spacedrive --instance initiator network devices
spacedrive --instance joiner network devices
```
### Instance Cleanup
```bash
# Stop all instances
spacedrive instance list
spacedrive instance stop alice
spacedrive instance stop bob
spacedrive stop # Default instance
# Clean up sockets (if needed)
rm /tmp/spacedrive*.sock /tmp/spacedrive*.pid
```
## Backwards Compatibility
The implementation maintains full backwards compatibility:
- All existing commands work unchanged with the default instance
- No breaking changes to CLI interface
- Default instance behavior is identical to single-instance mode
## Implementation Notes
- Instance names must be valid filenames (no special characters)
- Socket discovery happens automatically via filesystem scanning
- Daemon startup checks for instance conflicts
- Each instance runs independently with separate process trees

View File

@@ -18,20 +18,48 @@ pub struct DaemonConfig {
pub socket_path: PathBuf,
pub pid_file: PathBuf,
pub log_file: Option<PathBuf>,
pub instance_name: Option<String>,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self::new(None)
}
}
impl DaemonConfig {
/// Create a new daemon config with optional instance name
pub fn new(instance_name: Option<String>) -> Self {
let runtime_dir = dirs::runtime_dir()
.or_else(|| dirs::cache_dir())
.unwrap_or_else(|| PathBuf::from("/tmp"));
let (socket_name, pid_name, log_name) = if let Some(ref name) = instance_name {
(
format!("spacedrive-{}.sock", name),
format!("spacedrive-{}.pid", name),
format!("spacedrive-{}.log", name)
)
} else {
(
"spacedrive.sock".to_string(),
"spacedrive.pid".to_string(),
"spacedrive.log".to_string()
)
};
Self {
socket_path: runtime_dir.join("spacedrive.sock"),
pid_file: runtime_dir.join("spacedrive.pid"),
log_file: Some(runtime_dir.join("spacedrive.log")),
socket_path: runtime_dir.join(socket_name),
pid_file: runtime_dir.join(pid_name),
log_file: Some(runtime_dir.join(log_name)),
instance_name,
}
}
/// Get instance display name ("default" for None, or the actual name)
pub fn instance_display_name(&self) -> &str {
self.instance_name.as_deref().unwrap_or("default")
}
}
/// Commands that can be sent to the daemon
@@ -179,6 +207,14 @@ pub struct Daemon {
impl Daemon {
/// Create a new daemon instance
pub async fn new(data_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
Self::new_with_instance(data_dir, None).await
}
/// Create a new daemon instance with optional instance name
pub async fn new_with_instance(
data_dir: PathBuf,
instance_name: Option<String>,
) -> Result<Self, Box<dyn std::error::Error>> {
let core = Arc::new(Core::new_with_config(data_dir).await?);
// Ensure device is registered for all libraries
@@ -231,7 +267,7 @@ impl Daemon {
Ok(Self {
core,
config: DaemonConfig::default(),
config: DaemonConfig::new(instance_name.clone()),
start_time: std::time::Instant::now(),
shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
})
@@ -241,6 +277,15 @@ impl Daemon {
pub async fn new_with_networking(
data_dir: PathBuf,
networking_password: &str
) -> Result<Self, Box<dyn std::error::Error>> {
Self::new_with_networking_and_instance(data_dir, networking_password, None).await
}
/// Create a new daemon instance with networking enabled and optional instance name
pub async fn new_with_networking_and_instance(
data_dir: PathBuf,
networking_password: &str,
instance_name: Option<String>,
) -> Result<Self, Box<dyn std::error::Error>> {
let mut core = Core::new_with_config(data_dir).await?;
@@ -300,7 +345,7 @@ impl Daemon {
Ok(Self {
core,
config: DaemonConfig::default(),
config: DaemonConfig::new(instance_name.clone()),
start_time: std::time::Instant::now(),
shutdown_tx: Arc::new(tokio::sync::Mutex::new(None)),
})
@@ -353,7 +398,12 @@ impl Daemon {
/// Check if daemon is running
pub fn is_running() -> bool {
let config = DaemonConfig::default();
Self::is_running_instance(None)
}
/// Check if daemon instance is running
pub fn is_running_instance(instance_name: Option<String>) -> bool {
let config = DaemonConfig::new(instance_name);
if let Ok(pid_str) = std::fs::read_to_string(&config.pid_file) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
@@ -377,11 +427,16 @@ impl Daemon {
/// Stop a running daemon
pub async fn stop() -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::default();
Self::stop_instance(None).await
}
/// Stop a running daemon instance
pub async fn stop_instance(instance_name: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
let config = DaemonConfig::new(instance_name.clone());
// First check if daemon is actually running
if !Self::is_running() {
return Err("Daemon is not running".into());
if !Self::is_running_instance(instance_name) {
return Err(format!("Daemon instance '{}' is not running", config.instance_display_name()).into());
}
// Try to connect and send shutdown command
@@ -417,6 +472,69 @@ impl Daemon {
Ok(())
}
/// List all daemon instances
pub fn list_instances() -> Result<Vec<DaemonInstance>, Box<dyn std::error::Error>> {
let runtime_dir = dirs::runtime_dir()
.or_else(|| dirs::cache_dir())
.unwrap_or_else(|| PathBuf::from("/tmp"));
let mut instances = Vec::new();
// Find all spacedrive-*.sock files
if let Ok(entries) = std::fs::read_dir(&runtime_dir) {
for entry in entries.flatten() {
let file_name = entry.file_name();
let file_str = file_name.to_string_lossy();
if file_str.starts_with("spacedrive") && file_str.ends_with(".sock") {
let instance_name = if file_str == "spacedrive.sock" {
None // Default instance
} else {
// Extract instance name from spacedrive-{name}.sock
Some(file_str.strip_prefix("spacedrive-")
.and_then(|s| s.strip_suffix(".sock"))
.unwrap_or("unknown")
.to_string())
};
let is_running = Self::is_running_instance(instance_name.clone());
instances.push(DaemonInstance {
name: instance_name,
socket_path: entry.path(),
is_running,
});
}
}
}
// Sort by name for consistent output
instances.sort_by(|a, b| {
match (&a.name, &b.name) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Less, // Default first
(Some(_), None) => std::cmp::Ordering::Greater,
(Some(a), Some(b)) => a.cmp(b),
}
});
Ok(instances)
}
}
/// Daemon instance information
#[derive(Debug)]
pub struct DaemonInstance {
pub name: Option<String>, // None for default instance
pub socket_path: PathBuf,
pub is_running: bool,
}
impl DaemonInstance {
/// Get instance display name (\"default\" for None, or the actual name)
pub fn display_name(&self) -> &str {
self.name.as_deref().unwrap_or("default")
}
}
/// Handle a client connection
@@ -938,12 +1056,19 @@ async fn handle_command(
/// Client for communicating with the daemon
pub struct DaemonClient {
socket_path: PathBuf,
instance_name: Option<String>,
}
impl DaemonClient {
pub fn new() -> Self {
Self::new_with_instance(None)
}
pub fn new_with_instance(instance_name: Option<String>) -> Self {
let config = DaemonConfig::new(instance_name.clone());
Self {
socket_path: DaemonConfig::default().socket_path,
socket_path: config.socket_path,
instance_name,
}
}
@@ -969,6 +1094,6 @@ impl DaemonClient {
/// Check if daemon is running
pub fn is_running(&self) -> bool {
Daemon::is_running()
Daemon::is_running_instance(self.instance_name.clone())
}
}

View File

@@ -21,6 +21,10 @@ pub struct Cli {
#[arg(short = 'v', long, global = true)]
pub verbose: bool,
/// Daemon instance name (for multiple daemon support)
#[arg(long, global = true)]
pub instance: Option<String>,
#[command(subcommand)]
pub command: Commands,
}
@@ -79,11 +83,28 @@ pub enum Commands {
/// Check if the daemon is running
Daemon,
/// Manage daemon instances
#[command(subcommand)]
Instance(InstanceCommands),
/// Manage device networking and connections
#[command(subcommand)]
Network(commands::NetworkCommands),
}
#[derive(Subcommand, Clone)]
pub enum InstanceCommands {
/// List all daemon instances
List,
/// Stop a specific daemon instance
Stop {
/// Instance name to stop
name: String
},
/// Show currently targeted instance
Current,
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
@@ -96,10 +117,16 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
))
.init();
// Determine data directory
let data_dir = cli
// Determine data directory with instance isolation
let base_data_dir = cli
.data_dir
.unwrap_or_else(|| PathBuf::from("./data/spacedrive-cli-data"));
let data_dir = if let Some(ref instance) = cli.instance {
base_data_dir.join(format!("instance-{}", instance))
} else {
base_data_dir
};
// Handle daemon commands first (they don't need Core)
match &cli.command {
@@ -107,19 +134,27 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
foreground,
enable_networking,
} => {
return handle_start_daemon(data_dir, *foreground, *enable_networking).await;
return handle_start_daemon(data_dir, *foreground, *enable_networking, cli.instance.clone()).await;
}
Commands::Stop => {
return handle_stop_daemon().await;
return handle_stop_daemon(cli.instance.clone()).await;
}
Commands::Daemon => {
return handle_daemon_status().await;
return handle_daemon_status(cli.instance.clone()).await;
}
Commands::Instance(instance_cmd) => {
return handle_instance_command(instance_cmd.clone()).await;
}
_ => {
// For all other commands, check if daemon is running
if !daemon::Daemon::is_running() {
println!("❌ Spacedrive daemon is not running");
println!(" Start it with: spacedrive start");
if !daemon::Daemon::is_running_instance(cli.instance.clone()) {
let instance_display = cli.instance.as_deref().unwrap_or("default");
println!("❌ Spacedrive daemon instance '{}' is not running", instance_display);
if cli.instance.is_some() {
println!(" Start it with: spacedrive --instance {} start", instance_display);
} else {
println!(" Start it with: spacedrive start");
}
return Ok(());
}
}
@@ -128,16 +163,16 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
// For library, location, and job commands, use the daemon
match &cli.command {
Commands::Library(library_cmd) => {
return handle_library_daemon_command(library_cmd.clone()).await;
return handle_library_daemon_command(library_cmd.clone(), cli.instance.clone()).await;
}
Commands::Location(location_cmd) => {
return handle_location_daemon_command(location_cmd.clone()).await;
return handle_location_daemon_command(location_cmd.clone(), cli.instance.clone()).await;
}
Commands::Job(job_cmd) => {
return handle_job_daemon_command(job_cmd.clone()).await;
return handle_job_daemon_command(job_cmd.clone(), cli.instance.clone()).await;
}
Commands::Network(network_cmd) => {
return handle_network_daemon_command(network_cmd.clone()).await;
return handle_network_daemon_command(network_cmd.clone(), cli.instance.clone()).await;
}
Commands::Monitor => {
// Special case - monitor needs event streaming
@@ -151,7 +186,12 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
// Initialize core (temporary - for commands not yet converted to daemon)
let core = Core::new_with_config(data_dir.clone()).await?;
// Load CLI state
// Load CLI state (instance-specific)
let state_path = if cli.instance.is_some() {
data_dir.join("cli_state.json")
} else {
data_dir.join("cli_state.json")
};
let mut state = state::CliState::load(&data_dir)?;
// Execute command
@@ -168,7 +208,7 @@ pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
}
Commands::Monitor => monitor::run_monitor(&core).await?,
Commands::Status => commands::handle_status_command(&core, &state).await?,
Commands::Start { .. } | Commands::Stop | Commands::Daemon | Commands::Network(_) => {
Commands::Start { .. } | Commands::Stop | Commands::Daemon | Commands::Instance(_) | Commands::Network(_) => {
// These are handled above, should never reach here
unreachable!()
}
@@ -187,9 +227,11 @@ async fn handle_start_daemon(
data_dir: PathBuf,
foreground: bool,
enable_networking: bool,
instance_name: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
if daemon::Daemon::is_running() {
println!("⚠️ Spacedrive daemon is already running");
if daemon::Daemon::is_running_instance(instance_name.clone()) {
let instance_display = instance_name.as_deref().unwrap_or("default");
println!("⚠️ Spacedrive daemon instance '{}' is already running", instance_display);
return Ok(());
}
@@ -205,17 +247,17 @@ async fn handle_start_daemon(
println!(" Using default networking configuration.");
println!(" Use 'spacedrive network init --password <your_password>' to set a custom password.");
match daemon::Daemon::new_with_networking(data_dir.clone(), default_password).await {
match daemon::Daemon::new_with_networking_and_instance(data_dir.clone(), default_password, instance_name.clone()).await {
Ok(daemon) => daemon.start().await?,
Err(e) => {
println!("❌ Failed to start daemon with networking: {}", e);
println!(" Falling back to daemon without networking...");
let daemon = daemon::Daemon::new(data_dir).await?;
let daemon = daemon::Daemon::new_with_instance(data_dir, instance_name.clone()).await?;
daemon.start().await?;
}
}
} else {
let daemon = daemon::Daemon::new(data_dir).await?;
let daemon = daemon::Daemon::new_with_instance(data_dir, instance_name.clone()).await?;
daemon.start().await?;
}
} else {
@@ -229,6 +271,10 @@ async fn handle_start_daemon(
.arg("--data-dir")
.arg(data_dir);
if let Some(ref instance) = instance_name {
cmd.arg("--instance").arg(instance);
}
if enable_networking {
cmd.arg("--enable-networking");
}
@@ -255,45 +301,51 @@ async fn handle_start_daemon(
// Wait a bit to see if it started
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if daemon::Daemon::is_running() {
println!("✅ Spacedrive daemon started successfully");
if daemon::Daemon::is_running_instance(instance_name.clone()) {
let instance_display = instance_name.as_deref().unwrap_or("default");
println!("✅ Spacedrive daemon instance '{}' started successfully", instance_display);
} else {
println!("❌ Failed to start Spacedrive daemon");
let instance_display = instance_name.as_deref().unwrap_or("default");
println!("❌ Failed to start Spacedrive daemon instance '{}'", instance_display);
}
}
Ok(())
}
async fn handle_stop_daemon() -> Result<(), Box<dyn std::error::Error>> {
if !daemon::Daemon::is_running() {
println!("⚠️ Spacedrive daemon is not running");
async fn handle_stop_daemon(instance_name: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
if !daemon::Daemon::is_running_instance(instance_name.clone()) {
let instance_display = instance_name.as_deref().unwrap_or("default");
println!("⚠️ Spacedrive daemon instance '{}' is not running", instance_display);
return Ok(());
}
println!("🛑 Stopping Spacedrive daemon...");
daemon::Daemon::stop().await?;
let instance_display = instance_name.as_deref().unwrap_or("default");
println!("🛑 Stopping Spacedrive daemon instance '{}'...", instance_display);
daemon::Daemon::stop_instance(instance_name.clone()).await?;
// Wait a bit to ensure it's stopped
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
if !daemon::Daemon::is_running() {
println!("✅ Spacedrive daemon stopped");
if !daemon::Daemon::is_running_instance(instance_name.clone()) {
println!("✅ Spacedrive daemon instance '{}' stopped", instance_display);
} else {
println!("❌ Failed to stop Spacedrive daemon");
println!("❌ Failed to stop Spacedrive daemon instance '{}'", instance_display);
}
Ok(())
}
async fn handle_daemon_status() -> Result<(), Box<dyn std::error::Error>> {
async fn handle_daemon_status(instance_name: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
use colored::Colorize;
if daemon::Daemon::is_running() {
println!("✅ Spacedrive daemon is running");
let instance_display = instance_name.as_deref().unwrap_or("default");
if daemon::Daemon::is_running_instance(instance_name.clone()) {
println!("✅ Spacedrive daemon instance '{}' is running", instance_display);
// Try to get more info from daemon
let client = daemon::DaemonClient::new();
let client = daemon::DaemonClient::new_with_instance(instance_name);
// Get status
match client.send_command(daemon::DaemonCommand::GetStatus).await {
@@ -364,8 +416,73 @@ async fn handle_daemon_status() -> Result<(), Box<dyn std::error::Error>> {
_ => {}
}
} else {
println!("❌ Spacedrive daemon is not running");
println!(" Start it with: spacedrive start");
println!("❌ Spacedrive daemon instance '{}' is not running", instance_display);
if instance_name.is_some() {
println!(" Start it with: spacedrive --instance {} start", instance_display);
} else {
println!(" Start it with: spacedrive start");
}
}
Ok(())
}
async fn handle_instance_command(
cmd: InstanceCommands,
) -> Result<(), Box<dyn std::error::Error>> {
use colored::Colorize;
match cmd {
InstanceCommands::List => {
match daemon::Daemon::list_instances() {
Ok(instances) => {
if instances.is_empty() {
println!("📭 No daemon instances found");
} else {
use comfy_table::Table;
let mut table = Table::new();
table.set_header(vec!["Instance", "Status", "Socket Path"]);
for instance in instances {
let status = if instance.is_running {
"Running".green()
} else {
"Stopped".red()
};
table.add_row(vec![
instance.display_name().to_string(),
status.to_string(),
instance.socket_path.display().to_string(),
]);
}
println!("{}", table);
}
}
Err(e) => {
println!("❌ Failed to list instances: {}", e);
}
}
}
InstanceCommands::Stop { name } => {
let instance_name = if name == "default" { None } else { Some(name.clone()) };
match daemon::Daemon::stop_instance(instance_name).await {
Ok(_) => {
println!("✅ Daemon instance '{}' stopped", name);
}
Err(e) => {
println!("❌ Failed to stop instance '{}': {}", name, e);
}
}
}
InstanceCommands::Current => {
// This would show the current instance based on CLI args or context
println!("Current instance functionality not yet implemented");
println!("Use --instance <name> flag to target specific instances");
}
}
Ok(())
@@ -373,10 +490,11 @@ async fn handle_daemon_status() -> Result<(), Box<dyn std::error::Error>> {
async fn handle_library_daemon_command(
cmd: commands::LibraryCommands,
instance_name: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use colored::Colorize;
let client = daemon::DaemonClient::new();
let client = daemon::DaemonClient::new_with_instance(instance_name.clone());
match cmd {
commands::LibraryCommands::Create { name, path } => {
@@ -500,10 +618,11 @@ async fn handle_library_daemon_command(
async fn handle_location_daemon_command(
cmd: commands::LocationCommands,
instance_name: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use colored::Colorize;
let client = daemon::DaemonClient::new();
let client = daemon::DaemonClient::new_with_instance(instance_name.clone());
match cmd {
commands::LocationCommands::Add { path, name, mode } => {
@@ -700,10 +819,11 @@ async fn handle_location_daemon_command(
async fn handle_job_daemon_command(
cmd: commands::JobCommands,
instance_name: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use colored::Colorize;
let client = daemon::DaemonClient::new();
let client = daemon::DaemonClient::new_with_instance(instance_name.clone());
match cmd {
commands::JobCommands::List { status, recent: _ } => {
@@ -898,10 +1018,11 @@ async fn handle_job_daemon_command(
async fn handle_network_daemon_command(
cmd: commands::NetworkCommands,
instance_name: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
use colored::Colorize;
let client = daemon::DaemonClient::new();
let client = daemon::DaemonClient::new_with_instance(instance_name.clone());
// Check if daemon is running for most commands
match &cmd {