# zinit-common - Shared Crate Specification
Shared types and functionality used by server, client, and pid1.
## Design Principles
- **No heavy dependencies** - just serde + serde_json
- **No async** - blocking client, async version in zinit-client if needed
- **Pure data types** - easily serializable
- **Shared logic** - validation, signal parsing, client helpers
## Cargo.toml
```toml
[package]
name = "zinit-common"
version.workspace = true
edition.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
```
## Structure
```
zinit-common/
├── Cargo.toml
└── src/
├── lib.rs # Re-exports
├── state.rs # ServiceState, FailureReason
├── config.rs # ServiceConfig, DependencyDef, etc.
├── protocol.rs # RpcRequest, RpcResponse, error codes
├── responses.rs # ServiceInfo, ServiceStatus, LogLine
├── socket.rs # Path constants and helpers
├── signal.rs # Signal name/number conversion
├── validate.rs # Config validation
└── client.rs # Blocking ZinitClient
```
---
## state.rs
```rust
use serde::{Deserialize, Serialize};
/// Service state - shared between server and client
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "lowercase")]
pub enum ServiceState {
Inactive,
Blocked { waiting_on: Vec<String> },
Starting { pid: u32 },
Running { pid: u32 },
Stopping { pid: u32 },
Exited { exit_code: Option<i32> },
Failed { reason: FailureReason },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum FailureReason {
ExitCode { code: i32 },
Signal { signal: i32 },
StartTimeout,
StopTimeout,
HealthCheckFailed { attempts: u32 },
DependencyFailed { service: String },
SpawnError { message: String },
}
impl ServiceState {
pub fn name(&self) -> &'static str {
match self {
Self::Inactive => "inactive",
Self::Blocked { .. } => "blocked",
Self::Starting { .. } => "starting",
Self::Running { .. } => "running",
Self::Stopping { .. } => "stopping",
Self::Exited { .. } => "exited",
Self::Failed { .. } => "failed",
}
}
pub fn symbol(&self) -> &'static str {
match self {
Self::Inactive => "[-]",
Self::Blocked { .. } => "[?]",
Self::Starting { .. } => "[>]",
Self::Running { .. } => "[+]",
Self::Stopping { .. } => "[!]",
Self::Exited { .. } => "[.]",
Self::Failed { .. } => "[X]",
}
}
pub fn pid(&self) -> Option<u32> {
match self {
Self::Starting { pid } => Some(*pid),
Self::Running { pid } => Some(*pid),
Self::Stopping { pid } => Some(*pid),
_ => None,
}
}
pub fn is_active(&self) -> bool {
matches!(self, Self::Starting { .. } | Self::Running { .. } | Self::Stopping { .. })
}
pub fn is_satisfied(&self) -> bool {
matches!(self, Self::Running { .. })
}
pub fn can_attempt_start(&self) -> bool {
matches!(self, Self::Inactive | Self::Exited { .. } | Self::Failed { .. })
}
}
impl FailureReason {
pub fn display(&self) -> String {
match self {
Self::ExitCode { code } => format!("exit code {}", code),
Self::Signal { signal } => format!("signal {}", crate::signal::name(*signal)),
Self::StartTimeout => "start timeout".into(),
Self::StopTimeout => "stop timeout".into(),
Self::HealthCheckFailed { attempts } => format!("health check failed after {} attempts", attempts),
Self::DependencyFailed { service } => format!("dependency {} failed", service),
Self::SpawnError { message } => format!("spawn error: {}", message),
}
}
}
```
---
## config.rs
```rust
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfig {
pub service: ServiceDef,
#[serde(default)]
pub dependencies: DependencyDef,
#[serde(default)]
pub lifecycle: LifecycleDef,
#[serde(default)]
pub health: Option<HealthDef>,
#[serde(default)]
pub logging: LoggingDef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDef {
pub name: String,
pub exec: String,
#[serde(default = "default_dir")]
pub dir: String,
#[serde(default)]
pub oneshot: bool,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DependencyDef {
#[serde(default)]
pub after: Vec<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub wants: Vec<String>,
#[serde(default)]
pub conflicts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LifecycleDef {
#[serde(default)]
pub restart: RestartPolicy,
#[serde(default = "default_restart_delay")]
pub restart_delay_ms: u64,
#[serde(default = "default_restart_delay_max")]
pub restart_delay_max_ms: u64,
#[serde(default = "default_max_restarts")]
pub max_restarts: u32,
#[serde(default = "default_start_timeout")]
pub start_timeout_ms: u64,
#[serde(default = "default_stop_timeout")]
pub stop_timeout_ms: u64,
#[serde(default = "default_stop_signal")]
pub stop_signal: String,
}
impl Default for LifecycleDef {
fn default() -> Self {
Self {
restart: RestartPolicy::default(),
restart_delay_ms: default_restart_delay(),
restart_delay_max_ms: default_restart_delay_max(),
max_restarts: default_max_restarts(),
start_timeout_ms: default_start_timeout(),
stop_timeout_ms: default_stop_timeout(),
stop_signal: default_stop_signal(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RestartPolicy {
Always,
#[default]
OnFailure,
Never,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum HealthDef {
Tcp {
target: String,
#[serde(flatten)]
common: HealthCommon,
},
Http {
target: String,
#[serde(default = "default_http_status")]
expect_status: u16,
#[serde(flatten)]
common: HealthCommon,
},
Exec {
target: String,
#[serde(flatten)]
common: HealthCommon,
},
}
impl HealthDef {
pub fn common(&self) -> &HealthCommon {
match self {
Self::Tcp { common, .. } => common,
Self::Http { common, .. } => common,
Self::Exec { common, .. } => common,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthCommon {
#[serde(default = "default_health_interval")]
pub interval_ms: u64,
#[serde(default = "default_health_timeout")]
pub timeout_ms: u64,
#[serde(default = "default_health_retries")]
pub retries: u32,
#[serde(default = "default_start_period")]
pub start_period_ms: u64,
}
impl Default for HealthCommon {
fn default() -> Self {
Self {
interval_ms: default_health_interval(),
timeout_ms: default_health_timeout(),
retries: default_health_retries(),
start_period_ms: default_start_period(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingDef {
#[serde(default = "default_buffer_lines")]
pub buffer_lines: usize,
pub file: Option<String>,
#[serde(default = "default_forward")]
pub forward: bool,
}
impl Default for LoggingDef {
fn default() -> Self {
Self {
buffer_lines: default_buffer_lines(),
file: None,
forward: default_forward(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetConfig {
pub target: TargetDef,
#[serde(default)]
pub dependencies: DependencyDef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetDef {
pub name: String,
}
// Default functions
fn default_dir() -> String { "/".into() }
fn default_restart_delay() -> u64 { 1000 }
fn default_restart_delay_max() -> u64 { 300000 }
fn default_max_restarts() -> u32 { 10 }
fn default_start_timeout() -> u64 { 30000 }
fn default_stop_timeout() -> u64 { 10000 }
fn default_stop_signal() -> String { "SIGTERM".into() }
fn default_http_status() -> u16 { 200 }
fn default_health_interval() -> u64 { 10000 }
fn default_health_timeout() -> u64 { 5000 }
fn default_health_retries() -> u32 { 3 }
fn default_start_period() -> u64 { 5000 }
fn default_buffer_lines() -> usize { 1000 }
fn default_forward() -> bool { true }
```
---
## protocol.rs
```rust
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcRequest {
pub jsonrpc: String,
pub id: u64,
pub method: String,
#[serde(default)]
pub params: Value,
}
impl RpcRequest {
pub fn new(id: u64, method: impl Into<String>, params: Value) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
method: method.into(),
params,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcResponse {
pub jsonrpc: String,
pub id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<RpcError>,
}
impl RpcResponse {
pub fn success(id: u64, result: Value) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: Some(result),
error: None,
}
}
pub fn error(id: u64, code: i32, message: impl Into<String>) -> Self {
Self {
jsonrpc: "2.0".into(),
id,
result: None,
error: Some(RpcError {
code,
message: message.into(),
data: None,
}),
}
}
pub fn is_ok(&self) -> bool {
self.error.is_none()
}
pub fn into_result<T: serde::de::DeserializeOwned>(self) -> Result<T, RpcError> {
if let Some(err) = self.error {
Err(err)
} else if let Some(result) = self.result {
serde_json::from_value(result).map_err(|e| RpcError {
code: error_codes::INTERNAL_ERROR,
message: e.to_string(),
data: None,
})
} else {
serde_json::from_value(Value::Null).map_err(|e| RpcError {
code: error_codes::INTERNAL_ERROR,
message: e.to_string(),
data: None,
})
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RpcError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl std::fmt::Display for RpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for RpcError {}
pub mod error_codes {
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
// Custom error codes
pub const SERVICE_NOT_FOUND: i32 = -32000;
pub const SERVICE_ALREADY_RUNNING: i32 = -32001;
pub const SERVICE_NOT_RUNNING: i32 = -32002;
pub const INVALID_CONFIG: i32 = -32003;
pub const CYCLE_DETECTED: i32 = -32004;
pub const UNSAFE_REMOVAL: i32 = -32005;
}
```
---
## responses.rs
```rust
use serde::{Deserialize, Serialize};
use crate::state::ServiceState;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DepType {
After,
Requires,
Wants,
Conflicts,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceInfo {
pub name: String,
pub state: ServiceState,
pub is_target: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceStatus {
pub name: String,
pub state: ServiceState,
pub is_target: bool,
pub dependencies: Vec<DependencyInfo>,
pub uptime_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub name: String,
pub dep_type: DepType,
pub state: ServiceState,
pub satisfied: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReloadResult {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogStream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogLine {
pub timestamp_ms: u64,
pub service: String,
pub stream: LogStream,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhyBlocked {
pub name: String,
pub blocked: bool,
pub waiting_on: Vec<String>,
pub conflicts_with: Vec<String>,
pub ascii: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeResponse {
pub ascii: String,
}
```
---
## socket.rs
```rust
use std::path::PathBuf;
pub const SYSTEM_SOCKET: &str = "/var/run/zinit.sock";
pub const USER_SOCKET_SUFFIX: &str = "hero/var/zinit.sock";
pub fn default_path() -> PathBuf {
if PathBuf::from(SYSTEM_SOCKET).exists() {
PathBuf::from(SYSTEM_SOCKET)
} else if let Some(home) = std::env::var_os("HOME") {
PathBuf::from(home).join(USER_SOCKET_SUFFIX)
} else {
PathBuf::from(SYSTEM_SOCKET)
}
}
pub fn user_path() -> Option<PathBuf> {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(USER_SOCKET_SUFFIX))
}
pub fn system_path() -> PathBuf {
PathBuf::from(SYSTEM_SOCKET)
}
```
---
## signal.rs
```rust
pub fn parse(name: &str) -> Option<i32> {
match name.to_uppercase().trim_start_matches("SIG") {
"HUP" => Some(1),
"INT" => Some(2),
"QUIT" => Some(3),
"KILL" => Some(9),
"USR1" => Some(10),
"USR2" => Some(12),
"TERM" => Some(15),
_ => name.parse().ok(),
}
}
pub fn name(sig: i32) -> &'static str {
match sig {
1 => "SIGHUP",
2 => "SIGINT",
3 => "SIGQUIT",
9 => "SIGKILL",
10 => "SIGUSR1",
12 => "SIGUSR2",
15 => "SIGTERM",
_ => "UNKNOWN",
}
}
```
---
## validate.rs
```rust
use crate::config::{ServiceConfig, TargetConfig};
pub fn validate_service(config: &ServiceConfig) -> Vec<String> {
let mut errors = Vec::new();
if config.service.name.is_empty() {
errors.push("service.name is required".into());
}
if config.service.name.contains('/') || config.service.name.contains('\0') {
errors.push("service.name contains invalid characters".into());
}
if config.service.exec.is_empty() {
errors.push("service.exec is required".into());
}
if config.lifecycle.restart_delay_ms == 0 {
errors.push("lifecycle.restart_delay_ms must be > 0".into());
}
if config.lifecycle.start_timeout_ms == 0 {
errors.push("lifecycle.start_timeout_ms must be > 0".into());
}
if config.lifecycle.stop_timeout_ms == 0 {
errors.push("lifecycle.stop_timeout_ms must be > 0".into());
}
if crate::signal::parse(&config.lifecycle.stop_signal).is_none() {
errors.push(format!("invalid stop_signal: {}", config.lifecycle.stop_signal));
}
if let Some(ref health) = config.health {
let common = health.common();
if common.retries == 0 {
errors.push("health.retries must be > 0".into());
}
if common.timeout_ms == 0 {
errors.push("health.timeout_ms must be > 0".into());
}
if common.interval_ms == 0 {
errors.push("health.interval_ms must be > 0".into());
}
}
if config.logging.buffer_lines == 0 {
errors.push("logging.buffer_lines must be > 0".into());
}
errors
}
pub fn validate_target(config: &TargetConfig) -> Vec<String> {
let mut errors = Vec::new();
if config.target.name.is_empty() {
errors.push("target.name is required".into());
}
if config.target.name.contains('/') || config.target.name.contains('\0') {
errors.push("target.name contains invalid characters".into());
}
errors
}
```
---
## client.rs
```rust
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::time::Duration;
use crate::config::ServiceConfig;
use crate::protocol::{RpcError, RpcRequest, RpcResponse};
use crate::responses::*;
pub struct ZinitClient {
stream: UnixStream,
reader: BufReader<UnixStream>,
next_id: u64,
}
#[derive(Debug)]
pub enum ClientError {
Io(std::io::Error),
Json(serde_json::Error),
Rpc(RpcError),
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "IO error: {}", e),
Self::Json(e) => write!(f, "JSON error: {}", e),
Self::Rpc(e) => write!(f, "RPC error: {}", e),
}
}
}
impl std::error::Error for ClientError {}
impl From<std::io::Error> for ClientError {
fn from(e: std::io::Error) -> Self { Self::Io(e) }
}
impl From<serde_json::Error> for ClientError {
fn from(e: serde_json::Error) -> Self { Self::Json(e) }
}
impl From<RpcError> for ClientError {
fn from(e: RpcError) -> Self { Self::Rpc(e) }
}
impl ZinitClient {
pub fn connect(path: &Path) -> Result<Self, ClientError> {
let stream = UnixStream::connect(path)?;
stream.set_read_timeout(Some(Duration::from_secs(30)))?;
stream.set_write_timeout(Some(Duration::from_secs(10)))?;
let reader = BufReader::new(stream.try_clone()?);
Ok(Self { stream, reader, next_id: 1 })
}
pub fn connect_default() -> Result<Self, ClientError> {
Self::connect(&crate::socket::default_path())
}
pub fn call(&mut self, method: &str, params: serde_json::Value) -> Result<RpcResponse, ClientError> {
let request = RpcRequest::new(self.next_id, method, params);
self.next_id += 1;
let json = serde_json::to_string(&request)?;
writeln!(self.stream, "{}", json)?;
self.stream.flush()?;
let mut line = String::new();
self.reader.read_line(&mut line)?;
let response: RpcResponse = serde_json::from_str(&line)?;
Ok(response)
}
pub fn ping(&mut self) -> Result<String, ClientError> {
let resp = self.call("system.ping", serde_json::json!({}))?;
let val: serde_json::Value = resp.into_result()?;
Ok(val.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string())
}
pub fn shutdown(&mut self) -> Result<(), ClientError> {
let resp = self.call("system.shutdown", serde_json::json!({}))?;
let _: bool = resp.into_result()?;
Ok(())
}
pub fn list(&mut self) -> Result<Vec<ServiceInfo>, ClientError> {
let resp = self.call("service.list", serde_json::json!({}))?;
resp.into_result().map_err(Into::into)
}
pub fn status(&mut self, name: &str) -> Result<ServiceStatus, ClientError> {
let resp = self.call("service.status", serde_json::json!({"name": name}))?;
resp.into_result().map_err(Into::into)
}
pub fn start(&mut self, name: &str) -> Result<(), ClientError> {
let resp = self.call("service.start", serde_json::json!({"name": name}))?;
let _: serde_json::Value = resp.into_result()?;
Ok(())
}
pub fn stop(&mut self, name: &str) -> Result<(), ClientError> {
let resp = self.call("service.stop", serde_json::json!({"name": name}))?;
let _: serde_json::Value = resp.into_result()?;
Ok(())
}
pub fn restart(&mut self, name: &str) -> Result<(), ClientError> {
let resp = self.call("service.restart", serde_json::json!({"name": name}))?;
let _: serde_json::Value = resp.into_result()?;
Ok(())
}
pub fn kill(&mut self, name: &str, signal: Option<&str>) -> Result<(), ClientError> {
let params = match signal {
Some(s) => serde_json::json!({"name": name, "signal": s}),
None => serde_json::json!({"name": name}),
};
let resp = self.call("service.kill", params)?;
let _: serde_json::Value = resp.into_result()?;
Ok(())
}
pub fn why(&mut self, name: &str) -> Result<WhyBlocked, ClientError> {
let resp = self.call("service.why", serde_json::json!({"name": name}))?;
resp.into_result().map_err(Into::into)
}
pub fn tree(&mut self) -> Result<String, ClientError> {
let resp = self.call("service.tree", serde_json::json!({}))?;
let tree: TreeResponse = resp.into_result()?;
Ok(tree.ascii)
}
pub fn add(&mut self, config: ServiceConfig) -> Result<(), ClientError> {
let resp = self.call("service.add", serde_json::json!({"config": config}))?;
let _: serde_json::Value = resp.into_result()?;
Ok(())
}
pub fn remove(&mut self, name: &str) -> Result<(), ClientError> {
let resp = self.call("service.remove", serde_json::json!({"name": name}))?;
let _: serde_json::Value = resp.into_result()?;
Ok(())
}
pub fn reload(&mut self) -> Result<ReloadResult, ClientError> {
let resp = self.call("service.reload", serde_json::json!({}))?;
resp.into_result().map_err(Into::into)
}
pub fn logs(&mut self, name: &str, lines: Option<usize>) -> Result<Vec<LogLine>, ClientError> {
let params = match lines {
Some(n) => serde_json::json!({"name": name, "lines": n}),
None => serde_json::json!({"name": name}),
};
let resp = self.call("logs.tail", params)?;
resp.into_result().map_err(Into::into)
}
}
```
---
## lib.rs
```rust
pub mod state;
pub mod config;
pub mod protocol;
pub mod responses;
pub mod socket;
pub mod signal;
pub mod validate;
pub mod client;
pub use state::{ServiceState, FailureReason};
pub use config::{ServiceConfig, TargetConfig, DependencyDef, LifecycleDef, RestartPolicy};
pub use protocol::{RpcRequest, RpcResponse, RpcError, error_codes};
pub use responses::{ServiceInfo, ServiceStatus, LogLine, LogStream, DepType, ReloadResult};
pub use client::{ZinitClient, ClientError};
```