Sausage: host, broker, commit-on-close filesystem, cargo actor, console, agent
Imported from the spike branch, with: - a library crate (Host API) for the CLI and the tests - JSON test scenarios for candidates - commit on close only for new or truncated files - a Nix flake: nix run -- init, nix run -- run
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
//! On-disk layout of a Sausage workspace ("world").
|
||||
//!
|
||||
//! ```text
|
||||
//! <world>/
|
||||
//! sausage.json registry: actors, versions, subscriptions
|
||||
//! actor/<id>/public/ written by the actor, read-only for others
|
||||
//! actor/<id>/source/ source of the running version (broker)
|
||||
//! actor/<id>/status.json state of the actor (broker)
|
||||
//! actor/<id>/stderr.log stderr of the actor (broker)
|
||||
//! private/<id>/data/ private state of the actor
|
||||
//! broker/versions/<id>/<n>/ actor.wasm, source/, meta.json, snapshot/
|
||||
//! archive/ removed actors
|
||||
//! ```
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{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,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||
pub struct Registry {
|
||||
pub actors: BTreeMap<String, ActorRecord>,
|
||||
/// Publisher ID → subscriber IDs.
|
||||
pub subscriptions: BTreeMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ActorRecord {
|
||||
pub version: u64,
|
||||
pub next_version: u64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
/// The actor that built the `.wasm` file.
|
||||
pub built_by: Option<String>,
|
||||
/// The actor that requested the spawn or upgrade.
|
||||
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<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn registry_path(&self) -> PathBuf {
|
||||
self.root.join("sausage.json")
|
||||
}
|
||||
pub fn actor_dir(&self, id: &str) -> PathBuf {
|
||||
self.root.join("actor").join(id)
|
||||
}
|
||||
pub fn actors_root(&self) -> PathBuf {
|
||||
self.root.join("actor")
|
||||
}
|
||||
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.root.join("private").join(id).join("data")
|
||||
}
|
||||
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
||||
self.root.join("broker").join("versions").join(id)
|
||||
}
|
||||
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
||||
self.versions_dir(id).join(v.to_string())
|
||||
}
|
||||
pub fn scratch_dir(&self) -> PathBuf {
|
||||
self.root.join("broker").join("scratch")
|
||||
}
|
||||
|
||||
pub fn exists(&self) -> bool {
|
||||
self.registry_path().is_file()
|
||||
}
|
||||
|
||||
/// Create an empty world with the folders of the native actors.
|
||||
pub fn create(root: &Path) -> Result<World> {
|
||||
let world = World::new(std::path::absolute(root)?);
|
||||
if world.exists() {
|
||||
bail!("{} is already a Sausage world", root.display());
|
||||
}
|
||||
std::fs::create_dir_all(&world.root)?;
|
||||
world.save_registry(&Registry::default())?;
|
||||
for id in NATIVE {
|
||||
world.ensure_actor_dirs(id)?;
|
||||
let status = serde_json::json!({"id": id, "kind": "native", "state": "built-in"});
|
||||
write_atomic(&world.actor_dir(id).join("status.json"), serde_json::to_string_pretty(&status)?.as_bytes())?;
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Create the directories of one actor.
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn load_registry(&self) -> Result<Registry> {
|
||||
let text = std::fs::read_to_string(self.registry_path())
|
||||
.with_context(|| format!("no Sausage world at {}", self.root.display()))?;
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
|
||||
pub fn save_registry(&self, reg: &Registry) -> Result<()> {
|
||||
write_atomic(&self.registry_path(), serde_json::to_string_pretty(reg)?.as_bytes())
|
||||
}
|
||||
|
||||
/// Map a path in an actor's namespace to a host path.
|
||||
///
|
||||
/// - `/actor/<id>/...` 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<PathBuf> {
|
||||
let clean = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
let mut out = base.clone();
|
||||
for c in Path::new(rest).components() {
|
||||
match c {
|
||||
std::path::Component::Normal(n) => out.push(n),
|
||||
std::path::Component::CurDir | std::path::Component::RootDir => {}
|
||||
_ => bail!("path must not contain '..': {path}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
if let Some(rest) = path.strip_prefix("/actor/") {
|
||||
return clean(self.actors_root(), rest);
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("/public/").or(path.strip_prefix("/public").filter(|r| r.is_empty())) {
|
||||
return clean(self.public_dir(sender), rest);
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("/data/").or(path.strip_prefix("/data").filter(|r| r.is_empty())) {
|
||||
return clean(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/<id>/..., /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<String> {
|
||||
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 tmp = dir.join(format!(
|
||||
"{TMP_PREFIX}host-{}-{}",
|
||||
std::process::id(),
|
||||
path.file_name().and_then(|n| n.to_str()).unwrap_or("file")
|
||||
));
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy a directory tree. Skips `target/` folders and uncommitted files.
|
||||
pub fn copy_tree(from: &Path, to: &Path) -> 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_str = name.to_string_lossy();
|
||||
if name_str.starts_with(TMP_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
let ty = entry.file_type()?;
|
||||
let dst = to.join(&name);
|
||||
if ty.is_dir() {
|
||||
if name_str == "target" || name_str == ".git" {
|
||||
continue;
|
||||
}
|
||||
copy_tree(&entry.path(), &dst)?;
|
||||
} else if ty.is_file() {
|
||||
std::fs::copy(entry.path(), &dst)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the directory `dst` with a copy of `src`, as close to atomic as a
|
||||
/// directory can be: copy to a sibling, then swap with two renames.
|
||||
pub fn replace_tree(src: &Path, dst: &Path) -> 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_tree(src, &fresh)?;
|
||||
} 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user