Run world service policy in WASI init with pollable process observation
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
//! Reads a unit journal, with optional live output through the world watcher.
|
||||
use sausage_init::name;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Write},
|
||||
};
|
||||
fn run() -> io::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let (mut unit, mut lines, mut follow) = (String::new(), 10usize, false);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"-u" => {
|
||||
unit = name(
|
||||
&args
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::other("missing unit"))?,
|
||||
)
|
||||
}
|
||||
"-n" => {
|
||||
lines = args
|
||||
.next()
|
||||
.and_then(|n| n.parse().ok())
|
||||
.ok_or_else(|| io::Error::other("missing count"))?
|
||||
}
|
||||
"-f" => follow = true,
|
||||
"--no-pager" => (),
|
||||
_ => return Err(io::Error::other("unknown argument")),
|
||||
}
|
||||
}
|
||||
let path = format!("/var/log/journal/{unit}.log");
|
||||
let watcher = sausage_spawn::Watcher::new(&[path.clone()]);
|
||||
let bytes = fs::read(&path).unwrap_or_default();
|
||||
let mut starts = vec![0];
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
if *b == b'\n' && i + 1 < bytes.len() {
|
||||
starts.push(i + 1);
|
||||
}
|
||||
}
|
||||
let start = if lines == 0 {
|
||||
bytes.len()
|
||||
} else {
|
||||
starts[starts.len().saturating_sub(lines)]
|
||||
};
|
||||
io::stdout().write_all(&bytes[start..])?;
|
||||
io::stdout().flush()?;
|
||||
let mut offset = bytes.len();
|
||||
while follow {
|
||||
let pollable = watcher.subscribe();
|
||||
pollable.block();
|
||||
drop(pollable);
|
||||
watcher.changes();
|
||||
let bytes = fs::read(&path).unwrap_or_default();
|
||||
if bytes.len() < offset {
|
||||
offset = 0;
|
||||
}
|
||||
io::stdout().write_all(&bytes[offset..])?;
|
||||
io::stdout().flush()?;
|
||||
offset = bytes.len();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn main() {
|
||||
if run().is_err() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Unit definitions and the file protocol shared by init and its control tools.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, fs, io, time::Duration};
|
||||
/// WASI 0.2's legacy exit call loses numeric error codes. Use the standard extended call.
|
||||
pub fn exit(code: i32) -> ! {
|
||||
wasi::cli::exit::exit_with_code(code as u8);
|
||||
unreachable!("exit returned")
|
||||
}
|
||||
pub const RUN: &str = "/run/systemd";
|
||||
pub const UNITS: &str = "/etc/systemd/system";
|
||||
pub const TRANSIENT: &str = "/run/systemd/transient";
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Unit {
|
||||
pub values: BTreeMap<String, String>,
|
||||
pub source: String,
|
||||
}
|
||||
impl Unit {
|
||||
pub fn parse(source: String) -> io::Result<Self> {
|
||||
let mut values = BTreeMap::new();
|
||||
let mut section = "";
|
||||
for line in source.lines().map(str::trim) {
|
||||
if line.is_empty() || line.starts_with(['#', ';']) {
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
section = &line[1..line.len() - 1];
|
||||
continue;
|
||||
}
|
||||
let (key, value) = line
|
||||
.split_once('=')
|
||||
.ok_or_else(|| io::Error::other("expected Key=Value"))?;
|
||||
let key = format!("{section}.{}", key.trim());
|
||||
if matches!(
|
||||
key.as_str(),
|
||||
"Unit.After"
|
||||
| "Unit.Before"
|
||||
| "Unit.Requires"
|
||||
| "Unit.Wants"
|
||||
| "Service.Environment"
|
||||
) {
|
||||
values
|
||||
.entry(key)
|
||||
.and_modify(|v: &mut String| {
|
||||
v.push(' ');
|
||||
v.push_str(value.trim());
|
||||
})
|
||||
.or_insert(value.trim().into());
|
||||
} else {
|
||||
values.insert(key, value.trim().into());
|
||||
}
|
||||
}
|
||||
if !values.contains_key("Service.ExecStart") {
|
||||
return Err(io::Error::other("missing ExecStart"));
|
||||
}
|
||||
Ok(Self { values, source })
|
||||
}
|
||||
pub fn get(&self, key: &str) -> &str {
|
||||
self.values.get(key).map(String::as_str).unwrap_or("")
|
||||
}
|
||||
pub fn words(&self, key: &str) -> Vec<String> {
|
||||
self.get(key).split_whitespace().map(name).collect()
|
||||
}
|
||||
pub fn seconds(&self, key: &str, default: f64) -> Duration {
|
||||
Duration::from_secs_f64(
|
||||
self.get(key)
|
||||
.trim_end_matches('s')
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|n| n.is_finite() && *n >= 0.0)
|
||||
.unwrap_or(default),
|
||||
)
|
||||
}
|
||||
}
|
||||
pub fn name(s: &str) -> String {
|
||||
if s.ends_with(".service") || s.ends_with(".target") {
|
||||
s.into()
|
||||
} else {
|
||||
format!("{s}.service")
|
||||
}
|
||||
}
|
||||
pub fn unit_path(name: &str) -> String {
|
||||
let persistent = format!("{UNITS}/{name}");
|
||||
if std::path::Path::new(&persistent).exists() {
|
||||
persistent
|
||||
} else {
|
||||
format!("{TRANSIENT}/{name}")
|
||||
}
|
||||
}
|
||||
pub fn enabled(name: &str) -> bool {
|
||||
["default.target", "multi-user.target"]
|
||||
.iter()
|
||||
.any(|target| std::path::Path::new(&format!("{UNITS}/{target}.wants/{name}")).exists())
|
||||
}
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct Status {
|
||||
pub state: String,
|
||||
pub pid: Option<u32>,
|
||||
pub code: Option<i32>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub action: String,
|
||||
pub unit: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Reply {
|
||||
pub code: i32,
|
||||
pub text: String,
|
||||
}
|
||||
pub fn atomic(path: &str, bytes: &[u8]) -> io::Result<()> {
|
||||
let tmp = format!("{path}.tmp");
|
||||
fs::write(&tmp, bytes)?;
|
||||
fs::rename(tmp, path)
|
||||
}
|
||||
pub fn request(action: &str, unit: &str) -> io::Result<Reply> {
|
||||
let id = format!(
|
||||
"{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let reply = format!("{RUN}/replies/{id}");
|
||||
let state = format!("{RUN}/state/{}", name(unit));
|
||||
let watcher = sausage_spawn::Watcher::new(&[reply.clone(), state.clone()]);
|
||||
atomic(
|
||||
&format!("{RUN}/requests/{id}.json"),
|
||||
&serde_json::to_vec(&Request {
|
||||
action: action.into(),
|
||||
unit: name(unit),
|
||||
})?,
|
||||
)?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(60);
|
||||
let mut acknowledged = false;
|
||||
loop {
|
||||
if !acknowledged && let Ok(bytes) = fs::read(&reply) {
|
||||
fs::remove_file(&reply)?;
|
||||
let reply: Reply = serde_json::from_slice(&bytes)?;
|
||||
if reply.code != 0 {
|
||||
return Ok(reply);
|
||||
}
|
||||
acknowledged = true;
|
||||
}
|
||||
if acknowledged && let Ok(bytes) = fs::read(&state) {
|
||||
let status: Status = serde_json::from_slice(&bytes)?;
|
||||
let done = if action == "stop" {
|
||||
status.state == "inactive"
|
||||
} else {
|
||||
status.state == "active"
|
||||
|| status.state == "failed"
|
||||
|| status.state == "inactive" && status.code.is_some()
|
||||
};
|
||||
if done {
|
||||
return Ok(Reply {
|
||||
code: if action != "stop" && status.state == "failed" {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
text: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"init did not reply",
|
||||
));
|
||||
}
|
||||
// The short timer also handles a missing or failed init.
|
||||
let _ = watcher.changes();
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn unit_sections_and_defaults() {
|
||||
let unit = Unit::parse("[Unit]\nAfter=prepare\nRequires=prepare\n[Service]\nType=oneshot\nExecStart=/bin/task.wasm\n".into()).unwrap();
|
||||
assert_eq!(unit.words("Unit.Requires"), ["prepare.service"]);
|
||||
assert_eq!(
|
||||
unit.seconds("Service.TimeoutStopSec", 10.),
|
||||
Duration::from_secs(10)
|
||||
);
|
||||
assert!(Unit::parse("[Unit]\nDescription=missing command".into()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Runs the world's unit state machine, journal drains, and file-based control requests.
|
||||
use sausage_init::*;
|
||||
use sausage_spawn::{Child, Command, Stdio};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
fs,
|
||||
io::{self, Write},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
struct Service {
|
||||
unit: Unit,
|
||||
child: Option<Child>,
|
||||
state: String,
|
||||
code: Option<i32>,
|
||||
desired: bool,
|
||||
starts: VecDeque<Instant>,
|
||||
next: Instant,
|
||||
stopping: Option<Instant>,
|
||||
}
|
||||
impl Service {
|
||||
fn new(unit: Unit) -> Self {
|
||||
Self {
|
||||
unit,
|
||||
child: None,
|
||||
state: "inactive".into(),
|
||||
code: None,
|
||||
desired: false,
|
||||
starts: VecDeque::new(),
|
||||
next: Instant::now(),
|
||||
stopping: None,
|
||||
}
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
self.desired = false;
|
||||
if let Some(child) = &mut self.child {
|
||||
child.stdin.take();
|
||||
self.stopping
|
||||
.get_or_insert(Instant::now() + self.unit.seconds("Service.TimeoutStopSec", 10.));
|
||||
self.state = "deactivating".into();
|
||||
} else {
|
||||
self.state = "inactive".into();
|
||||
}
|
||||
}
|
||||
fn start(&mut self) {
|
||||
let now = Instant::now();
|
||||
let interval = self.unit.seconds("Service.StartLimitIntervalSec", 10.);
|
||||
while self
|
||||
.starts
|
||||
.front()
|
||||
.is_some_and(|t| now.duration_since(*t) >= interval)
|
||||
{
|
||||
self.starts.pop_front();
|
||||
}
|
||||
let burst = self
|
||||
.unit
|
||||
.get("Service.StartLimitBurst")
|
||||
.parse()
|
||||
.unwrap_or(5);
|
||||
if burst != 0 && self.starts.len() >= burst {
|
||||
self.state = "failed".into();
|
||||
self.desired = false;
|
||||
return;
|
||||
}
|
||||
self.starts.push_back(now);
|
||||
let Some(argv) = shlex::split(self.unit.get("Service.ExecStart")).filter(|a| !a.is_empty())
|
||||
else {
|
||||
self.state = "failed".into();
|
||||
self.desired = false;
|
||||
return;
|
||||
};
|
||||
let mut command = Command::new(&argv[0]);
|
||||
command
|
||||
.args(&argv[1..])
|
||||
.new_group()
|
||||
.stdin(Stdio::Pipe)
|
||||
.stdout(Stdio::Pipe)
|
||||
.stderr(Stdio::Pipe);
|
||||
let cwd = self.unit.get("Service.WorkingDirectory");
|
||||
command.current_dir(if cwd.is_empty() { "/" } else { cwd });
|
||||
for assignment in shlex::split(self.unit.get("Service.Environment")).unwrap_or_default() {
|
||||
if let Some((key, value)) = assignment.split_once('=') {
|
||||
command.env(key, value);
|
||||
}
|
||||
}
|
||||
match command.spawn() {
|
||||
Ok(child) => {
|
||||
self.child = Some(child);
|
||||
self.state = if self.unit.get("Service.Type") == "oneshot" {
|
||||
"activating"
|
||||
} else {
|
||||
"active"
|
||||
}
|
||||
.into();
|
||||
self.code = None;
|
||||
}
|
||||
Err(_) => {
|
||||
self.code = Some(127);
|
||||
self.exited(127);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn exited(&mut self, code: i32) {
|
||||
self.code = Some(code);
|
||||
if self.stopping.take().is_some() {
|
||||
self.state = if self.desired {
|
||||
"activating"
|
||||
} else {
|
||||
"inactive"
|
||||
}
|
||||
.into();
|
||||
if self.desired {
|
||||
self.code = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.state = if code != 0 {
|
||||
"failed"
|
||||
} else if matches!(
|
||||
self.unit.get("Service.RemainAfterExit"),
|
||||
"yes" | "true" | "1"
|
||||
) {
|
||||
"active"
|
||||
} else {
|
||||
"inactive"
|
||||
}
|
||||
.into();
|
||||
let restart = self.unit.get("Service.Restart");
|
||||
if restart == "always" || restart == "on-failure" && code != 0 {
|
||||
self.next = Instant::now() + self.unit.seconds("Service.RestartSec", 0.1);
|
||||
} else {
|
||||
self.desired = false;
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, name: &str) {
|
||||
if let Some(child) = &mut self.child {
|
||||
for slot in [&mut child.stdout, &mut child.stderr] {
|
||||
if let Some(stream) = slot {
|
||||
loop {
|
||||
match stream.read(8192) {
|
||||
Ok(bytes) if !bytes.is_empty() => journal(name, &bytes),
|
||||
Ok(_) => break,
|
||||
Err(_) => {
|
||||
*slot = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if child.try_wait().is_none() && self.stopping.is_some_and(|t| Instant::now() >= t) {
|
||||
let _ = sausage_spawn::kill(child.id());
|
||||
}
|
||||
if let Some(code) = child.try_wait() {
|
||||
if child.stdout.is_none() && child.stderr.is_none() {
|
||||
self.child = None;
|
||||
self.exited(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn publish(&self, name: &str) {
|
||||
let status = Status {
|
||||
state: self.state.clone(),
|
||||
pid: self.child.as_ref().map(Child::id),
|
||||
code: self.code,
|
||||
};
|
||||
let bytes = serde_json::to_vec(&status).unwrap();
|
||||
let path = format!("{RUN}/state/{name}");
|
||||
if fs::read(&path).ok().as_deref() != Some(bytes.as_slice()) {
|
||||
let _ = atomic(&path, &bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn journal(name: &str, bytes: &[u8]) {
|
||||
let path = format!("/var/log/journal/{name}.log");
|
||||
if fs::metadata(&path).is_ok_and(|m| m.len() + bytes.len() as u64 > 1024 * 1024) {
|
||||
let _ = fs::rename(&path, format!("{path}.1"));
|
||||
}
|
||||
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = f.write_all(bytes);
|
||||
}
|
||||
}
|
||||
fn activate(name: &str, services: &mut BTreeMap<String, Service>, seen: &mut BTreeSet<String>) {
|
||||
if !seen.insert(name.into()) {
|
||||
return;
|
||||
}
|
||||
let Some(service) = services.get(name) else {
|
||||
return;
|
||||
};
|
||||
let deps = [
|
||||
service.unit.words("Unit.Requires"),
|
||||
service.unit.words("Unit.Wants"),
|
||||
]
|
||||
.concat();
|
||||
for dep in deps {
|
||||
activate(&dep, services, seen);
|
||||
}
|
||||
let service = services.get_mut(name).unwrap();
|
||||
service.desired = true;
|
||||
if service.child.is_none() {
|
||||
service.state = "activating".into();
|
||||
service.code = None;
|
||||
}
|
||||
service.next = Instant::now();
|
||||
}
|
||||
fn predecessors(name: &str, services: &BTreeMap<String, Service>) -> Vec<String> {
|
||||
let mut deps = services[name].unit.words("Unit.After");
|
||||
deps.extend(services[name].unit.words("Unit.Requires"));
|
||||
for (other, service) in services {
|
||||
if service.unit.words("Unit.Before").iter().any(|s| s == name) {
|
||||
deps.push(other.clone());
|
||||
}
|
||||
}
|
||||
deps
|
||||
}
|
||||
fn scan(services: &mut BTreeMap<String, Service>) {
|
||||
let mut found = BTreeMap::new();
|
||||
let mut present = BTreeSet::new();
|
||||
for dir in [TRANSIENT, UNITS] {
|
||||
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.ends_with(".service") {
|
||||
continue;
|
||||
}
|
||||
present.insert(name.clone());
|
||||
if let Ok(source) = fs::read_to_string(entry.path()) {
|
||||
if let Ok(unit) = Unit::parse(source) {
|
||||
found.insert(name, unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, service) in services.iter_mut() {
|
||||
if !present.contains(name) {
|
||||
service.stop();
|
||||
}
|
||||
}
|
||||
services.retain(|name, service| present.contains(name) || service.child.is_some());
|
||||
let mut new_enabled = Vec::new();
|
||||
for (name, unit) in found {
|
||||
if !services.contains_key(&name) && enabled(&name) {
|
||||
new_enabled.push(name.clone());
|
||||
}
|
||||
let service = services
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| Service::new(unit.clone()));
|
||||
if service.unit != unit {
|
||||
let desired = service.desired || service.state == "active";
|
||||
service.stop();
|
||||
service.unit = unit;
|
||||
service.desired = desired;
|
||||
service.starts.clear();
|
||||
service.next = Instant::now();
|
||||
}
|
||||
}
|
||||
for name in new_enabled {
|
||||
activate(&name, services, &mut BTreeSet::new());
|
||||
}
|
||||
}
|
||||
fn controls(services: &mut BTreeMap<String, Service>) {
|
||||
for entry in fs::read_dir(format!("{RUN}/requests"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
{
|
||||
let filename = entry.file_name().to_string_lossy().into_owned();
|
||||
if !filename.ends_with(".json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = fs::read(entry.path()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(request) = serde_json::from_slice::<Request>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let mut reply = Reply {
|
||||
code: 0,
|
||||
text: String::new(),
|
||||
};
|
||||
if !services.contains_key(&request.unit) {
|
||||
reply.code = 5;
|
||||
reply.text = "unit not found".into();
|
||||
} else {
|
||||
match request.action.as_str() {
|
||||
"start" => {
|
||||
services.get_mut(&request.unit).unwrap().starts.clear();
|
||||
activate(&request.unit, services, &mut BTreeSet::new());
|
||||
}
|
||||
"stop" => services.get_mut(&request.unit).unwrap().stop(),
|
||||
"restart" => {
|
||||
let service = services.get_mut(&request.unit).unwrap();
|
||||
service.stop();
|
||||
service.desired = true;
|
||||
if service.child.is_none() {
|
||||
service.state = "activating".into();
|
||||
service.code = None;
|
||||
}
|
||||
service.starts.clear();
|
||||
service.next = Instant::now();
|
||||
}
|
||||
_ => {
|
||||
reply.code = 1;
|
||||
reply.text = "unknown action".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(service) = services.get(&request.unit) {
|
||||
service.publish(&request.unit);
|
||||
}
|
||||
let id = filename.trim_end_matches(".json");
|
||||
let _ = atomic(
|
||||
&format!("{RUN}/replies/{id}"),
|
||||
&serde_json::to_vec(&reply).unwrap(),
|
||||
);
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
fn main() -> io::Result<()> {
|
||||
for dir in [
|
||||
UNITS,
|
||||
TRANSIENT,
|
||||
"/run/systemd/requests",
|
||||
"/run/systemd/replies",
|
||||
"/run/systemd/state",
|
||||
"/var/log/journal",
|
||||
] {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let watcher = sausage_spawn::Watcher::new(&["/".into()]);
|
||||
let stdin = wasi::cli::stdin::get_stdin();
|
||||
let mut services = BTreeMap::new();
|
||||
scan(&mut services);
|
||||
loop {
|
||||
if stdin.read(1).is_err() {
|
||||
break;
|
||||
}
|
||||
let changes = watcher.changes();
|
||||
if !changes.is_empty() {
|
||||
for service in services.values_mut() {
|
||||
let program = shlex::split(service.unit.get("Service.ExecStart"))
|
||||
.and_then(|a| a.first().cloned())
|
||||
.unwrap_or_default();
|
||||
if service.child.is_some()
|
||||
&& changes.iter().any(|c| {
|
||||
c.path == program || matches!(c.kind, sausage_spawn::ChangeKind::Overflow)
|
||||
})
|
||||
{
|
||||
service.stop();
|
||||
service.desired = true;
|
||||
service.next = Instant::now();
|
||||
}
|
||||
}
|
||||
scan(&mut services);
|
||||
}
|
||||
controls(&mut services);
|
||||
let names: Vec<_> = services.keys().cloned().collect();
|
||||
for name in &names {
|
||||
services.get_mut(name).unwrap().tick(name);
|
||||
}
|
||||
for name in &names {
|
||||
let service = &services[name];
|
||||
if !service.desired
|
||||
|| service.child.is_some()
|
||||
|| Instant::now() < service.next
|
||||
|| service.stopping.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let required = service.unit.words("Unit.Requires");
|
||||
if required
|
||||
.iter()
|
||||
.any(|dep| !services.contains_key(dep) || services[dep].state == "failed")
|
||||
{
|
||||
let service = services.get_mut(name).unwrap();
|
||||
service.state = "failed".into();
|
||||
service.desired = false;
|
||||
continue;
|
||||
}
|
||||
if predecessors(name, &services).iter().any(|dep| {
|
||||
services.get(dep).is_some_and(|s| {
|
||||
s.state == "activating" || s.desired && s.child.is_none() && s.code.is_none()
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
services.get_mut(name).unwrap().start();
|
||||
}
|
||||
for (name, service) in &services {
|
||||
service.publish(name);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
// Stop dependents before their prerequisites. A cycle stops together.
|
||||
while services.values().any(|s| s.child.is_some()) {
|
||||
let live: Vec<_> = services
|
||||
.iter()
|
||||
.filter(|(_, s)| s.child.is_some())
|
||||
.map(|(n, _)| n.clone())
|
||||
.collect();
|
||||
let mut leaves: Vec<_> = live
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
!live
|
||||
.iter()
|
||||
.any(|other| other != *name && predecessors(other, &services).contains(name))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if leaves.is_empty() {
|
||||
leaves = live;
|
||||
}
|
||||
for name in leaves {
|
||||
let service = services.get_mut(&name).unwrap();
|
||||
service.stop();
|
||||
service.tick(&name);
|
||||
service.publish(&name);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Acceptance guest for unit lifecycle and process-interface readiness tests.
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
};
|
||||
fn main() {
|
||||
let args: Vec<_> = std::env::args().collect();
|
||||
match args.get(1).map(String::as_str).unwrap_or("") {
|
||||
"kill-init" => {
|
||||
let table = fs::read_to_string("/proc/processes").unwrap();
|
||||
let row = table
|
||||
.lines()
|
||||
.find(|line| line.ends_with("/bin/init.wasm"))
|
||||
.unwrap();
|
||||
sausage_spawn::kill(row.split('\t').next().unwrap().parse().unwrap()).unwrap();
|
||||
}
|
||||
"watch" => {
|
||||
use sausage_spawn::{ChangeKind, Watcher};
|
||||
let watcher = Watcher::new(&["/run/watched".into()]);
|
||||
let poll = watcher.subscribe();
|
||||
assert!(!poll.ready());
|
||||
fs::create_dir_all("/run/watched").unwrap();
|
||||
poll.block();
|
||||
assert!(
|
||||
watcher
|
||||
.changes()
|
||||
.iter()
|
||||
.any(|c| matches!(c.kind, ChangeKind::Created))
|
||||
);
|
||||
fs::write("/run/watched/a", "a").unwrap();
|
||||
watcher.changes();
|
||||
fs::write("/run/watched/a", "b").unwrap();
|
||||
assert!(
|
||||
watcher
|
||||
.changes()
|
||||
.iter()
|
||||
.any(|c| c.path == "/run/watched/a" && matches!(c.kind, ChangeKind::Modified))
|
||||
);
|
||||
fs::remove_file("/run/watched/a").unwrap();
|
||||
assert!(
|
||||
watcher
|
||||
.changes()
|
||||
.iter()
|
||||
.any(|c| matches!(c.kind, ChangeKind::Removed))
|
||||
);
|
||||
for i in 0..300 {
|
||||
fs::write(format!("/run/watched/{i}"), "").unwrap();
|
||||
}
|
||||
assert!(
|
||||
watcher
|
||||
.changes()
|
||||
.iter()
|
||||
.any(|c| matches!(c.kind, ChangeKind::Overflow))
|
||||
);
|
||||
drop(poll);
|
||||
let mut cmd = sausage_spawn::Command::new("/bin/probe.wasm");
|
||||
cmd.arg("exit").arg("7");
|
||||
let child = cmd.spawn().unwrap();
|
||||
let exit = child.subscribe();
|
||||
exit.block();
|
||||
assert_eq!(child.try_wait(), Some(7));
|
||||
}
|
||||
"exit" => sausage_init::exit(args.get(2).and_then(|a| a.parse().ok()).unwrap_or(0)),
|
||||
"oneshot" | "server" | "crash" | "after" | "linger" => {
|
||||
if args[1] == "after" {
|
||||
assert!(
|
||||
!fs::read_to_string("/proc/processes")
|
||||
.unwrap()
|
||||
.contains("oneshot /order.log")
|
||||
);
|
||||
}
|
||||
let mut log = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&args[2])
|
||||
.unwrap();
|
||||
writeln!(log, "start").unwrap();
|
||||
for n in 1..=10 {
|
||||
println!("line {n}");
|
||||
}
|
||||
if args[1] == "linger" {
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
if args[1] == "crash" {
|
||||
std::process::exit(1);
|
||||
}
|
||||
if args[1] == "server" || args[1] == "after" {
|
||||
let _ = std::io::stdin().read_to_end(&mut Vec::new());
|
||||
writeln!(log, "stop").unwrap();
|
||||
}
|
||||
}
|
||||
_ => panic!("unknown probe mode"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Controls init and reads its public unit state files.
|
||||
use sausage_init::*;
|
||||
use std::{fs, io};
|
||||
fn run() -> io::Result<i32> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let action = args.first().map(String::as_str).unwrap_or("list-units");
|
||||
if action == "daemon-reload" {
|
||||
return Ok(0);
|
||||
}
|
||||
if action == "list-units" {
|
||||
for entry in fs::read_dir(format!("{RUN}/state"))? {
|
||||
let entry = entry?;
|
||||
let status: Status = serde_json::from_slice(&fs::read(entry.path())?)?;
|
||||
println!("{} {}", entry.file_name().to_string_lossy(), status.state);
|
||||
}
|
||||
return Ok(0);
|
||||
}
|
||||
let now = args.iter().any(|a| a == "--now");
|
||||
let units: Vec<_> = args
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|a| !a.starts_with('-'))
|
||||
.map(|a| name(a))
|
||||
.collect();
|
||||
if units.is_empty() {
|
||||
return Ok(1);
|
||||
}
|
||||
let mut result = 0;
|
||||
for unit in units {
|
||||
let status = fs::read(format!("{RUN}/state/{unit}"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Status>(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let code = match action {
|
||||
"is-active" => {
|
||||
println!(
|
||||
"{}",
|
||||
if status.state.is_empty() {
|
||||
"unknown"
|
||||
} else {
|
||||
&status.state
|
||||
}
|
||||
);
|
||||
if status.state == "active" { 0 } else { 3 }
|
||||
}
|
||||
"is-failed" => {
|
||||
println!(
|
||||
"{}",
|
||||
if status.state.is_empty() {
|
||||
"unknown"
|
||||
} else {
|
||||
&status.state
|
||||
}
|
||||
);
|
||||
if status.state == "failed" { 0 } else { 1 }
|
||||
}
|
||||
"is-enabled" => {
|
||||
println!(
|
||||
"{}",
|
||||
if enabled(&unit) {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
);
|
||||
if enabled(&unit) { 0 } else { 1 }
|
||||
}
|
||||
"status" => {
|
||||
println!("{unit}: {}", status.state);
|
||||
if status.state == "active" {
|
||||
0
|
||||
} else if status.state.is_empty() {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
"cat" => {
|
||||
print!("{}", fs::read_to_string(unit_path(&unit))?);
|
||||
0
|
||||
}
|
||||
"enable" | "disable" => {
|
||||
let spec = Unit::parse(fs::read_to_string(unit_path(&unit))?)?;
|
||||
let targets: Vec<_> = spec
|
||||
.get("Install.WantedBy")
|
||||
.split_whitespace()
|
||||
.filter(|t| matches!(*t, "default.target" | "multi-user.target"))
|
||||
.collect();
|
||||
if action == "enable" && targets.is_empty() {
|
||||
return Ok(1);
|
||||
}
|
||||
for target in ["default.target", "multi-user.target"] {
|
||||
let dir = format!("{UNITS}/{target}.wants");
|
||||
let path = format!("{dir}/{unit}");
|
||||
if action == "enable" && targets.contains(&target) {
|
||||
fs::create_dir_all(dir)?;
|
||||
fs::write(path, b"")?;
|
||||
} else if action == "disable" {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
if now {
|
||||
request(if action == "enable" { "start" } else { "stop" }, &unit)?.code
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
"start" | "stop" | "restart" => {
|
||||
let reply = request(action, &unit)?;
|
||||
if !reply.text.is_empty() {
|
||||
println!("{}", reply.text);
|
||||
}
|
||||
reply.code
|
||||
}
|
||||
_ => 1,
|
||||
};
|
||||
result = result.max(code);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
fn main() {
|
||||
sausage_init::exit(run().unwrap_or(1));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Creates transient unit files and optionally waits for their final state.
|
||||
use sausage_init::*;
|
||||
use std::{fs, io, time::Duration};
|
||||
fn run() -> io::Result<i32> {
|
||||
let mut args = std::env::args().skip(1).peekable();
|
||||
let mut unit = format!(
|
||||
"run-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
);
|
||||
let (mut wait, mut properties, mut command) = (false, Vec::new(), Vec::new());
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--unit" => {
|
||||
unit = args
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::other("missing unit"))?
|
||||
}
|
||||
"--wait" => wait = true,
|
||||
"-p" => properties.push(
|
||||
args.next()
|
||||
.ok_or_else(|| io::Error::other("missing property"))?,
|
||||
),
|
||||
"--working-directory" => properties.push(format!(
|
||||
"WorkingDirectory={}",
|
||||
args.next()
|
||||
.ok_or_else(|| io::Error::other("missing directory"))?
|
||||
)),
|
||||
"-E" => properties.push(format!(
|
||||
"Environment={}",
|
||||
args.next()
|
||||
.ok_or_else(|| io::Error::other("missing environment"))?
|
||||
)),
|
||||
"--" => {
|
||||
command.extend(args);
|
||||
break;
|
||||
}
|
||||
_ if arg.starts_with("--unit=") => unit = arg[7..].into(),
|
||||
_ if arg.starts_with('-') => return Ok(1),
|
||||
_ => {
|
||||
command.push(arg);
|
||||
command.extend(args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if command.is_empty() {
|
||||
return Ok(1);
|
||||
}
|
||||
let unit = name(&unit);
|
||||
let mut text = format!(
|
||||
"[Service]\nExecStart={}\n",
|
||||
shlex::try_join(command.iter().map(String::as_str)).map_err(io::Error::other)?
|
||||
);
|
||||
for property in properties {
|
||||
text.push_str(&property);
|
||||
text.push('\n');
|
||||
}
|
||||
fs::create_dir_all(TRANSIENT)?;
|
||||
atomic(&format!("{TRANSIENT}/{unit}"), text.as_bytes())?;
|
||||
let reply = request("start", &unit)?;
|
||||
if reply.code != 0 {
|
||||
return Ok(reply.code);
|
||||
}
|
||||
if wait {
|
||||
loop {
|
||||
if let Ok(bytes) = fs::read(format!("{RUN}/state/{unit}")) {
|
||||
let status: Status = serde_json::from_slice(&bytes)?;
|
||||
if let Some(code) = status.code {
|
||||
return Ok(code);
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
fn main() {
|
||||
sausage_init::exit(run().unwrap_or(1));
|
||||
}
|
||||
Reference in New Issue
Block a user