This commit is contained in:
+6
-5
@@ -255,7 +255,8 @@ impl Broker {
|
||||
}
|
||||
InstanceConfig {
|
||||
mounts: vec![
|
||||
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
|
||||
Mount { guest: "/".into(), host: self.world.root.clone(), writable: true },
|
||||
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: true },
|
||||
Mount { guest: "/public".into(), host: own.join("public"), writable: true },
|
||||
Mount { guest: "/data".into(), host: own.join("data"), writable: true },
|
||||
],
|
||||
@@ -445,7 +446,7 @@ impl Broker {
|
||||
self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass");
|
||||
let watchers: Vec<String> =
|
||||
self.metas.lock().unwrap().iter().filter(|(_, m)| !m.watches.is_empty()).map(|(id, _)| id.clone()).collect();
|
||||
self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/actor", "op": "rescan"}));
|
||||
self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/", "op": "rescan"}));
|
||||
let report = self.scan().await;
|
||||
for change in &report {
|
||||
eprintln!("sausage: {change}");
|
||||
@@ -599,9 +600,9 @@ impl Broker {
|
||||
let on = ty == "watch";
|
||||
let path_s = b["path"].as_str().ok_or_else(|| anyhow!("missing \"path\""))?;
|
||||
let global = self.world.global_path(&self.world.resolve(&env.from, path_s)?);
|
||||
let path = global.ok_or_else(|| anyhow!("{path_s} is not under /actor"))?;
|
||||
let path = global.ok_or_else(|| anyhow!("{path_s} is not under the world root"))?;
|
||||
let path = match path.trim_end_matches('/') {
|
||||
"" => "/actor".to_string(),
|
||||
"" => "/".to_string(),
|
||||
p => p.to_string(),
|
||||
};
|
||||
self.update_meta(&env.from, |m| {
|
||||
@@ -649,7 +650,7 @@ fn same_file(a: &Path, b: &Path) -> bool {
|
||||
|
||||
/// Whether the namespace path `path` is `prefix` or inside it.
|
||||
fn under(path: &str, prefix: &str) -> bool {
|
||||
path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
prefix == "/" || path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
|
||||
}
|
||||
|
||||
pub fn truncate(s: &str, max: usize) -> String {
|
||||
|
||||
+2
-2
@@ -390,8 +390,8 @@ impl preopens::Host for VfsView<'_> {
|
||||
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
|
||||
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
||||
for (fd, name) in &dirs {
|
||||
if name == "/actor" {
|
||||
self.state.dirs.insert(fd.rep(), "/actor".to_string());
|
||||
if name == "/actor" || name == "/" {
|
||||
self.state.dirs.insert(fd.rep(), name.clone());
|
||||
}
|
||||
}
|
||||
Ok(dirs)
|
||||
|
||||
+10
-8
@@ -5,7 +5,7 @@
|
||||
//! Events are coalesced per path: once a path is quiet for `QUIET` (or has
|
||||
//! kept changing for `MAX_DELAY`), it becomes one change
|
||||
//! `{path, op: write | create | remove | rename, to?}` with a namespace path
|
||||
//! (`/actor/...`). Then the broker
|
||||
//! (relative to the world root, such as `/actor/...` or `/config/...`). Then the broker
|
||||
//!
|
||||
//! - sends `fs_changed` to the actors that watch the path (`watch` request),
|
||||
//! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder
|
||||
@@ -75,7 +75,7 @@ impl Pending {
|
||||
}
|
||||
|
||||
async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) {
|
||||
let root = broker.world.actors_root();
|
||||
let root = broker.world.root.clone();
|
||||
let mut pending: HashMap<String, Pending> = HashMap::new();
|
||||
// Actor checks and full passes. Dropping the set (when the host stops) aborts them.
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
@@ -122,11 +122,11 @@ async fn sleep_until(t: Option<Instant>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The namespace path (`/actor/...`) of a host path, unless it is outside
|
||||
/// `actor/` or a temporary file of the host.
|
||||
/// The world namespace path of a host path, unless it is outside the
|
||||
/// world or a temporary file of the host.
|
||||
fn namespace(root: &Path, path: &Path) -> Option<String> {
|
||||
let rel = path.strip_prefix(root).ok()?;
|
||||
let mut out = String::from("/actor");
|
||||
let mut out = String::new();
|
||||
for part in rel.components() {
|
||||
let name = part.as_os_str().to_str()?;
|
||||
if name.starts_with(TMP_PREFIX) {
|
||||
@@ -135,7 +135,7 @@ fn namespace(root: &Path, path: &Path) -> Option<String> {
|
||||
out.push('/');
|
||||
out.push_str(name);
|
||||
}
|
||||
Some(out)
|
||||
Some(if out.is_empty() { "/".to_string() } else { out })
|
||||
}
|
||||
|
||||
/// One change: namespace path, op, and the new path of a rename.
|
||||
@@ -219,7 +219,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_rename_is_one_change_and_temp_files_are_skipped() {
|
||||
let root = Path::new("/w/actor");
|
||||
let root = Path::new("/w");
|
||||
let now = Instant::now();
|
||||
let mut pending = HashMap::new();
|
||||
let ev = |kind, paths: &[&str]| {
|
||||
@@ -236,7 +236,8 @@ mod tests {
|
||||
ev(name(RenameMode::Both), &["/w/actor/a/public/x", "/w/actor/a/public/y"]),
|
||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/actor/a/.sausage-tmp-host-1-actor.json"]),
|
||||
ev(name(RenameMode::Both), &["/w/actor/a/.sausage-tmp-host-1-actor.json", "/w/actor/a/actor.json"]),
|
||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/.lock"]),
|
||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/outside/.lock"]),
|
||||
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/config/x"]),
|
||||
] {
|
||||
for (path, op, to) in changes(root, &e) {
|
||||
add(&mut pending, path, op, to, now);
|
||||
@@ -250,6 +251,7 @@ mod tests {
|
||||
vec![
|
||||
("/actor/a/actor.json".to_string(), "write", None),
|
||||
("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())),
|
||||
("/config/x".to_string(), "create", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
+13
-8
@@ -3,13 +3,14 @@
|
||||
//! watches the folder (`watch.rs`).
|
||||
//!
|
||||
//! ```text
|
||||
//! <world>/
|
||||
//! <world>/ mounted at /, readable and writable by every actor
|
||||
//! config/ global configuration by convention (no secrets)
|
||||
//! actor/<id>/
|
||||
//! actor.wasm the current version
|
||||
//! actor.json version, running, subscriptions, tapped, watches (broker)
|
||||
//! source/ source of the current version
|
||||
//! public/ written by the actor, read-only for others
|
||||
//! data/ state, writable only by the actor
|
||||
//! public/ published files, readable and writable by everyone
|
||||
//! data/ state, readable and writable by everyone
|
||||
//! stderr.log stderr of the actor and notes of the broker
|
||||
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
|
||||
//! ```
|
||||
@@ -131,6 +132,7 @@ impl World {
|
||||
for id in NATIVE {
|
||||
world.ensure_actor_dirs(id)?;
|
||||
}
|
||||
std::fs::create_dir_all(world.root.join("config"))?;
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
@@ -191,8 +193,8 @@ impl World {
|
||||
|
||||
/// Map a path in an actor's namespace to a host path.
|
||||
///
|
||||
/// - `/actor/<id>/...` is the global filesystem.
|
||||
/// - `/public/...` and `/data/...` belong to `sender`.
|
||||
/// - Absolute paths are relative to the world root.
|
||||
/// - `/public/...` and `/data/...` alias `sender`'s folders.
|
||||
/// - Native senders (the console) can also give absolute host paths.
|
||||
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
|
||||
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
@@ -221,13 +223,16 @@ impl World {
|
||||
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/...")
|
||||
if Path::new(path).is_absolute() {
|
||||
return under(self.root.clone(), path);
|
||||
}
|
||||
bail!("cannot resolve path {path} for {sender}: use an absolute world path")
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
let rel = host.strip_prefix(&self.root).ok()?;
|
||||
Some(format!("/{}", rel.display()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-11
@@ -140,20 +140,20 @@ async fn exit_request_is_answered_and_signals_the_host() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn filesystem_permissions_and_public_sharing() {
|
||||
async fn filesystem_is_writable_and_public_aliases_share_files() {
|
||||
let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await;
|
||||
for path in [
|
||||
"/actor/reader/public/denied.txt",
|
||||
"/actor/writer/public/denied.txt",
|
||||
"/actor/reader/public/shared.txt",
|
||||
"/actor/writer/public/shared.txt",
|
||||
] {
|
||||
let denied = ask(
|
||||
let written = ask(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
json!({"type": "write", "path": path, "text": "denied"}),
|
||||
json!({"type": "write", "path": path, "text": "shared"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(denied.body["type"], "error", "write to {path} succeeded");
|
||||
assert_eq!(written.body["type"], "ok", "write to {path} failed");
|
||||
}
|
||||
|
||||
let written = ask(
|
||||
@@ -176,7 +176,7 @@ async fn filesystem_permissions_and_public_sharing() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actors_can_read_but_not_write_other_actor_data() {
|
||||
async fn actors_can_read_and_write_other_actor_data() {
|
||||
let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;
|
||||
ask_type(
|
||||
&host,
|
||||
@@ -201,10 +201,10 @@ async fn actors_can_read_but_not_write_other_actor_data() {
|
||||
&mut mailbox,
|
||||
"reader",
|
||||
json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}),
|
||||
"error",
|
||||
"ok",
|
||||
)
|
||||
.await;
|
||||
let unchanged = ask_type(
|
||||
let changed = ask_type(
|
||||
&host,
|
||||
&mut mailbox,
|
||||
"writer",
|
||||
@@ -212,7 +212,47 @@ async fn actors_can_read_but_not_write_other_actor_data() {
|
||||
"content",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(unchanged.body["text"], "shared");
|
||||
assert_eq!(changed.body["text"], "changed");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn world_root_is_shared_writable_and_survives_restart() {
|
||||
let (world, host, mut mailbox) = filesystem_world("world-root").await;
|
||||
assert!(world.root.join("config").is_dir());
|
||||
let file = "/config/x";
|
||||
ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": file, "text": "first"}), "ok").await;
|
||||
let read = json!({"type": "read", "path": file});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "reader", read.clone(), "content").await.body["text"], "first");
|
||||
ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": file, "text": "second"}), "ok").await;
|
||||
assert_eq!(std::fs::read_to_string(world.root.join("config/x")).unwrap(), "second");
|
||||
for actor in ["writer", "reader"] {
|
||||
assert_eq!(world.resolve(actor, file).unwrap(), world.root.join("config/x"));
|
||||
}
|
||||
assert_eq!(world.global_path(&world.root.join("config/x")).as_deref(), Some(file));
|
||||
|
||||
// Watches use the same world namespace, not only /actor paths.
|
||||
let watching = request(&host, &mut mailbox, json!({"type": "watch", "path": world.root.join("config").display().to_string()})).await;
|
||||
assert_eq!(watching.body, json!({"type": "watching", "path": "/config"}));
|
||||
drain(&mut mailbox, Duration::from_millis(300)).await;
|
||||
std::fs::write(world.root.join("config/event"), "watched").unwrap();
|
||||
assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await),
|
||||
vec![json!({"type": "fs_changed", "path": "/config/event", "op": "create"})]);
|
||||
request(&host, &mut mailbox, json!({"type": "unwatch", "path": world.root.join("config").display().to_string()})).await;
|
||||
|
||||
// A new top-level folder appears in already running instances, without a mount change.
|
||||
std::fs::create_dir(world.root.join("docs")).unwrap();
|
||||
std::fs::write(world.root.join("docs/new"), "host-created").unwrap();
|
||||
let docs = json!({"type": "read", "path": "/docs/new"});
|
||||
assert_eq!(ask_type(&host, &mut mailbox, "reader", docs, "content").await.body["text"], "host-created");
|
||||
ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": "/docs/new", "text": "actor-updated"}), "ok").await;
|
||||
assert_eq!(std::fs::read_to_string(world.root.join("docs/new")).unwrap(), "actor-updated");
|
||||
host.stop().await;
|
||||
|
||||
let (host, mut mailbox) = start(&world).await;
|
||||
for actor in ["writer", "reader"] {
|
||||
assert_eq!(ask_type(&host, &mut mailbox, actor, read.clone(), "content").await.body["text"], "second");
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
@@ -1187,7 +1227,7 @@ async fn a_watcher_overflow_sends_one_rescan_and_runs_a_full_pass() {
|
||||
let report = host.broker.clone().overflowed().await;
|
||||
assert!(report.iter().any(|r| r["type"] == "spawned" && r["actor"] == "planted"), "{report:?}");
|
||||
|
||||
let rescan = json!({"type": "fs_changed", "path": "/actor", "op": "rescan"});
|
||||
let rescan = json!({"type": "fs_changed", "path": "/", "op": "rescan"});
|
||||
assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), vec![rescan]);
|
||||
assert!(bystander.try_recv().is_err(), "an actor without watches got the rescan");
|
||||
host.stop().await;
|
||||
|
||||
Reference in New Issue
Block a user