use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs::{self, File};
use std::io::{ErrorKind, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use tokio::task;
use crate::config::EgressEndpoint;
use crate::daemon::api::{MachineStart, MachineStatus, MachineStop};
use crate::daemon::egress;
use crate::image::Image;
use crate::machine::{self, Engine, GUEST_SHELL, Machine, State};
use crate::{Error, Result};
pub(super) const MACHINE_DIR_NAME: &str = "machines";
const MACHINE_RECORD_FILE_MODE: u32 = 0o600;
const ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(5);
const BROADCAST: &str = r#"printf '%s' "$1" | tee /dev/pts/[0-9]* >/dev/null 2>&1"#;
const SCRIPT_FLAG: &str = "-c";
const HANG_UP: &str = "kill -s HUP -1";
const REMOTE_SCHEMES: [&str; 4] = ["s3://", "gs://", "r2://", "rclone://"];
#[derive(Default)]
struct Claims {
closed: bool,
names: HashMap<String, State>,
}
fn claims(inflight: &Mutex<Claims>) -> MutexGuard<'_, Claims> {
inflight.lock().unwrap_or_else(PoisonError::into_inner)
}
struct InFlight<'a> {
inflight: &'a Mutex<Claims>,
name: String,
}
impl Drop for InFlight<'_> {
fn drop(&mut self) {
claims(self.inflight).names.remove(&self.name);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct Marker {
pub(super) egress: MarkerEgress,
pub(super) spec: machine::Spec,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct MarkerEgress {
pub(super) token: egress::Token,
}
pub(super) struct Machines {
dir: PathBuf,
inflight: Mutex<Claims>,
lock: Arc<File>,
egress: Arc<egress::State>,
}
impl Machines {
pub(super) fn new(state_dir: &Path, lock: File, egress: Arc<egress::State>) -> Self {
Self {
dir: state_dir.join(MACHINE_DIR_NAME),
inflight: Mutex::default(),
lock: Arc::new(lock),
egress,
}
}
pub(super) async fn status<E: Engine>(&self, name: String) -> Result<MachineStatus> {
let marker = machine_marker(&self.dir, &name)?;
let inflight = claims(&self.inflight).names.get(&name).copied();
if let Some(state) = inflight {
return Ok(MachineStatus {
status: status_without_pid(name, state),
marker: read_marker(&marker)?.map(|stored| stored.spec),
});
}
blocking(move || {
if !marker_exists(&marker)? {
return Err(Error::MachineNotFound { name });
}
let spec = read_marker(&marker)?.map(|stored| stored.spec);
let status = E::Machine::attach(&name)?.status()?;
Ok(MachineStatus {
status,
marker: spec,
})
})
.await
}
pub(super) async fn start<E: Engine>(
&self,
name: String,
spec: machine::Spec,
) -> Result<MachineStart> {
let marker = machine_marker(&self.dir, &name)?;
validate_mounts(&spec)?;
let _claim = self.claim(&name, State::Starting)?;
let lock = Arc::clone(&self.lock);
let egress = Arc::clone(&self.egress);
blocking(move || {
let _lock = lock;
if marker_exists(&marker)? {
return start_existing::<E>(&name, &marker, spec, &egress);
}
let spec = pin::<E>(spec)?;
let token = egress::Token::mint()?;
let minted = Marker {
egress: MarkerEgress { token },
spec,
};
write_marker(&marker, &minted)?;
let binding = egress.register(&name, &minted);
boot::<E>(&name, &marker, minted.spec, &egress, &binding)
})
.await
}
pub(super) async fn stop<E: Engine>(&self, name: String) -> Result<MachineStop> {
let marker = machine_marker(&self.dir, &name)?;
let _claim = self.claim(&name, State::Stopping)?;
let lock = Arc::clone(&self.lock);
let egress = Arc::clone(&self.egress);
blocking(move || {
let _lock = lock;
if !marker_exists(&marker)? {
return Ok(MachineStop::NotRunning);
}
let machine = E::Machine::attach(&name)?;
drop(machine.exec(
&broadcast(&name, &format!("mchn machine stop {name}")),
ANNOUNCE_TIMEOUT,
));
if destroy(machine, &marker, &egress, &name)? {
Ok(MachineStop::Stopped)
} else {
Ok(MachineStop::NotRunning)
}
})
.await
}
pub(super) async fn list<E: Engine>(&self) -> Result<Vec<machine::Status>> {
let inflight = claims(&self.inflight).names.clone();
let mut statuses: Vec<machine::Status> = inflight
.iter()
.map(|(name, &state)| status_without_pid(name.clone(), state))
.collect();
let queries: Vec<_> = self
.names()?
.into_iter()
.filter(|name| !inflight.contains_key(name))
.map(|name| {
let handle = task::spawn_blocking({
let name = name.clone();
move || E::Machine::attach(&name)?.status()
});
(name, handle)
})
.collect();
for (name, handle) in queries {
let status = match handle.await {
Ok(Ok(status)) => status,
Ok(Err(_)) | Err(_) => status_without_pid(name, State::Unknown),
};
statuses.push(status);
}
statuses.sort_by(|a, b| a.name.cmp(&b.name));
Ok(statuses)
}
pub(super) fn close(&self, force: bool) -> Result<usize> {
let mut inflight = claims(&self.inflight);
let mut machines: HashSet<String> = self.names()?.into_iter().collect();
machines.extend(inflight.names.keys().cloned());
let count = machines.len();
if force || count == 0 {
inflight.closed = true;
drop(inflight); }
Ok(count)
}
pub(super) async fn stop_all<E: Engine>(
&self,
timeout: Duration,
) -> Result<Vec<(String, Result<()>)>> {
let inflight = claims(&self.inflight).names.clone();
let mut outcomes = Vec::new();
let mut stops = Vec::new();
for name in self.names()? {
if let Some(&state) = inflight.get(&name) {
let busy = Error::MachineBusy {
name: name.clone(),
state,
};
outcomes.push((name, Err(busy)));
continue;
}
let marker = self.dir.join(&name);
let lock = Arc::clone(&self.lock);
let egress = Arc::clone(&self.egress);
let handle = task::spawn_blocking({
let name = name.clone();
move || -> Result<()> {
let _lock = lock;
let machine = E::Machine::attach(&name)?;
drop(machine.exec(
&broadcast(&name, "the Moon MCHN daemon is stopping"),
ANNOUNCE_TIMEOUT,
));
destroy(machine, &marker, &egress, &name)?;
Ok(())
}
});
stops.push((name, handle));
}
let deadline = tokio::time::sleep(timeout).deadline();
for (name, handle) in stops {
let outcome = match tokio::time::timeout_at(deadline, handle).await {
Ok(joined) => joined.unwrap_or_else(|error| Err(join_error(&error))),
Err(_) => Err(Error::MachineBusy {
name: name.clone(),
state: State::Stopping,
}),
};
outcomes.push((name, outcome));
}
Ok(outcomes)
}
pub(super) fn resolve(&self, name: String) -> Result<()> {
let marker = machine_marker(&self.dir, &name)?;
let inflight = claims(&self.inflight).names.get(&name).copied();
if let Some(state) = inflight {
return Err(Error::MachineBusy { name, state });
}
if !marker_exists(&marker)? {
return Err(Error::MachineNotFound { name });
}
Ok(())
}
fn names(&self) -> Result<Vec<String>> {
let mut names = Vec::new();
for entry in fs::read_dir(&self.dir).map_err(|e| Error::io(&self.dir, e))? {
let entry = entry.map_err(|e| Error::io(&self.dir, e))?;
let name = entry.file_name().into_string();
if let Ok(marker) = name {
if validate_name(marker.as_ref()).is_err() {
continue;
}
names.push(marker);
}
}
Ok(names)
}
fn claim(&self, name: &str, state: State) -> Result<InFlight<'_>> {
let mut inflight = claims(&self.inflight);
if inflight.closed {
return Err(Error::DaemonStopping);
}
if let Some(&present) = inflight.names.get(name) {
return Err(Error::MachineBusy {
name: name.to_string(),
state: present,
});
}
inflight.names.insert(name.to_string(), state);
drop(inflight);
Ok(InFlight {
inflight: &self.inflight,
name: name.to_string(),
})
}
}
fn pin<E: Engine>(mut spec: machine::Spec) -> Result<machine::Spec> {
let image = &spec.image;
let Some(pinned) = &image.digest else {
spec.image.digest = Some(E::image_digest(&spec.image.name)?);
return Ok(spec);
};
let found = match &image.name {
Image::Archive(_) => Some(E::image_digest(&image.name)?),
Image::Registry(reference) => reference.digest().cloned(),
};
match found {
Some(found) if found != *pinned => Err(Error::ImageDigestMismatch {
image: image.name.to_string(),
pinned: pinned.clone(),
found,
}),
_ => Ok(spec),
}
}
fn boot<E: Engine>(
name: &str,
marker: &Path,
spec: machine::Spec,
egress: &egress::State,
binding: &EgressEndpoint,
) -> Result<MachineStart> {
let machine = E::Machine::start(name, spec, binding).inspect_err(|e| {
if !matches!(e, Error::MachineLeftover { .. }) {
drop(remove_marker(marker));
egress.retire(name);
}
})?;
Ok(MachineStart::Started(machine.status()?))
}
fn start_existing<E: Engine>(
name: &str,
marker: &Path,
spec: machine::Spec,
egress: &egress::State,
) -> Result<MachineStart> {
let machine = E::Machine::attach(name)?;
let status = machine.status()?;
match status.state {
State::Running | State::Starting | State::Paused => {
reuse(&machine, status, name, marker, &spec)
}
State::Stopping => Err(Error::MachineBusy {
name: name.to_string(),
state: status.state,
}),
State::Failed => {
let spec = pin::<E>(spec)?;
let token = egress::Token::mint()?;
let minted = Marker {
egress: MarkerEgress { token },
spec,
};
destroy(machine, marker, egress, name)?;
write_marker(marker, &minted)?;
let binding = egress.register(name, &minted);
boot::<E>(name, marker, minted.spec, egress, &binding)
}
State::Unknown => Err(Error::MachineUnknown {
name: name.to_string(),
}),
}
}
fn reuse<M: Machine>(
machine: &M,
status: machine::Status,
name: &str,
marker: &Path,
spec: &machine::Spec,
) -> Result<MachineStart> {
if !read_marker(marker)?.is_some_and(|stored| stored.spec.satisfies(spec)) {
return Err(Error::MachineConfigMismatch {
name: name.to_string(),
});
}
if status.state == State::Paused {
machine.resume()?;
return Ok(MachineStart::Started(machine.status()?));
}
Ok(MachineStart::AlreadyRunning(status))
}
fn destroy<M: Machine>(
machine: M,
marker: &Path,
egress: &egress::State,
name: &str,
) -> Result<bool> {
let stopped = match machine.stop() {
Ok(()) => true,
Err(Error::MachineNotFound { .. }) => false,
Err(e) => return Err(e),
};
remove_marker(marker)?;
egress.retire(name);
Ok(stopped)
}
fn status_without_pid(name: String, state: State) -> machine::Status {
machine::Status {
name,
state,
pid: None,
}
}
fn write_marker(path: &Path, marker: &Marker) -> Result<()> {
let contents = toml::to_string_pretty(marker).map_err(Error::ConfigSerialization)?;
let dir = path
.parent()
.ok_or_else(|| Error::io(path, ErrorKind::InvalidInput.into()))?;
let mut file = tempfile::Builder::new()
.permissions(fs::Permissions::from_mode(MACHINE_RECORD_FILE_MODE))
.tempfile_in(dir)
.map_err(|e| Error::io(path, e))?;
file.write_all(contents.as_bytes())
.map_err(|e| Error::io(path, e))?;
file.persist_noclobber(path)
.map_err(|e| Error::io(path, e.error))?;
Ok(())
}
pub(super) fn read_marker(path: &Path) -> Result<Option<Marker>> {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(Error::io(path, e)),
};
if contents.trim().is_empty() {
return Ok(None);
}
let marker: Marker = toml::from_str(&contents).map_err(|e| Error::ConfigDeserialization {
path: path.to_path_buf(),
source: e,
})?;
Ok(Some(marker))
}
fn remove_marker(path: &Path) -> Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
Err(e) => Err(Error::io(path, e)),
}
}
fn marker_exists(path: &Path) -> Result<bool> {
match path.try_exists() {
Ok(exists) => Ok(exists),
Err(err) if err.kind() == ErrorKind::InvalidFilename => Ok(false),
Err(e) => Err(Error::io(path, e)),
}
}
pub(super) fn machine_marker(dir: &Path, name: &str) -> Result<PathBuf> {
validate_name(name)?;
Ok(dir.join(name))
}
fn broadcast(name: &str, reason: &str) -> Vec<String> {
let message = format!(
"\r\nBroadcast message from moonmchn:\r\n\r\n\
Machine {name} is stopping now: {reason}.\r\n\r\n"
);
vec![
GUEST_SHELL.into(),
SCRIPT_FLAG.into(),
format!("{BROADCAST}; {HANG_UP}"),
GUEST_SHELL.into(),
message,
]
}
fn validate_name(name: &str) -> Result<()> {
if !(name.starts_with(is_name_start) && name.chars().all(is_name_char)) {
return Err(Error::InvalidMachineName {
name: name.to_string(),
});
}
Ok(())
}
fn is_name_start(c: char) -> bool {
matches!(c, 'a'..='z' | '0'..='9')
}
fn is_name_char(c: char) -> bool {
is_name_start(c) || matches!(c, '_' | '.' | '-')
}
fn validate_mounts(spec: &machine::Spec) -> Result<()> {
for mount in &spec.mounts {
let remote = REMOTE_SCHEMES
.iter()
.any(|scheme| mount.source.starts_with(scheme));
if !remote && !Path::new(&mount.source).is_absolute() {
return Err(Error::InvalidMountSource {
path: mount.source.clone(),
});
}
}
Ok(())
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> Result<T> + Send + 'static) -> Result<T> {
task::spawn_blocking(f)
.await
.map_err(|error| join_error(&error))?
}
fn join_error(error: &tokio::task::JoinError) -> Error {
Error::Engine {
message: error.to_string(),
}
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::MetadataExt;
use super::*;
use crate::daemon::{instance, state};
use crate::image::Digest;
#[test]
fn a_marker_writes_its_egress_table_above_the_spec() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("alpha");
let marker = Marker {
egress: MarkerEgress {
token: egress::Token([0xaa; 32]),
},
spec: machine::Spec {
image: machine::ImageSpec {
name: Image::parse("ghcr.io/org/name:latest").unwrap(),
digest: Some(Digest::parse(&format!("sha256:{}", "a".repeat(64))).unwrap()),
},
..machine::Spec::default()
},
};
write_marker(&path, &marker).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(
text.starts_with(&format!(
"[egress]\ntoken = \"{}\"\n\n[spec]\n",
"a".repeat(64)
)),
"{text}"
);
assert!(
text.contains(&format!(
"[spec.image]\nname = \"ghcr.io/org/name:latest\"\ndigest = \"sha256:{}\"\n",
"a".repeat(64)
)),
"{text}"
);
assert_eq!(read_marker(&path).unwrap(), Some(marker));
let unknown = [
format!("extra = 1\n{text}"),
text.replacen("[egress]\n", "[egress]\nextra = 1\n", 1),
];
for text in unknown {
fs::write(&path, &text).unwrap();
assert!(
matches!(read_marker(&path), Err(Error::ConfigDeserialization { .. })),
"{text}"
);
}
}
#[test]
fn write_marker_refuses_to_clobber_and_leaves_no_temporary_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("alpha");
let marker = Marker {
egress: MarkerEgress {
token: egress::Token([0xaa; 32]),
},
spec: machine::Spec::default(),
};
write_marker(&path, &marker).unwrap();
let again = write_marker(&path, &marker);
assert!(matches!(
again,
Err(Error::Io { source, .. }) if source.kind() == ErrorKind::AlreadyExists
));
assert_eq!(read_marker(&path).unwrap(), Some(marker));
assert_eq!(fs::metadata(&path).unwrap().mode() & 0o7777, 0o600);
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn validates_names() {
let valid_names = [
"a", "z", "0", "9", "alpha", "a-b_c.d", "0x", "a_", "a.", "a-",
];
for name in valid_names {
assert!(validate_name(name).is_ok(), "valid name: {name}");
}
let invalid_names = [
"", "-a", ".a", "_a", "a/b", "a b", "..", "é", "Alpha", "ALPHA", "aB", "aé", "a1",
"a\\b", "a\n", "a\t", "a\0", "a@", " a",
];
for name in invalid_names {
assert!(
matches!(
validate_name(name),
Err(Error::InvalidMachineName { name: invalid }) if invalid == name
),
"invalid name: {name:?}"
);
}
}
#[test]
fn a_forced_close_refuses_later_claims() {
let dir = tempfile::tempdir().unwrap();
let machines = registry(dir.path());
assert_eq!(machines.close(true).unwrap(), 0);
assert!(matches!(
machines.claim("a", State::Starting),
Err(Error::DaemonStopping)
));
}
#[test]
fn close_counts_records_and_claims_once_and_keeps_admitting() {
let dir = tempfile::tempdir().unwrap();
let machines = registry(dir.path());
fs::write(machines.dir.join("a"), "").unwrap();
let _a = machines.claim("a", State::Starting).unwrap();
let _b = machines.claim("b", State::Starting).unwrap();
assert_eq!(machines.close(false).unwrap(), 2);
assert!(machines.claim("c", State::Starting).is_ok());
}
#[test]
fn close_with_nothing_running_closes() {
let dir = tempfile::tempdir().unwrap();
let machines = registry(dir.path());
assert_eq!(machines.close(false).unwrap(), 0);
assert!(matches!(
machines.claim("a", State::Starting),
Err(Error::DaemonStopping)
));
}
#[test]
fn broadcast_is_sh_with_the_message_as_its_argument() {
let command = broadcast("alpha", "mchn machine stop alpha");
assert_eq!(
command,
[
"sh",
"-c",
r#"printf '%s' "$1" | tee /dev/pts/[0-9]* >/dev/null 2>&1; kill -s HUP -1"#,
"sh",
"\r\nBroadcast message from moonmchn:\r\n\r\nMachine alpha is stopping now: mchn machine stop alpha.\r\n\r\n",
]
);
}
fn registry(dir: &Path) -> Machines {
let uid = rustix::process::geteuid().as_raw();
let dir = state::prepare_in(&dir.join("state"), uid).unwrap();
let lock = instance::claim(&dir).unwrap();
let egress = Arc::new(egress::State::bind(&dir).unwrap());
Machines::new(&dir, lock, egress)
}
}