mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Access was binary and org-wide: any org member got full read+write on every project. Now each project carries four ordered levels, resolved by one resolver and enforced at one choke point. - `projectPerm` (perms.go) replaces `projectAllowed`; `proj(level, h)` in server.go gates every per-project route by the level it declares at registration, so no handler grows its own check. - `Project` gains Creator/Default/Perms. `Default == ""` means write, so an upgraded hub behaves identically until someone edits permissions. - Creator becomes the first project admin; org owners are implicitly admin everywhere in their org and a grant naming one is refused, not ignored; a project always keeps at least one explicit admin. - Default `none` makes a project invite-only. A `none` member is treated exactly like a non-member, including on create-or-join by name. - Rename/delete move from org-owner-only to project `admin`. - Both metadata backends persist it: the file store rides along, the SQL store gains `project_perms` plus an idempotent ALTER for the two new columns (migrate() had only ever created tables). Client side, a refusal stops looking like an outage: `remote.ErrForbidden` plus `Result.ReadOnly` (push refused → pull-only) and `Result.NoAccess` (pull refused → paused, working folder untouched). Neither sets Offline, neither loses a local op, and re-granting self-heals on the next cycle. `bdrive status`/`sync` and the daemon (once, on transition) say which. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
269 lines
7.6 KiB
Go
269 lines
7.6 KiB
Go
// Package daemon runs the per-mount background sync loop and manages its
|
|
// lifecycle (detached start, pidfile, graceful stop).
|
|
//
|
|
// The loop scans the working folder every scan-interval (cheap: size+mtime
|
|
// against the state cache) and talks to the remote every remote-interval —
|
|
// or immediately after local changes, so edits propagate quickly without
|
|
// hammering the object store.
|
|
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
"github.com/runbear-io/beardrive/internal/syncer"
|
|
)
|
|
|
|
// The volume store is keyed by the mount id, so exactly one daemon runs per
|
|
// mount and its pid/log live in the store dir.
|
|
func PidPath(volDir string) string {
|
|
return filepath.Join(volDir, "daemon.pid")
|
|
}
|
|
func LogPath(volDir string) string {
|
|
return filepath.Join(volDir, "daemon.log")
|
|
}
|
|
|
|
// Running reports the daemon pid for a mount if one is alive.
|
|
func Running(volDir string) (int, bool) {
|
|
data, err := os.ReadFile(PidPath(volDir))
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
|
if err != nil || pid <= 0 {
|
|
return 0, false
|
|
}
|
|
if err := syscall.Kill(pid, 0); err != nil {
|
|
return 0, false
|
|
}
|
|
return pid, true
|
|
}
|
|
|
|
// Start launches a detached daemon for the folder (no-op if already running).
|
|
func Start(folder, volDir string, scanInterval, remoteInterval time.Duration) (int, error) {
|
|
if pid, ok := Running(volDir); ok {
|
|
return pid, nil
|
|
}
|
|
exe, err := os.Executable()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
logf, err := os.OpenFile(LogPath(volDir), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer logf.Close()
|
|
cmd := exec.Command(exe, "daemon", "run", folder,
|
|
"--scan-interval", scanInterval.String(),
|
|
"--remote-interval", remoteInterval.String())
|
|
cmd.Stdout = logf
|
|
cmd.Stderr = logf
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
|
if err := cmd.Start(); err != nil {
|
|
return 0, err
|
|
}
|
|
pid := cmd.Process.Pid
|
|
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(pid)+"\n"), 0o644); err != nil {
|
|
return pid, err
|
|
}
|
|
return pid, cmd.Process.Release()
|
|
}
|
|
|
|
// Stop terminates the daemon for a mount and waits for it to exit.
|
|
func Stop(volDir string) (bool, error) {
|
|
pid, ok := Running(volDir)
|
|
if !ok {
|
|
os.Remove(PidPath(volDir))
|
|
return false, nil
|
|
}
|
|
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
|
|
return false, err
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if err := syscall.Kill(pid, 0); err != nil {
|
|
os.Remove(PidPath(volDir))
|
|
return true, nil
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
syscall.Kill(pid, syscall.SIGKILL)
|
|
os.Remove(PidPath(volDir))
|
|
return true, nil
|
|
}
|
|
|
|
// Run is the daemon main loop, executed in the foreground of the (usually
|
|
// detached) `bdrive daemon run` process.
|
|
func Run(folder string, scanInterval, remoteInterval time.Duration) error {
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
|
defer stop()
|
|
|
|
proj, ok, err := config.ResolveMount(folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%s is not a beardrive project (run `bdrive init`)", folder)
|
|
}
|
|
volDir, err := config.VolumeDir(proj.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st, err := store.Open(volDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dev, err := config.LoadDevice()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(PidPath(volDir), []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(PidPath(volDir))
|
|
|
|
log.Printf("daemon started: folder=%s mount=%s volume=%s remote=%q device=%s(%s) scan=%s sync=%s",
|
|
folder, proj.ID, proj.Volume, proj.Remote, dev.Name, dev.ID, scanInterval, remoteInterval)
|
|
|
|
var be remote.Backend
|
|
defer func() {
|
|
if be != nil {
|
|
be.Close()
|
|
}
|
|
}()
|
|
var lastRemote time.Time
|
|
var lastToken string
|
|
// Which access state we last logged, so a degraded daemon says it once
|
|
// instead of on every tick.
|
|
lastAccess := store.AccessOK
|
|
|
|
for {
|
|
// Re-read the project config each tick: picks up `bdrive remote set`
|
|
// and hand-edits. A vanished config means the folder was moved,
|
|
// renamed, or deleted — exit cleanly (propagating nothing); the next
|
|
// bdrive command in the folder's new location resumes the daemon.
|
|
cur, ok, err := config.LoadProject(folder)
|
|
if err != nil || !ok {
|
|
log.Printf("project config gone (folder moved or deleted); exiting")
|
|
return nil
|
|
}
|
|
if cur.ID != proj.ID {
|
|
log.Printf("mount identity changed; exiting")
|
|
return nil
|
|
}
|
|
// If the registry says this mount now lives elsewhere, a new
|
|
// location has taken over — stand down.
|
|
if m, err := config.LoadMounts(); err == nil {
|
|
if mi, ok := m[proj.ID]; ok && mi.Path != folder {
|
|
log.Printf("mount re-registered at %s; exiting", mi.Path)
|
|
return nil
|
|
}
|
|
}
|
|
if cur.Remote != proj.Remote {
|
|
log.Printf("remote changed: %q -> %q", proj.Remote, cur.Remote)
|
|
if be != nil {
|
|
be.Close()
|
|
be = nil
|
|
}
|
|
lastRemote = time.Time{}
|
|
}
|
|
proj = cur
|
|
|
|
// Re-read settings each tick too, so a login/logout/account switch
|
|
// after the daemon started is reflected in op authorship — otherwise
|
|
// a long-lived daemon stamps every change with a stale identity. The
|
|
// http backend captures its credential at open, so drop it when the
|
|
// token changes and reconnect with the new one.
|
|
settings, _ := config.LoadSettings()
|
|
if settings.Token != lastToken {
|
|
if be != nil {
|
|
be.Close()
|
|
be = nil
|
|
}
|
|
lastToken = settings.Token
|
|
lastRemote = time.Time{}
|
|
}
|
|
|
|
doRemote := proj.Remote != "" && time.Since(lastRemote) >= remoteInterval
|
|
if doRemote && be == nil {
|
|
b, err := remote.Open(ctx, proj.Remote)
|
|
if err != nil {
|
|
log.Printf("remote unavailable: %v", err)
|
|
doRemote = false
|
|
lastRemote = time.Now()
|
|
} else {
|
|
be = b
|
|
}
|
|
}
|
|
|
|
sess := &syncer.Session{Folder: folder, MountID: proj.ID, Store: st, Device: dev, Account: settings}
|
|
if doRemote {
|
|
sess.Backend = be
|
|
}
|
|
res, err := sess.Cycle(ctx)
|
|
switch {
|
|
case ctx.Err() != nil:
|
|
log.Printf("daemon stopping")
|
|
return nil
|
|
case err != nil:
|
|
log.Printf("cycle error: %v", err)
|
|
case res.NoAccess:
|
|
// The connection is fine, the answer isn't: keep the backend and
|
|
// keep ticking cheaply so a re-grant self-heals. Log the
|
|
// transition only — a paused daemon must stay quiet.
|
|
if lastAccess != store.AccessNone {
|
|
log.Printf("access revoked for this project; sync paused (%v)", res.AccessErr)
|
|
lastAccess = store.AccessNone
|
|
}
|
|
lastRemote = time.Now()
|
|
case res.ReadOnly:
|
|
if lastAccess != store.AccessReadOnly {
|
|
log.Printf("read-only on this project, pulling only; local changes stay on this device")
|
|
lastAccess = store.AccessReadOnly
|
|
}
|
|
lastRemote = time.Now()
|
|
case res.Offline:
|
|
log.Printf("offline, will retry: %v", res.OfflineErr)
|
|
if be != nil {
|
|
be.Close()
|
|
be = nil
|
|
}
|
|
lastRemote = time.Now()
|
|
default:
|
|
if lastAccess != store.AccessOK {
|
|
log.Printf("access restored; syncing normally")
|
|
lastAccess = store.AccessOK
|
|
}
|
|
if res.Activity() {
|
|
log.Printf("local+%d pulled+%d conflicts=%d files~%d pushed=%v",
|
|
res.LocalOps, res.PulledOps, res.Conflicts, res.Materialized, res.Pushed)
|
|
}
|
|
if doRemote {
|
|
lastRemote = time.Now()
|
|
}
|
|
if res.LocalOps > 0 && !doRemote {
|
|
lastRemote = time.Time{} // push local edits on the next tick
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
log.Printf("daemon stopping")
|
|
return nil
|
|
case <-time.After(scanInterval):
|
|
}
|
|
}
|
|
}
|