summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/roots.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/roots.py')
-rw-r--r--packages/meshbay-node/src/meshbay_node/roots.py101
1 files changed, 64 insertions, 37 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py
index 74ea2f6..d288231 100644
--- a/packages/meshbay-node/src/meshbay_node/roots.py
+++ b/packages/meshbay-node/src/meshbay_node/roots.py
@@ -120,10 +120,9 @@ class Root:
name: str
path: Path
kind: str = "generic"
- upload: bool = False
- direct: bool = False
- # Runtime, not configuration: set by the indexer when the directory can no
- # longer be read, and cleared when it comes back.
+ writable: bool = False
+ removable: bool = False
+ ejected: bool = False
available: bool = True
@property
@@ -165,6 +164,13 @@ class RootSet:
roots: list[Root] = field(default_factory=list)
+ # Roots this set ejected by itself — a removable device that went away
+ # without the operator clicking Eject. Drained by the indexer, which is
+ # the only caller holding a roster to write the state to. Without that
+ # the flag is lost on the next restart, and the surprise unplug looks
+ # like a deletion all over again on the pass after it.
+ auto_ejected: list[str] = field(default_factory=list)
+
# ── Construction ─────────────────────────────────────────────────────────
@classmethod
@@ -172,8 +178,9 @@ class RootSet:
"""
Build from configuration, refusing anything ambiguous.
- `specs` are dicts with `path`, and optionally `name`, `kind`, `upload`.
- Raises RootError with a message meant for an operator reading a log.
+ `specs` are dicts with `path`, and optionally `name`, `kind`, `writable`,
+ `removable`. Raises RootError with a message meant for an operator reading
+ a log.
"""
roots: list[Root] = []
by_folded: dict[str, Root] = {}
@@ -209,34 +216,22 @@ class RootSet:
log.warning("root %r: unknown kind %r — using 'generic'", name, kind)
kind = "generic"
+ # Backward compat: old configs use `upload` instead of `writable`
+ writable = bool(spec.get("writable", spec.get("upload", False)))
+ # `ejected` is runtime state, not configuration — it reaches here
+ # only from the roster, restored at startup so a drive ejected
+ # before a restart does not come back on its own.
root = Root(name=name, path=path, kind=kind,
- upload=bool(spec.get("upload", False)),
- direct=bool(spec.get("direct", False)))
+ writable=writable,
+ removable=bool(spec.get("removable", False)),
+ ejected=bool(spec.get("ejected", False)),
+ available=not bool(spec.get("ejected", False)))
_refuse_nesting(root, roots)
roots.append(root)
by_folded[root.folded] = root
- cls._settle_upload_root(roots)
return cls(roots=roots)
- @staticmethod
- def _settle_upload_root(roots: list[Root]) -> None:
- """
- Exactly one root receives uploads, and the operator picks it.
-
- Not guessed when several are marked, because "uploads went somewhere
- else" is discovered weeks later. With none marked and a single root, the
- answer is not ambiguous, so it is taken.
- """
- marked = [r for r in roots if r.upload]
- if len(marked) > 1:
- names = ", ".join(r.name for r in marked)
- raise RootError(
- f"several roots are marked upload = true ({names}) — exactly one "
- f"receives uploads")
- if not marked and len(roots) == 1:
- roots[0].upload = True
-
# ── Lookup ───────────────────────────────────────────────────────────────
def by_name(self, name: str) -> Root | None:
@@ -247,11 +242,8 @@ class RootSet:
return None
@property
- def upload_root(self) -> Root | None:
- for root in self.roots:
- if root.upload:
- return root
- return None
+ def writable_roots(self) -> list[Root]:
+ return [r for r in self.roots if r.writable]
@property
def names(self) -> list[str]:
@@ -336,10 +328,28 @@ class RootSet:
Called periodically and after a filesystem event that looks like a
disappearance. A change here never edits the index: a root going away
freezes its entries, and a root coming back triggers a rescan.
+
+ An ejected root stays unavailable regardless of `is_live()` — the
+ operator must explicitly plug it back. A removable root whose path
+ disappears without an eject is auto-ejected as a safety net.
"""
changed: list[tuple[Root, bool]] = []
for root in self.roots:
+ if root.ejected:
+ if root.available:
+ root.available = False
+ changed.append((root, False))
+ continue
live = root.is_live()
+ if not live and root.removable:
+ root.ejected = True
+ # Recorded for the caller to persist. A flag that only lives
+ # in memory would be forgotten on the next restart, and the
+ # rescan that followed would read an empty mount point as an
+ # erased library — the exact outcome eject exists to prevent.
+ self.auto_ejected.append(root.name)
+ log.warning("Root %r auto-ejected (device disappeared): %s",
+ root.name, root.path)
if live != root.available:
root.available = live
changed.append((root, live))
@@ -347,14 +357,31 @@ class RootSet:
"available" if live else "UNAVAILABLE", root.path)
return changed
- def describe(self) -> list[dict]:
- """Per-root state for the index payload and the admin UI."""
+ def describe(self, *, with_paths: bool = False) -> list[dict]:
+ """
+ Per-root state for the index payload and the admin UI.
+
+ Deliberately no paths by default: this is what every member receives.
+ `with_paths=True` is the operator's own view, over a channel that is
+ already theirs alone (loopback + run token).
+ """
out = []
for r in self.roots:
d: dict = {"name": r.name, "kind": r.kind,
- "available": r.available, "upload": r.upload}
- if r.direct:
- d["direct"] = True
+ "available": r.available,
+ "writable": r.writable,
+ "removable": r.removable,
+ "ejected": r.ejected,
+ # Backward compat for MNP 1.0 clients
+ "upload": r.writable}
+ # `with_paths` is for the operator's *own* channels only — the
+ # loopback API and the CLI reading it, both of which already
+ # require being on this machine with the run token. A member is
+ # told what exists and whether it is readable, never where on the
+ # operator's disk it lives, and the index payload every member
+ # receives must keep calling this without the flag.
+ if with_paths:
+ d["path"] = str(r.path)
out.append(d)
return out