//! On-disk layout of a Sausage world. The folder is the source of truth: //! `sausage run` boots every actor that it finds in `actor/`. //! //! ```text //! / //! actor// //! actor.wasm the current version //! actor.json version, running, subscriptions (broker) //! source/ source of the current version //! public/ written by the actor, read-only for others //! data/ state, writable only by the actor //! stderr.log stderr of the actor and notes of the broker //! versions// every version that was installed: actor.wasm, source/, meta.json //! ``` use std::collections::BTreeSet; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; use crate::fs::TMP_PREFIX; /// IDs of the actors that the host implements natively. pub const NATIVE: &[&str] = &["broker", "console", "cargo"]; #[derive(Clone)] pub struct World { pub root: PathBuf, } /// `actor//actor.json`. #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct ActorMeta { /// The installed version. `None` for native actors and for a new /// `actor.wasm` that the broker has not installed yet. #[serde(default)] pub version: Option, #[serde(default = "yes")] pub running: bool, /// The actors whose published messages this actor receives. #[serde(default)] pub subscriptions: BTreeSet, } fn yes() -> bool { true } /// `versions//meta.json`. #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct VersionMeta { pub version: u64, /// Path of the `.wasm` file, as the requester gave it. pub wasm: String, /// Path of the source folder, as the requester gave it. pub source: Option, /// The actor that built the `.wasm` file. pub built_by: Option, /// The actor that requested the version ("scan" for a file found on disk). pub requested_by: String, pub created_at: u64, } pub fn valid_id(id: &str) -> bool { !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') } pub fn now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } impl World { pub fn new(root: impl Into) -> Self { Self { root: root.into() } } pub fn actors_root(&self) -> PathBuf { self.root.join("actor") } pub fn actor_dir(&self, id: &str) -> PathBuf { self.actors_root().join(id) } pub fn wasm_path(&self, id: &str) -> PathBuf { self.actor_dir(id).join("actor.wasm") } pub fn public_dir(&self, id: &str) -> PathBuf { self.actor_dir(id).join("public") } pub fn source_dir(&self, id: &str) -> PathBuf { self.actor_dir(id).join("source") } pub fn data_dir(&self, id: &str) -> PathBuf { self.actor_dir(id).join("data") } pub fn versions_dir(&self, id: &str) -> PathBuf { self.actor_dir(id).join("versions") } pub fn version_dir(&self, id: &str, v: u64) -> PathBuf { self.versions_dir(id).join(v.to_string()) } /// Private scratch space of the broker (candidate copies, seeds). pub fn scratch_dir(&self) -> PathBuf { self.data_dir("broker").join("scratch") } pub fn exists(&self) -> bool { self.actors_root().is_dir() } /// Create an empty world with the folders of the native actors. pub fn create(root: &Path) -> Result { let world = World::new(std::path::absolute(root)?); if world.exists() { bail!("{} is already a Sausage world", root.display()); } for id in NATIVE { world.ensure_actor_dirs(id)?; } Ok(world) } pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> { std::fs::create_dir_all(self.public_dir(id))?; std::fs::create_dir_all(self.data_dir(id))?; Ok(()) } /// IDs of every folder in `actor/`, sorted. pub fn actor_ids(&self) -> Result> { let mut ids: Vec = std::fs::read_dir(self.actors_root())? .filter_map(|e| e.ok()) .filter(|e| e.path().is_dir()) .filter_map(|e| e.file_name().into_string().ok()) .filter(|id| valid_id(id)) .collect(); ids.sort(); Ok(ids) } pub fn read_meta(&self, id: &str) -> ActorMeta { std::fs::read_to_string(self.actor_dir(id).join("actor.json")) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or(ActorMeta { running: true, ..Default::default() }) } pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> { write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes()) } /// Make `actor.wasm` and `source/` a copy of `versions//`, /// unless `actor.wasm` already is that version (it came from there). pub fn install_files(&self, id: &str, version: u64) -> Result<()> { let dir = self.version_dir(id, version); self.ensure_actor_dirs(id)?; let wasm = std::fs::read(dir.join("actor.wasm"))?; if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) { return Ok(()); } write_atomic(&self.wasm_path(id), &wasm)?; replace_tree(&dir.join("source"), &self.source_dir(id)) } /// Version numbers in `versions/`, ascending. pub fn versions(&self, id: &str) -> Vec { let mut v: Vec = std::fs::read_dir(self.versions_dir(id)) .map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect()) .unwrap_or_default(); v.sort(); v } pub fn next_version(&self, id: &str) -> u64 { self.versions(id).last().map_or(1, |v| v + 1) } /// Map a path in an actor's namespace to a host path. /// /// - `/actor//...` is the global filesystem. /// - `/public/...` and `/data/...` belong to `sender`. /// - Native senders (the console) can also give absolute host paths. pub fn resolve(&self, sender: &str, path: &str) -> Result { let under = |base: PathBuf, rest: &str| -> Result { let mut out = base; for c in Path::new(rest).components() { match c { Component::Normal(n) => out.push(n), Component::CurDir | Component::RootDir => {} _ => bail!("path must not contain '..': {path}"), } } Ok(out) }; let prefixed = |prefix: &str| { path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/')) }; if let Some(rest) = prefixed("/actor") { return under(self.actors_root(), rest); } if let Some(rest) = prefixed("/public") { return under(self.public_dir(sender), rest); } if let Some(rest) = prefixed("/data") { return under(self.data_dir(sender), rest); } if NATIVE.contains(&sender) && Path::new(path).is_absolute() { return Ok(PathBuf::from(path)); } bail!("cannot resolve path {path} for {sender}: use /actor//..., /public/... or /data/...") } /// The path of a host file in the global filesystem, if it has one. pub fn global_path(&self, host: &Path) -> Option { let rel = host.strip_prefix(self.actors_root()).ok()?; Some(format!("/actor/{}", rel.display())) } } /// Write a file so that readers never see a partial version. pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { let dir = path.parent().context("path has no parent")?; std::fs::create_dir_all(dir)?; let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id())); std::fs::write(&tmp, bytes)?; std::fs::rename(&tmp, path)?; Ok(()) } /// Folders that a copy of a source folder leaves out. const BUILD_DIRS: &[&str] = &["target", ".git"]; /// Copy a directory tree, except uncommitted files. pub fn copy_tree(from: &Path, to: &Path) -> Result<()> { copy_filtered(from, to, &[]) } fn copy_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? { let entry = entry?; let name = entry.file_name(); let name = name.to_string_lossy(); if name.starts_with(TMP_PREFIX) { continue; } let dst = to.join(&*name); let ty = entry.file_type()?; if ty.is_dir() && !skip.contains(&&*name) { copy_filtered(&entry.path(), &dst, skip)?; } else if ty.is_file() { std::fs::copy(entry.path(), &dst)?; } } Ok(()) } /// Replace the directory `dst` with a copy of `src` (an empty folder if `src` /// does not exist): copy to a sibling, then swap with two renames. pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> { replace_filtered(src, dst, &[]) } /// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`. pub fn replace_source(src: &Path, dst: &Path) -> Result<()> { replace_filtered(src, dst, BUILD_DIRS) } fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> { let parent = dst.parent().context("path has no parent")?; let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir"); let fresh = parent.join(format!("{TMP_PREFIX}new-{name}")); let old = parent.join(format!("{TMP_PREFIX}old-{name}")); let _ = std::fs::remove_dir_all(&fresh); let _ = std::fs::remove_dir_all(&old); if src.is_dir() { copy_filtered(src, &fresh, skip)?; } else { std::fs::create_dir_all(&fresh)?; } if dst.exists() { std::fs::rename(dst, &old)?; } std::fs::rename(&fresh, dst)?; let _ = std::fs::remove_dir_all(&old); Ok(()) }