# 09 - Add Service Feature
## Overview
Runtime service registration via CLI and RPC, allowing services to be added without editing config files on disk.
## CLI Interface
### From TOML file
```bash
# Ephemeral (default) - service lost on zinit restart
zinit add-service ./myservice.toml
# Persist to /etc/zinit/services/<name>.toml
zinit add-service --persist ./myservice.toml
# Explicit ephemeral
zinit add-service --ephemeral ./myservice.toml
```
### From structured flags
```bash
# Minimal
zinit add-service --name myapp --exec "/usr/bin/myapp"
# With dependencies
zinit add-service --name myapp \
--exec "/usr/bin/myapp --config /etc/myapp.toml" \
--dir /var/lib/myapp \
--after database \
--requires database \
--wants logging \
--env "DB_HOST=localhost" \
--env "DB_PORT=5432" \
--persist
# Oneshot
zinit add-service --name setup-db \
--exec "/usr/local/bin/init-db.sh" \
--oneshot \
--after network
# With lifecycle options
zinit add-service --name worker \
--exec "/usr/bin/worker" \
--restart always \
--restart-delay 5000 \
--max-restarts 10
```
### CLI Arguments
```rust
#[derive(Parser)]
struct AddServiceCmd {
/// Path to TOML service file (mutually exclusive with --name/--exec)
#[arg(value_name = "FILE")]
file: Option<PathBuf>,
// --- Service definition (alternative to file) ---
/// Service name (required if no file)
#[arg(long, requires = "exec")]
name: Option<String>,
/// Command to execute (required if no file)
#[arg(long, requires = "name")]
exec: Option<String>,
/// Working directory
#[arg(long, default_value = "/")]
dir: String,
/// Run once and don't restart
#[arg(long)]
oneshot: bool,
/// Environment variable (KEY=VALUE), can repeat
#[arg(long = "env", short = 'e', value_name = "KEY=VALUE")]
envs: Vec<String>,
// --- Dependencies ---
/// Start after this service (ordering only)
#[arg(long, value_name = "SERVICE")]
after: Vec<String>,
/// Require this service (hard dependency)
#[arg(long, value_name = "SERVICE")]
requires: Vec<String>,
/// Want this service (soft dependency)
#[arg(long, value_name = "SERVICE")]
wants: Vec<String>,
/// Conflict with this service (mutual exclusion)
#[arg(long, value_name = "SERVICE")]
conflicts: Vec<String>,
// --- Lifecycle ---
/// Restart policy: always, on-failure, never
#[arg(long, default_value = "on-failure")]
restart: String,
/// Initial restart delay in ms
#[arg(long, default_value = "1000")]
restart_delay: u64,
/// Maximum restart delay in ms
#[arg(long, default_value = "300000")]
restart_delay_max: u64,
/// Maximum restart attempts (0 = unlimited)
#[arg(long, default_value = "10")]
max_restarts: u32,
// --- Persistence ---
/// Save to /etc/zinit/services/<name>.toml
#[arg(long, conflicts_with = "ephemeral")]
persist: bool,
/// Don't save to disk (default)
#[arg(long, conflicts_with = "persist")]
ephemeral: bool,
}
```
### CLI Implementation
```rust
fn run_add_service(args: AddServiceCmd, client: &mut ZinitClient) -> Result<()> {
// Build config from file or flags
let config = if let Some(path) = args.file {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?
} else {
let name = args.name.ok_or_else(|| anyhow!("--name required when not using file"))?;
let exec = args.exec.ok_or_else(|| anyhow!("--exec required when not using file"))?;
// Parse env vars
let env: HashMap<String, String> = args.envs
.iter()
.map(|s| {
let (k, v) = s.split_once('=')
.ok_or_else(|| anyhow!("Invalid env format: {} (expected KEY=VALUE)", s))?;
Ok((k.to_string(), v.to_string()))
})
.collect::<Result<_>>()?;
ServiceConfig {
service: ServiceDef {
name,
exec,
dir: args.dir,
oneshot: args.oneshot,
env,
},
dependencies: DependencyDef {
after: args.after,
requires: args.requires,
wants: args.wants,
conflicts: args.conflicts,
},
lifecycle: LifecycleDef {
restart: args.restart.parse()?,
restart_delay_ms: args.restart_delay,
restart_delay_max_ms: args.restart_delay_max,
max_restarts: args.max_restarts,
..Default::default()
},
health: None,
logging: LoggingDef::default(),
}
};
// Call RPC
let persist = args.persist; // ephemeral is default
client.add_service(&config, persist)?;
println!("Service '{}' added{}",
config.service.name,
if persist { " (persisted)" } else { " (ephemeral)" }
);
Ok(())
}
```
---
## RPC Interface
### Request
```
Method: service.add
```
```rust
#[derive(Serialize, Deserialize)]
struct AddServiceParams {
/// Full service configuration
config: ServiceConfig,
/// Whether to persist to disk
#[serde(default)]
persist: bool,
}
```
### Response
```rust
#[derive(Serialize, Deserialize)]
struct AddServiceResult {
/// Service name
name: String,
/// Path where config was saved (if persisted)
path: Option<String>,
/// Validation warnings (non-fatal)
warnings: Vec<String>,
}
```
### Error Codes
| -32001 | SERVICE_EXISTS | Service with this name already registered |
| -32002 | VALIDATION_FAILED | Config validation failed |
| -32003 | DEPENDENCY_MISSING | Referenced dependency doesn't exist |
| -32004 | CIRCULAR_DEPENDENCY | Would create dependency cycle |
| -32005 | EXEC_NOT_FOUND | Executable path doesn't exist |
| -32006 | PERSIST_FAILED | Failed to write config to disk |
### Example Exchange
```json
// Request
{
"jsonrpc": "2.0",
"id": 1,
"method": "service.add",
"params": {
"config": {
"service": {
"name": "myapp",
"exec": "/usr/bin/myapp",
"dir": "/",
"oneshot": false,
"env": {}
},
"dependencies": {
"after": ["database"],
"requires": ["database"],
"wants": [],
"conflicts": []
},
"lifecycle": {
"restart": "on-failure",
"restart_delay_ms": 1000,
"restart_delay_max_ms": 300000,
"max_restarts": 10,
"start_timeout_ms": 30000,
"stop_timeout_ms": 10000,
"stop_signal": "SIGTERM"
},
"health": null,
"logging": {
"buffer_lines": 1000
}
},
"persist": true
}
}
// Success response
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"name": "myapp",
"path": "/etc/zinit/services/myapp.toml",
"warnings": []
}
}
// Error response (service exists)
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32001,
"message": "Service 'myapp' already exists"
}
}
// Error response (validation failed)
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32002,
"message": "Validation failed",
"data": {
"errors": [
"service.exec is required",
"lifecycle.restart_delay_ms must be > 0"
]
}
}
}
```
---
## Server Implementation
### Handler
```rust
impl Supervisor {
pub fn handle_add_service(&mut self, params: AddServiceParams) -> Result<AddServiceResult, RpcError> {
let config = params.config;
let name = config.service.name.clone();
// 1. Check if service already exists
if self.services.contains_key(&name) {
return Err(RpcError::new(
error_codes::SERVICE_EXISTS,
format!("Service '{}' already exists", name),
));
}
// 2. Validate config (basic)
let errors = validate_service(&config);
if !errors.is_empty() {
return Err(RpcError::new_with_data(
error_codes::VALIDATION_FAILED,
"Validation failed",
serde_json::json!({ "errors": errors }),
));
}
// 3. Validate exec path exists
let exec_path = config.service.exec
.split_whitespace()
.next()
.unwrap_or(&config.service.exec);
if !Path::new(exec_path).exists() {
return Err(RpcError::new(
error_codes::EXEC_NOT_FOUND,
format!("Executable not found: {}", exec_path),
));
}
// 4. Validate dependencies exist
let all_deps: Vec<&str> = config.dependencies.after.iter()
.chain(config.dependencies.requires.iter())
.chain(config.dependencies.wants.iter())
.chain(config.dependencies.conflicts.iter())
.map(|s| s.as_str())
.collect();
for dep in &all_deps {
if !self.services.contains_key(*dep) {
return Err(RpcError::new(
error_codes::DEPENDENCY_MISSING,
format!("Dependency '{}' not found", dep),
));
}
}
// 5. Check for circular dependencies
if let Some(cycle) = self.graph.would_create_cycle(&name, &config.dependencies) {
return Err(RpcError::new_with_data(
error_codes::CIRCULAR_DEPENDENCY,
"Would create circular dependency",
serde_json::json!({ "cycle": cycle }),
));
}
// 6. Persist if requested
let path = if params.persist {
let path = self.config_dir.join(format!("{}.toml", name));
let toml = toml::to_string_pretty(&config)
.map_err(|e| RpcError::new(
error_codes::PERSIST_FAILED,
format!("Failed to serialize config: {}", e),
))?;
std::fs::write(&path, toml)
.map_err(|e| RpcError::new(
error_codes::PERSIST_FAILED,
format!("Failed to write {}: {}", path.display(), e),
))?;
Some(path.display().to_string())
} else {
None
};
// 7. Add to graph and services
self.graph.add_service(&name, &config.dependencies);
let service = Service::new(config);
self.services.insert(name.clone(), service);
// 8. Collect warnings
let mut warnings = Vec::new();
// Warn about soft dependencies that don't exist (wants)
// Actually we error above, but if we made wants optional:
// for dep in &config.dependencies.wants {
// if !self.services.contains_key(dep) {
// warnings.push(format!("Wanted service '{}' not found", dep));
// }
// }
Ok(AddServiceResult {
name,
path,
warnings,
})
}
}
```
### Graph Cycle Detection
```rust
impl DependencyGraph {
/// Check if adding this service with these deps would create a cycle
pub fn would_create_cycle(&self, name: &str, deps: &DependencyDef) -> Option<Vec<String>> {
// Temporarily add the node
let mut test_graph = self.clone();
test_graph.add_service(name, deps);
// Check for cycles using DFS
test_graph.find_cycle()
}
/// Find any cycle in the graph, returns the cycle path if found
fn find_cycle(&self) -> Option<Vec<String>> {
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
let mut path = Vec::new();
for node in self.nodes.keys() {
if !visited.contains(node) {
if let Some(cycle) = self.dfs_cycle(node, &mut visited, &mut rec_stack, &mut path) {
return Some(cycle);
}
}
}
None
}
fn dfs_cycle(
&self,
node: &str,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
path: &mut Vec<String>,
) -> Option<Vec<String>> {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
path.push(node.to_string());
if let Some(deps) = self.edges.get(node) {
for dep in deps {
if !visited.contains(dep) {
if let Some(cycle) = self.dfs_cycle(dep, visited, rec_stack, path) {
return Some(cycle);
}
} else if rec_stack.contains(dep) {
// Found cycle - extract it from path
let start = path.iter().position(|n| n == dep).unwrap();
let mut cycle: Vec<String> = path[start..].to_vec();
cycle.push(dep.clone()); // Close the cycle
return Some(cycle);
}
}
}
path.pop();
rec_stack.remove(node);
None
}
}
```
---
## Error Codes (add to protocol.rs)
```rust
pub mod error_codes {
// Existing codes...
pub const NOT_FOUND: i32 = -32000;
// New codes for add-service
pub const SERVICE_EXISTS: i32 = -32001;
pub const VALIDATION_FAILED: i32 = -32002;
pub const DEPENDENCY_MISSING: i32 = -32003;
pub const CIRCULAR_DEPENDENCY: i32 = -32004;
pub const EXEC_NOT_FOUND: i32 = -32005;
pub const PERSIST_FAILED: i32 = -32006;
}
```
---
## Behavior Notes
### No auto-start
After `add-service`, the service is registered but **not started**. Its initial state is `Inactive`. User must explicitly call `zinit start <name>`.
```bash
$ zinit add-service --name foo --exec /bin/foo
Service 'foo' added (ephemeral)
$ zinit list
[-] foo inactive
$ zinit start foo
$ zinit list
[+] foo running (pid: 1234)
```
### Ephemeral vs Persist
- **Ephemeral** (default): Service exists only in memory. Lost when zinit-server restarts.
- **Persist**: Writes config to `/etc/zinit/services/<name>.toml`. Survives restarts.
### Conflict handling
If service name exists, error out immediately:
```bash
$ zinit add-service --name sshd --exec /bin/false
Error: Service 'sshd' already exists
```
To replace, user must `zinit remove <name>` first.
### Dependency validation
- **after/requires/conflicts**: Must reference existing services. Error if not found.
- **wants**: Could be lenient (warn but allow), but for simplicity, also error if not found.
- **Circular deps**: Detected via graph cycle check. Error with cycle path in response.
### Exec validation
Extracts first token from exec string (the binary path) and checks `Path::exists()`.
```bash
$ zinit add-service --name bad --exec "/nonexistent/binary"
Error: Executable not found: /nonexistent/binary
$ zinit add-service --name ok --exec "/bin/sh -c 'echo hello'"
Service 'ok' added (ephemeral)
# Validates /bin/sh exists
```
---
## Client Method Update
Update `ZinitClient::add_service` to include persist flag:
```rust
impl ZinitClient {
pub fn add_service(&mut self, config: &ServiceConfig, persist: bool) -> Result<AddServiceResult, ClientError> {
let resp = self.call("service.add", serde_json::json!({
"config": config,
"persist": persist
}))?;
resp.into_result().map_err(Into::into)
}
}
```
---
## Testing
### Unit tests
```rust
#[test]
fn test_add_service_basic() {
let mut supervisor = test_supervisor();
let config = ServiceConfig {
service: ServiceDef {
name: "test".into(),
exec: "/bin/true".into(),
..Default::default()
},
..Default::default()
};
let result = supervisor.handle_add_service(AddServiceParams {
config,
persist: false,
}).unwrap();
assert_eq!(result.name, "test");
assert!(result.path.is_none());
assert!(supervisor.services.contains_key("test"));
}
#[test]
fn test_add_service_duplicate_fails() {
let mut supervisor = test_supervisor_with_service("existing");
let config = ServiceConfig {
service: ServiceDef {
name: "existing".into(),
exec: "/bin/true".into(),
..Default::default()
},
..Default::default()
};
let err = supervisor.handle_add_service(AddServiceParams {
config,
persist: false,
}).unwrap_err();
assert_eq!(err.code, error_codes::SERVICE_EXISTS);
}
#[test]
fn test_add_service_missing_dep_fails() {
let mut supervisor = test_supervisor();
let config = ServiceConfig {
service: ServiceDef {
name: "test".into(),
exec: "/bin/true".into(),
..Default::default()
},
dependencies: DependencyDef {
requires: vec!["nonexistent".into()],
..Default::default()
},
..Default::default()
};
let err = supervisor.handle_add_service(AddServiceParams {
config,
persist: false,
}).unwrap_err();
assert_eq!(err.code, error_codes::DEPENDENCY_MISSING);
}
#[test]
fn test_add_service_cycle_detection() {
let mut supervisor = test_supervisor();
// Add A -> B
add_service(&mut supervisor, "a", &["b"]);
add_service(&mut supervisor, "b", &[]);
// Try to add B -> A (would create cycle)
let config = ServiceConfig {
service: ServiceDef {
name: "b".into(),
exec: "/bin/true".into(),
..Default::default()
},
dependencies: DependencyDef {
requires: vec!["a".into()],
..Default::default()
},
..Default::default()
};
// This would fail because B already exists
// Test cycle with new service instead:
// A -> B -> C -> A
add_service(&mut supervisor, "c", &[]);
let config = ServiceConfig {
service: ServiceDef {
name: "d".into(),
exec: "/bin/true".into(),
..Default::default()
},
dependencies: DependencyDef {
after: vec!["a".into()],
requires: vec!["c".into()],
..Default::default()
},
..Default::default()
};
// Add D that depends on A, then try to make A depend on D
// ... (cycle detection test continues)
}
```
### Integration tests
```bash
#!/bin/bash
# test_add_service.sh
# Setup
zinit add-service --name base --exec "/bin/sleep 300"
zinit start base
# Test: add service with dependency
zinit add-service --name dependent --exec "/bin/sleep 300" --requires base
# Test: duplicate fails
# Test: missing dep fails
# Test: bad exec fails
# Cleanup
zinit stop dependent
zinit stop base
zinit remove dependent
zinit remove base
```