mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
sfs v0.1: mountable synced file system for AI agents
- sfs mnt/umnt/sync/status/log/remote/whoami CLI (cobra) - per-device append-only journals + content-addressed blob store - deterministic lamport-ordered merge, LWW with conflict-copy preservation - cloud-agnostic backends: S3, GCS, file:// (S3-compatible via AWS_ENDPOINT_URL) - offline-first: real files on disk, journal locally, push on reconnect - background sync daemon per mount with change tracking (device/author/time) - macOS + Linux; Homebrew formula + goreleaser release pipeline Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/sfs
|
||||
/dist/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,46 @@
|
||||
# Release automation: `goreleaser release` on a tagged commit builds
|
||||
# macOS/Linux binaries and publishes the Homebrew formula to
|
||||
# runbear-io/homebrew-tap, enabling `brew install runbear-io/tap/sfs`.
|
||||
version: 2
|
||||
|
||||
project_name: sfs
|
||||
|
||||
builds:
|
||||
- id: sfs
|
||||
main: ./cmd/sfs
|
||||
binary: sfs
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w -X main.version={{.Version}}
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
brews:
|
||||
- name: sfs
|
||||
repository:
|
||||
owner: runbear-io
|
||||
name: homebrew-tap
|
||||
homepage: https://github.com/runbear-io/sfs
|
||||
description: "Synced file system for AI agents: mount, sync, and track folders"
|
||||
license: MIT
|
||||
test: |
|
||||
assert_match "sfs", shell_output("#{bin}/sfs version")
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "a75ed46b-65f6-4b00-b5ee-f946ec2826ab",
|
||||
"ended_at": "2026-06-12T06:15:19.362Z",
|
||||
"reason": "other",
|
||||
"agents_spawned": 0,
|
||||
"agents_completed": 0,
|
||||
"modes_used": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Runbear, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,162 @@
|
||||
# sfs — a synced file system for AI agents
|
||||
|
||||
**sfs** mounts any folder as a synced volume: its contents stay synchronized
|
||||
across all your devices through cloud object storage, every change is
|
||||
tracked (who, when, on which device), and everything keeps working offline.
|
||||
|
||||
It is built for AI agent workflows — give your agents on every machine the
|
||||
same `~/agent-workspace`, and notes, plans, memory files, and artifacts
|
||||
follow them everywhere, with a full audit trail of which agent or human
|
||||
changed what.
|
||||
|
||||
```console
|
||||
$ sfs mnt ./workspace --remote s3://my-bucket/workspace
|
||||
mounted /Users/snow/workspace
|
||||
volume: workspace
|
||||
remote: s3://my-bucket/workspace
|
||||
device: macbook (d380dea58598) as snow@runbear.io
|
||||
daemon: running (pid 55434, scan 3s, remote sync 30s)
|
||||
```
|
||||
|
||||
On another machine:
|
||||
|
||||
```console
|
||||
$ sfs mnt ./workspace --remote s3://my-bucket/workspace
|
||||
# … the same files appear, and stay in sync from now on
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Mount anywhere** — `sfs mnt ./folder` turns any folder into a synced
|
||||
volume. Files are *real files on disk*: every tool, editor, and agent can
|
||||
use them with zero integration work.
|
||||
- **Multi-device sync** — devices converge through a shared remote. Each
|
||||
device only writes its own append-only journal, so no locking service or
|
||||
server is needed — any object store works.
|
||||
- **Change tracking** — `sfs log` shows which device and author changed
|
||||
which file, when. Content is stored content-addressed, so history is
|
||||
never lost, even for overwritten or deleted files.
|
||||
- **Cloud-provider agnostic** — Amazon S3 (`s3://`), Google Cloud Storage
|
||||
(`gs://`), any S3-compatible store (MinIO, Cloudflare R2 via
|
||||
`AWS_ENDPOINT_URL`), or a plain shared directory (`file://`, e.g. a NAS).
|
||||
- **Offline-first** — the working folder is always fully usable with no
|
||||
network. Changes are journaled locally and pushed when the remote becomes
|
||||
reachable again.
|
||||
- **Conflict-safe** — concurrent edits resolve deterministically
|
||||
(last-writer-wins), and the losing version is preserved as a
|
||||
`name.sfs-conflict-<device>-<time>` file. Nothing is silently dropped.
|
||||
- **macOS & Linux.**
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
brew install runbear-io/tap/sfs # macOS (and Linuxbrew)
|
||||
```
|
||||
|
||||
or from source:
|
||||
|
||||
```sh
|
||||
go install github.com/runbear-io/sfs/cmd/sfs@latest
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
# 1. Mount a folder, syncing through S3 (or gs://, or file://)
|
||||
sfs mnt ./notes --remote s3://my-bucket/notes
|
||||
|
||||
# 2. Work normally — create, edit, delete files with any tool.
|
||||
echo "remember this" > notes/memory.md
|
||||
|
||||
# 3. On every other device, mount the same remote:
|
||||
sfs mnt ./notes --remote s3://my-bucket/notes
|
||||
|
||||
# See what changed, who changed it, and from which device
|
||||
sfs log ./notes
|
||||
|
||||
# Check sync state and the daemon
|
||||
sfs status
|
||||
|
||||
# Sync on demand (the daemon also syncs automatically)
|
||||
sfs sync ./notes
|
||||
|
||||
# Stop syncing (files stay on disk; mount again any time)
|
||||
sfs umnt ./notes
|
||||
```
|
||||
|
||||
### Credentials
|
||||
|
||||
sfs uses each provider's standard credential chain — nothing sfs-specific:
|
||||
|
||||
| Remote | Credentials |
|
||||
|---|---|
|
||||
| `s3://bucket/prefix` | `AWS_PROFILE`, `~/.aws/credentials`, env vars, IAM roles. S3-compatible stores via `AWS_ENDPOINT_URL`. |
|
||||
| `gs://bucket/prefix` | Application Default Credentials (`gcloud auth application-default login`) or `GOOGLE_APPLICATION_CREDENTIALS`. |
|
||||
| `file:///path` | none — any local or network-mounted directory |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `sfs mnt <folder> [--remote URL]` | Mount a folder as a synced volume and start the sync daemon |
|
||||
| `sfs umnt <folder>` | Stop syncing (`--forget` also unregisters the mount) |
|
||||
| `sfs sync [folder]` | Run one sync cycle now |
|
||||
| `sfs status [folder]` | Mounts, daemon state, pending changes |
|
||||
| `sfs log [folder] [-p path] [-n N]` | Change history: author, device, time, file |
|
||||
| `sfs remote [folder]` / `sfs remote set <folder> <url>` | Show / set the cloud remote |
|
||||
| `sfs whoami` | Device identity used in change tracking |
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
working folder ←materialize/scan→ local volume store ←push/pull→ object store
|
||||
(real files) ~/.sfs/volumes/<vol> s3:// gs:// file://
|
||||
├─ blobs/ content-addressed (sha256)
|
||||
├─ journal/ one append-only op log per device
|
||||
├─ state.json what's materialized
|
||||
└─ sync.json lamport clock + push cursor
|
||||
```
|
||||
|
||||
- Every change becomes an **op** (`put`/`delete`) in this device's
|
||||
append-only journal, stamped with a lamport clock, wall-clock time, device
|
||||
ID, and author. File content goes into a content-addressed blob store.
|
||||
- A **sync** uploads new blobs, then the journal; it downloads other
|
||||
devices' journals and any blobs it's missing. Since each device writes
|
||||
only its own journal, there are no concurrent writers per object and any
|
||||
dumb object store suffices.
|
||||
- The folder's state is a deterministic **replay** of all journals ordered
|
||||
by `(lamport, time, device)` — every device converges to the same view.
|
||||
Concurrent edits keep the last writer at the path; the loser is preserved
|
||||
as a conflict-copy file by the device that detects the overlap.
|
||||
- A per-mount **daemon** scans the folder every few seconds (cheap
|
||||
size+mtime check) and exchanges with the remote every ~30s — or
|
||||
immediately after local edits.
|
||||
|
||||
### What sfs does not sync
|
||||
|
||||
`.git` directories (per-file LWW would corrupt repositories), `.DS_Store`,
|
||||
and its own temp files. Empty directories are not tracked (like git).
|
||||
|
||||
## Roadmap
|
||||
|
||||
- `sfs restore <path>@<time>` — restore any file from history (all content
|
||||
is already retained)
|
||||
- FUSE/NFS mount mode for lazy-loading huge volumes
|
||||
- `.sfsignore` patterns
|
||||
- Journal compaction & blob GC policies
|
||||
- Per-path access scopes for multi-agent setups
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
go build ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The integration tests in `internal/syncer` simulate multiple devices syncing
|
||||
through a `file://` remote, including offline operation and concurrent-edit
|
||||
conflicts. Set `SFS_HOME` to relocate all sfs state (used heavily in tests).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/config"
|
||||
"github.com/runbear-io/sfs/internal/daemon"
|
||||
"github.com/runbear-io/sfs/internal/journal"
|
||||
"github.com/runbear-io/sfs/internal/syncer"
|
||||
)
|
||||
|
||||
func syncCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "sync [folder]",
|
||||
Short: "Sync a mounted folder with its remote now",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess, mi, err := openSession(cmd.Context(), folder, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeSession(sess)
|
||||
res, err := sess.Cycle(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("synced %s (volume %q)\n", folder, mi.Volume)
|
||||
printCycle(res)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func statusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status [folder]",
|
||||
Short: "Show mount, sync, and daemon status",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) > 0 {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, ok := mounts[folder]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s is not an sfs mount", folder)
|
||||
}
|
||||
mounts = map[string]config.MountInfo{folder: mi}
|
||||
}
|
||||
if len(mounts) == 0 {
|
||||
fmt.Println("no sfs mounts (create one with `sfs mnt <folder>`)")
|
||||
return nil
|
||||
}
|
||||
dev, err := config.LoadDevice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("device: %s (%s) as %s\n\n", dev.Name, dev.ID, dev.Author)
|
||||
first := true
|
||||
for folder, mi := range mounts {
|
||||
if !first {
|
||||
fmt.Println()
|
||||
}
|
||||
first = false
|
||||
fmt.Printf("%s\n", folder)
|
||||
fmt.Printf(" volume: %s\n", mi.Volume)
|
||||
if mi.Remote != "" {
|
||||
fmt.Printf(" remote: %s\n", mi.Remote)
|
||||
} else {
|
||||
fmt.Printf(" remote: (none — local only)\n")
|
||||
}
|
||||
vdir, err := config.VolumeDir(mi.Volume)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pid, ok := daemon.Running(vdir); ok {
|
||||
fmt.Printf(" daemon: running (pid %d)\n", pid)
|
||||
} else {
|
||||
fmt.Printf(" daemon: stopped\n")
|
||||
}
|
||||
sess, _, err := openSession(cmd.Context(), folder, false)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cache, err := sess.Store.LoadCache()
|
||||
if err == nil {
|
||||
var total int64
|
||||
for _, c := range cache {
|
||||
total += c.Size
|
||||
}
|
||||
fmt.Printf(" files: %d (%s)\n", len(cache), humanBytes(total))
|
||||
}
|
||||
st, err := sess.Store.LoadSync()
|
||||
myOps, err2 := sess.Store.DeviceOps(dev.ID)
|
||||
if err == nil && err2 == nil {
|
||||
pending := int64(len(myOps)) - st.PushedOps
|
||||
if pending < 0 {
|
||||
pending = 0
|
||||
}
|
||||
fmt.Printf(" pending: %d local change(s) not yet pushed\n", pending)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func logCmd() *cobra.Command {
|
||||
var limit int
|
||||
var pathFilter string
|
||||
c := &cobra.Command{
|
||||
Use: "log [folder]",
|
||||
Short: "Show change history: who changed which file, when, on which device",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sess, _, err := openSession(cmd.Context(), folder, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := syncer.LogEntries(sess.Store, pathFilter, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("no history yet")
|
||||
return nil
|
||||
}
|
||||
for _, op := range entries {
|
||||
when := op.Time.Local().Format("2006-01-02 15:04:05")
|
||||
kind := op.Kind
|
||||
if kind == journal.KindPut {
|
||||
kind = "put "
|
||||
} else {
|
||||
kind = "delete"
|
||||
}
|
||||
line := fmt.Sprintf("%s %s %-40s %s on %s", when, kind, op.Path, op.Author, op.DeviceName)
|
||||
if op.Kind == journal.KindPut {
|
||||
line += fmt.Sprintf(" (%s)", humanBytes(op.Size))
|
||||
}
|
||||
if op.Note != "" {
|
||||
line += " [" + op.Note + "]"
|
||||
}
|
||||
fmt.Println(line)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().IntVarP(&limit, "limit", "n", 50, "max entries to show (0 = all)")
|
||||
c.Flags().StringVarP(&pathFilter, "path", "p", "", "only show history for this file or directory")
|
||||
return c
|
||||
}
|
||||
|
||||
func remoteCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "remote [folder]",
|
||||
Short: "Show or set the cloud remote of a mounted folder",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, err := mustMount(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mi.Remote == "" {
|
||||
fmt.Println("(none)")
|
||||
} else {
|
||||
fmt.Println(mi.Remote)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
set := &cobra.Command{
|
||||
Use: "set <folder> <url>",
|
||||
Short: "Set the remote (s3://bucket/prefix, gs://bucket/prefix, file:///path)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args[:1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := args[1]
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "s3" && u.Scheme != "gs" && u.Scheme != "file") {
|
||||
return fmt.Errorf("invalid remote %q (want s3://bucket/prefix, gs://bucket/prefix, or file:///path)", raw)
|
||||
}
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, ok := mounts[folder]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
|
||||
}
|
||||
mi.Remote = raw
|
||||
mounts[folder] = mi
|
||||
if err := config.SaveMounts(mounts); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("remote of %s set to %s\n", folder, raw)
|
||||
fmt.Println("run `sfs sync` to sync now (a running daemon picks it up automatically)")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.AddCommand(set)
|
||||
return c
|
||||
}
|
||||
|
||||
func daemonCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "daemon",
|
||||
Short: "Manage the background sync daemon",
|
||||
Hidden: true,
|
||||
}
|
||||
var scanInterval, remoteInterval time.Duration
|
||||
run := &cobra.Command{
|
||||
Use: "run <folder>",
|
||||
Short: "Run the sync daemon in the foreground (internal)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return daemon.Run(folder, scanInterval, remoteInterval)
|
||||
},
|
||||
}
|
||||
run.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "local scan interval")
|
||||
run.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "remote sync interval")
|
||||
c.AddCommand(run)
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/config"
|
||||
"github.com/runbear-io/sfs/internal/remote"
|
||||
"github.com/runbear-io/sfs/internal/store"
|
||||
"github.com/runbear-io/sfs/internal/syncer"
|
||||
)
|
||||
|
||||
func absFolder(args []string) (string, error) {
|
||||
arg := "."
|
||||
if len(args) > 0 {
|
||||
arg = args[0]
|
||||
}
|
||||
return filepath.Abs(arg)
|
||||
}
|
||||
|
||||
func mustMount(folder string) (config.MountInfo, error) {
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return config.MountInfo{}, err
|
||||
}
|
||||
mi, ok := mounts[folder]
|
||||
if !ok {
|
||||
return mi, fmt.Errorf("%s is not an sfs mount (run `sfs mnt %s` first)", folder, folder)
|
||||
}
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
// openSession builds a syncer session for a mounted folder. When withRemote
|
||||
// is set and the remote is unreachable, it degrades to offline with a warning
|
||||
// rather than failing.
|
||||
func openSession(ctx context.Context, folder string, withRemote bool) (*syncer.Session, config.MountInfo, error) {
|
||||
mi, err := mustMount(folder)
|
||||
if err != nil {
|
||||
return nil, mi, err
|
||||
}
|
||||
dev, err := config.LoadDevice()
|
||||
if err != nil {
|
||||
return nil, mi, err
|
||||
}
|
||||
vdir, err := config.VolumeDir(mi.Volume)
|
||||
if err != nil {
|
||||
return nil, mi, err
|
||||
}
|
||||
st, err := store.Open(vdir)
|
||||
if err != nil {
|
||||
return nil, mi, err
|
||||
}
|
||||
sess := &syncer.Session{Folder: folder, Store: st, Device: dev}
|
||||
if withRemote && mi.Remote != "" {
|
||||
be, err := remote.Open(ctx, mi.Remote)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: remote unavailable, working offline: %v\n", err)
|
||||
} else {
|
||||
sess.Backend = be
|
||||
}
|
||||
}
|
||||
return sess, mi, nil
|
||||
}
|
||||
|
||||
func closeSession(sess *syncer.Session) {
|
||||
if sess != nil && sess.Backend != nil {
|
||||
sess.Backend.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for m := n / unit; m >= unit; m /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
func printCycle(res *syncer.Result) {
|
||||
fmt.Printf(" local changes: %d\n", res.LocalOps)
|
||||
fmt.Printf(" pulled changes: %d\n", res.PulledOps)
|
||||
if res.Conflicts > 0 {
|
||||
fmt.Printf(" conflicts: %d (preserved as *.sfs-conflict-* files)\n", res.Conflicts)
|
||||
}
|
||||
fmt.Printf(" files updated: %d\n", res.Materialized)
|
||||
switch {
|
||||
case res.Offline:
|
||||
fmt.Printf(" remote: offline (%v)\n", res.OfflineErr)
|
||||
case res.Pushed:
|
||||
fmt.Printf(" remote: pushed\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// sfs is a syncing file system for AI agents: mount a folder, and its
|
||||
// contents stay synchronized across devices through cloud object storage,
|
||||
// with full per-file change history and offline support.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/config"
|
||||
)
|
||||
|
||||
// version is set at release time via -ldflags "-X main.version=...".
|
||||
var version = "0.1.0-dev"
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Use: "sfs",
|
||||
Short: "A synced file system for AI agents",
|
||||
Long: `sfs — a mountable, offline-first, synced file system for AI agents.
|
||||
|
||||
Mount any folder and sfs keeps it synchronized across your devices through
|
||||
cloud object storage (Amazon S3, Google Cloud Storage, or a plain shared
|
||||
directory). Every change is journaled — you can always see which device and
|
||||
author changed which file, and when. Files are real files on disk, so
|
||||
everything keeps working offline; changes sync when the remote is reachable.`,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
root.AddCommand(
|
||||
mntCmd(),
|
||||
umntCmd(),
|
||||
syncCmd(),
|
||||
statusCmd(),
|
||||
logCmd(),
|
||||
remoteCmd(),
|
||||
whoamiCmd(),
|
||||
daemonCmd(),
|
||||
versionCmd(),
|
||||
)
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the sfs version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("sfs", version)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func whoamiCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show this device's identity used in change tracking",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dev, err := config.LoadDevice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
home, err := config.Home()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("device id: %s\n", dev.ID)
|
||||
fmt.Printf("device name: %s\n", dev.Name)
|
||||
fmt.Printf("author: %s\n", dev.Author)
|
||||
fmt.Printf("sfs home: %s\n", home)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/config"
|
||||
"github.com/runbear-io/sfs/internal/daemon"
|
||||
"github.com/runbear-io/sfs/internal/store"
|
||||
)
|
||||
|
||||
func mntCmd() *cobra.Command {
|
||||
var remoteURL, volume string
|
||||
var foreground bool
|
||||
var scanInterval, remoteInterval time.Duration
|
||||
c := &cobra.Command{
|
||||
Use: "mnt <folder>",
|
||||
Aliases: []string{"mount"},
|
||||
Short: "Mount a folder as a synced sfs volume",
|
||||
Long: `Mount a folder as a synced sfs volume.
|
||||
|
||||
Existing files in the folder are imported into the volume. If a remote is
|
||||
configured (--remote, or previously via "sfs remote set"), the volume syncs
|
||||
with it and with every other device mounting the same remote. A background
|
||||
daemon keeps the folder in sync until "sfs umnt".`,
|
||||
Example: ` sfs mnt ./notes
|
||||
sfs mnt ./notes --remote s3://my-bucket/notes
|
||||
sfs mnt ./notes --remote gs://my-bucket/notes
|
||||
sfs mnt ./shared --remote file:///Volumes/nas/sfs/shared`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(folder, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, exists := mounts[folder]
|
||||
if exists {
|
||||
if volume != "" && volume != mi.Volume {
|
||||
return fmt.Errorf("%s is already mounted as volume %q", folder, mi.Volume)
|
||||
}
|
||||
} else {
|
||||
v := volume
|
||||
if v == "" {
|
||||
v = filepath.Base(folder)
|
||||
}
|
||||
mi = config.MountInfo{Volume: v}
|
||||
}
|
||||
if remoteURL != "" {
|
||||
mi.Remote = remoteURL
|
||||
}
|
||||
mounts[folder] = mi
|
||||
if err := config.SaveMounts(mounts); err != nil {
|
||||
return err
|
||||
}
|
||||
vdir, err := config.VolumeDir(mi.Volume)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := store.Open(vdir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dev, err := config.LoadDevice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initial cycle: import existing files, pull remote state.
|
||||
sess, _, err := openSession(cmd.Context(), folder, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := sess.Cycle(cmd.Context())
|
||||
closeSession(sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("mounted %s\n", folder)
|
||||
fmt.Printf(" volume: %s\n", mi.Volume)
|
||||
if mi.Remote != "" {
|
||||
fmt.Printf(" remote: %s\n", mi.Remote)
|
||||
} else {
|
||||
fmt.Printf(" remote: (none — local only; set one with `sfs remote set %s <url>`)\n", folder)
|
||||
}
|
||||
fmt.Printf(" device: %s (%s) as %s\n", dev.Name, dev.ID, dev.Author)
|
||||
printCycle(res)
|
||||
|
||||
if foreground {
|
||||
return daemon.Run(folder, scanInterval, remoteInterval)
|
||||
}
|
||||
pid, err := daemon.Start(folder, vdir, scanInterval, remoteInterval)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start sync daemon: %w", err)
|
||||
}
|
||||
fmt.Printf(" daemon: running (pid %d, scan %s, remote sync %s)\n", pid, scanInterval, remoteInterval)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVarP(&remoteURL, "remote", "r", "", "remote to sync with (s3://bucket/prefix, gs://bucket/prefix, file:///path)")
|
||||
c.Flags().StringVarP(&volume, "volume", "v", "", "volume name (default: folder basename)")
|
||||
c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground")
|
||||
c.Flags().DurationVar(&scanInterval, "scan-interval", 3*time.Second, "how often to scan the folder for local changes")
|
||||
c.Flags().DurationVar(&remoteInterval, "remote-interval", 30*time.Second, "how often to sync with the remote")
|
||||
return c
|
||||
}
|
||||
|
||||
func umntCmd() *cobra.Command {
|
||||
var forget bool
|
||||
c := &cobra.Command{
|
||||
Use: "umnt <folder>",
|
||||
Aliases: []string{"umount", "unmount"},
|
||||
Short: "Stop syncing a mounted folder",
|
||||
Long: `Stop the sync daemon for a folder. Files stay on disk and the volume's
|
||||
history is kept; "sfs mnt" the folder again to resume syncing.
|
||||
|
||||
With --forget the folder is also removed from the mount registry (local
|
||||
volume data under ~/.sfs/volumes is still kept).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, err := mustMount(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vdir, err := config.VolumeDir(mi.Volume)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stopped, err := daemon.Stop(vdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stopped {
|
||||
fmt.Printf("stopped sync daemon for %s\n", folder)
|
||||
} else {
|
||||
fmt.Printf("no daemon running for %s\n", folder)
|
||||
}
|
||||
if forget {
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
delete(mounts, folder)
|
||||
if err := config.SaveMounts(mounts); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("forgot mount %s (volume %q kept under ~/.sfs/volumes)\n", folder, mi.Volume)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().BoolVar(&forget, "forget", false, "also remove the folder from the mount registry")
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
module github.com/runbear-io/sfs
|
||||
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
cloud.google.com/go/storage v1.62.3
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3
|
||||
github.com/aws/smithy-go v1.27.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
google.golang.org/api v0.284.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.7.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U=
|
||||
cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY=
|
||||
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
|
||||
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
|
||||
cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY=
|
||||
cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
|
||||
cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
|
||||
github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk=
|
||||
github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
|
||||
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc=
|
||||
google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package config manages sfs's global state under the sfs home directory
|
||||
// (default ~/.sfs, overridable with $SFS_HOME): the device identity and the
|
||||
// registry of mounted folders.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Home returns the sfs home directory ($SFS_HOME or ~/.sfs).
|
||||
func Home() (string, error) {
|
||||
if h := os.Getenv("SFS_HOME"); h != "" {
|
||||
return h, nil
|
||||
}
|
||||
uh, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(uh, ".sfs"), nil
|
||||
}
|
||||
|
||||
// Device identifies this machine and its operator in journals.
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Author string `json:"author"`
|
||||
}
|
||||
|
||||
// LoadDevice loads the device identity, creating one on first use.
|
||||
func LoadDevice() (Device, error) {
|
||||
home, err := Home()
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
p := filepath.Join(home, "device.json")
|
||||
if data, err := os.ReadFile(p); err == nil {
|
||||
var d Device
|
||||
if err := json.Unmarshal(data, &d); err == nil && d.ID != "" {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
d := Device{ID: randID(), Name: hostname(), Author: detectAuthor()}
|
||||
if err := os.MkdirAll(home, 0o755); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
if err := writeJSON(p, d); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func randID() string {
|
||||
b := make([]byte, 6)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "device000000"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
h, _ := os.Hostname()
|
||||
h = strings.TrimSuffix(h, ".local")
|
||||
if h == "" {
|
||||
h = "device"
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func detectAuthor() string {
|
||||
if out, err := exec.Command("git", "config", "--get", "user.email").Output(); err == nil {
|
||||
if s := strings.TrimSpace(string(out)); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
u := os.Getenv("USER")
|
||||
if u == "" {
|
||||
if cu, err := user.Current(); err == nil {
|
||||
u = cu.Username
|
||||
}
|
||||
}
|
||||
if u == "" {
|
||||
u = "unknown"
|
||||
}
|
||||
return u + "@" + hostname()
|
||||
}
|
||||
|
||||
// MountInfo describes one mounted folder.
|
||||
type MountInfo struct {
|
||||
Volume string `json:"volume"`
|
||||
Remote string `json:"remote,omitempty"`
|
||||
}
|
||||
|
||||
func mountsPath() (string, error) {
|
||||
home, err := Home()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "mounts.json"), nil
|
||||
}
|
||||
|
||||
// LoadMounts returns the abs-folder → mount registry.
|
||||
func LoadMounts() (map[string]MountInfo, error) {
|
||||
p, err := mountsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]MountInfo{}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return out, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func SaveMounts(m map[string]MountInfo) error {
|
||||
p, err := mountsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeJSON(p, m)
|
||||
}
|
||||
|
||||
// VolumeDir returns (and creates parents for) the local store dir of a volume.
|
||||
func VolumeDir(volume string) (string, error) {
|
||||
home, err := Home()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "volumes", volume), nil
|
||||
}
|
||||
|
||||
func writeJSON(path string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".sfs-tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// 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/sfs/internal/config"
|
||||
"github.com/runbear-io/sfs/internal/remote"
|
||||
"github.com/runbear-io/sfs/internal/store"
|
||||
"github.com/runbear-io/sfs/internal/syncer"
|
||||
)
|
||||
|
||||
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 volume 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 volume 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) `sfs daemon run` process.
|
||||
func Run(folder string, scanInterval, remoteInterval time.Duration) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
mounts, err := config.LoadMounts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mi, ok := mounts[folder]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s is not an sfs mount", folder)
|
||||
}
|
||||
volDir, err := config.VolumeDir(mi.Volume)
|
||||
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 volume=%s remote=%q device=%s(%s) scan=%s sync=%s",
|
||||
folder, mi.Volume, mi.Remote, dev.Name, dev.ID, scanInterval, remoteInterval)
|
||||
|
||||
var be remote.Backend
|
||||
defer func() {
|
||||
if be != nil {
|
||||
be.Close()
|
||||
}
|
||||
}()
|
||||
var lastRemote time.Time
|
||||
|
||||
for {
|
||||
// Pick up `sfs remote set` / `sfs umnt --forget` without restarting.
|
||||
if m, err := config.LoadMounts(); err == nil {
|
||||
cur, ok := m[folder]
|
||||
if !ok {
|
||||
log.Printf("mount unregistered; exiting")
|
||||
return nil
|
||||
}
|
||||
if cur.Remote != mi.Remote {
|
||||
log.Printf("remote changed: %q -> %q", mi.Remote, cur.Remote)
|
||||
if be != nil {
|
||||
be.Close()
|
||||
be = nil
|
||||
}
|
||||
lastRemote = time.Time{}
|
||||
}
|
||||
mi = cur
|
||||
}
|
||||
|
||||
doRemote := mi.Remote != "" && time.Since(lastRemote) >= remoteInterval
|
||||
if doRemote && be == nil {
|
||||
b, err := remote.Open(ctx, mi.Remote)
|
||||
if err != nil {
|
||||
log.Printf("remote unavailable: %v", err)
|
||||
doRemote = false
|
||||
lastRemote = time.Now()
|
||||
} else {
|
||||
be = b
|
||||
}
|
||||
}
|
||||
|
||||
sess := &syncer.Session{Folder: folder, Store: st, Device: dev}
|
||||
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.Offline:
|
||||
log.Printf("offline, will retry: %v", res.OfflineErr)
|
||||
if be != nil {
|
||||
be.Close()
|
||||
be = nil
|
||||
}
|
||||
lastRemote = time.Now()
|
||||
default:
|
||||
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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package journal implements sfs's append-only operation log.
|
||||
//
|
||||
// Every change to a volume is recorded as an Op in a per-device JSONL
|
||||
// journal. Journals are append-only and each device only ever writes its
|
||||
// own journal, so syncing is conflict-free at the transport level: a sync
|
||||
// uploads your journal and downloads everyone else's. The merged view of
|
||||
// a volume is a deterministic replay of the union of all ops ordered by
|
||||
// (lamport, time, device, seq) — every device converges to the same state.
|
||||
package journal
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
KindPut = "put"
|
||||
KindDelete = "delete"
|
||||
)
|
||||
|
||||
// Op is a single journaled file operation.
|
||||
type Op struct {
|
||||
Seq int64 `json:"seq"` // per-device sequence number, 1-based
|
||||
Lamport int64 `json:"lamport"` // logical clock for cross-device ordering
|
||||
Time time.Time `json:"time"`
|
||||
Device string `json:"device"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Kind string `json:"kind"` // "put" or "delete"
|
||||
Path string `json:"path"` // slash-separated, relative to volume root
|
||||
Blob string `json:"blob,omitempty"` // sha256 hex of content (put only)
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Mode uint32 `json:"mode,omitempty"` // permission bits
|
||||
Note string `json:"note,omitempty"` // e.g. "conflict copy of <path>"
|
||||
}
|
||||
|
||||
// Less defines the total order used to replay ops from many devices.
|
||||
func Less(a, b Op) bool {
|
||||
if a.Lamport != b.Lamport {
|
||||
return a.Lamport < b.Lamport
|
||||
}
|
||||
if !a.Time.Equal(b.Time) {
|
||||
return a.Time.Before(b.Time)
|
||||
}
|
||||
if a.Device != b.Device {
|
||||
return a.Device < b.Device
|
||||
}
|
||||
return a.Seq < b.Seq
|
||||
}
|
||||
|
||||
func Sort(ops []Op) {
|
||||
sort.SliceStable(ops, func(i, j int) bool { return Less(ops[i], ops[j]) })
|
||||
}
|
||||
|
||||
// FileState is the resolved state of one path after replay.
|
||||
type FileState struct {
|
||||
Blob string
|
||||
Size int64
|
||||
Mode uint32
|
||||
}
|
||||
|
||||
// Replay folds a set of ops (from any number of devices) into the
|
||||
// resulting volume state. Last writer wins per path under the total order.
|
||||
func Replay(ops []Op) map[string]FileState {
|
||||
sorted := append([]Op(nil), ops...)
|
||||
Sort(sorted)
|
||||
state := make(map[string]FileState)
|
||||
for _, op := range sorted {
|
||||
switch op.Kind {
|
||||
case KindPut:
|
||||
state[op.Path] = FileState{Blob: op.Blob, Size: op.Size, Mode: op.Mode}
|
||||
case KindDelete:
|
||||
delete(state, op.Path)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// Parse decodes a JSONL journal.
|
||||
func Parse(data []byte) ([]Op, error) {
|
||||
var ops []Op
|
||||
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var op Op
|
||||
if err := json.Unmarshal(line, &op); err != nil {
|
||||
return nil, fmt.Errorf("parse journal line %d: %w", len(ops)+1, err)
|
||||
}
|
||||
ops = append(ops, op)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// ReadFile reads a journal file; a missing file is an empty journal.
|
||||
func ReadFile(path string) ([]Op, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return Parse(data)
|
||||
}
|
||||
|
||||
// Append appends ops to a journal file as JSONL.
|
||||
func Append(path string, ops []Op) error {
|
||||
if len(ops) == 0 {
|
||||
return nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
for _, op := range ops {
|
||||
b, err := json.Marshal(op)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf.Write(b)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package journal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func op(lamport int64, dev string, seq int64, kind, path, blob string) Op {
|
||||
return Op{
|
||||
Seq: seq, Lamport: lamport, Time: time.Unix(1000+lamport, 0).UTC(),
|
||||
Device: dev, Kind: kind, Path: path, Blob: blob,
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayLastWriterWins(t *testing.T) {
|
||||
ops := []Op{
|
||||
op(3, "b", 1, KindPut, "a.txt", "v2"),
|
||||
op(1, "a", 1, KindPut, "a.txt", "v1"),
|
||||
op(2, "a", 2, KindPut, "b.txt", "x"),
|
||||
}
|
||||
state := Replay(ops)
|
||||
if state["a.txt"].Blob != "v2" {
|
||||
t.Fatalf("want v2, got %q", state["a.txt"].Blob)
|
||||
}
|
||||
if state["b.txt"].Blob != "x" {
|
||||
t.Fatalf("want x, got %q", state["b.txt"].Blob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayDelete(t *testing.T) {
|
||||
ops := []Op{
|
||||
op(1, "a", 1, KindPut, "a.txt", "v1"),
|
||||
op(2, "b", 1, KindDelete, "a.txt", ""),
|
||||
}
|
||||
if state := Replay(ops); len(state) != 0 {
|
||||
t.Fatalf("expected empty state, got %v", state)
|
||||
}
|
||||
// delete then put resurrects
|
||||
ops = append(ops, op(3, "a", 2, KindPut, "a.txt", "v3"))
|
||||
if state := Replay(ops); state["a.txt"].Blob != "v3" {
|
||||
t.Fatalf("expected v3 after resurrection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderTieBreak(t *testing.T) {
|
||||
// same lamport + time: device id breaks the tie deterministically
|
||||
a := op(5, "aaa", 1, KindPut, "f", "from-a")
|
||||
b := op(5, "bbb", 1, KindPut, "f", "from-b")
|
||||
a.Time = b.Time
|
||||
if state := Replay([]Op{a, b}); state["f"].Blob != "from-b" {
|
||||
t.Fatalf("want from-b (higher device id wins tie), got %q", state["f"].Blob)
|
||||
}
|
||||
if state := Replay([]Op{b, a}); state["f"].Blob != "from-b" {
|
||||
t.Fatalf("order of input must not matter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRead(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "dev.jsonl")
|
||||
ops := []Op{
|
||||
op(1, "a", 1, KindPut, "x.txt", "blob1"),
|
||||
op(2, "a", 2, KindDelete, "x.txt", ""),
|
||||
}
|
||||
if err := Append(p, ops[:1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Append(p, ops[1:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[0].Blob != "blob1" || got[1].Kind != KindDelete {
|
||||
t.Fatalf("roundtrip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMissingFile(t *testing.T) {
|
||||
got, err := ReadFile(filepath.Join(t.TempDir(), "nope.jsonl"))
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("missing journal should be empty, got %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkipsBlankLines(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "j.jsonl")
|
||||
os.WriteFile(p, []byte("\n{\"seq\":1,\"kind\":\"put\",\"path\":\"a\"}\n\n"), 0o644)
|
||||
got, err := ReadFile(p)
|
||||
if err != nil || len(got) != 1 {
|
||||
t.Fatalf("got %v %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
gcs "cloud.google.com/go/storage"
|
||||
"google.golang.org/api/iterator"
|
||||
)
|
||||
|
||||
// gcsBackend stores objects in Google Cloud Storage using Application
|
||||
// Default Credentials (gcloud auth application-default login, or a service
|
||||
// account via GOOGLE_APPLICATION_CREDENTIALS).
|
||||
type gcsBackend struct {
|
||||
client *gcs.Client
|
||||
bucket *gcs.BucketHandle
|
||||
prefix string
|
||||
}
|
||||
|
||||
func newGCS(ctx context.Context, bucket, prefix string) (*gcsBackend, error) {
|
||||
if bucket == "" {
|
||||
return nil, fmt.Errorf("gs remote needs a bucket: gs://bucket/prefix")
|
||||
}
|
||||
client, err := gcs.NewClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create GCS client: %w", err)
|
||||
}
|
||||
return &gcsBackend{client: client, bucket: client.Bucket(bucket), prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func (b *gcsBackend) key(key string) string {
|
||||
if b.prefix == "" {
|
||||
return key
|
||||
}
|
||||
return path.Join(b.prefix, key)
|
||||
}
|
||||
|
||||
func (b *gcsBackend) Put(ctx context.Context, key string, r io.Reader, _ int64) error {
|
||||
w := b.bucket.Object(b.key(key)).NewWriter(ctx)
|
||||
if _, err := io.Copy(w, r); err != nil {
|
||||
w.Close()
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
func (b *gcsBackend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
return b.bucket.Object(b.key(key)).NewReader(ctx)
|
||||
}
|
||||
|
||||
func (b *gcsBackend) List(ctx context.Context, prefix string) ([]Object, error) {
|
||||
it := b.bucket.Objects(ctx, &gcs.Query{Prefix: b.key(prefix)})
|
||||
strip := b.prefix
|
||||
if strip != "" {
|
||||
strip += "/"
|
||||
}
|
||||
var out []Object
|
||||
for {
|
||||
attrs, err := it.Next()
|
||||
if errors.Is(err, iterator.Done) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, Object{Key: strings.TrimPrefix(attrs.Name, strip), Size: attrs.Size})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *gcsBackend) Exists(ctx context.Context, key string) (bool, error) {
|
||||
_, err := b.bucket.Object(b.key(key)).Attrs(ctx)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, gcs.ErrObjectNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (b *gcsBackend) Close() error { return b.client.Close() }
|
||||
@@ -0,0 +1,95 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// localBackend stores objects in a plain directory. Useful for tests and for
|
||||
// syncing through any mounted network drive.
|
||||
type localBackend struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func newLocal(root string) (*localBackend, error) {
|
||||
if root == "" {
|
||||
return nil, fmt.Errorf("file:// remote needs an absolute path")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &localBackend{root: root}, nil
|
||||
}
|
||||
|
||||
func (b *localBackend) path(key string) string {
|
||||
return filepath.Join(b.root, filepath.FromSlash(key))
|
||||
}
|
||||
|
||||
func (b *localBackend) Put(_ context.Context, key string, r io.Reader, _ int64) error {
|
||||
dst := b.path(key)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), ".sfs-tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, r); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), dst)
|
||||
}
|
||||
|
||||
func (b *localBackend) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
||||
return os.Open(b.path(key))
|
||||
}
|
||||
|
||||
func (b *localBackend) List(_ context.Context, prefix string) ([]Object, error) {
|
||||
var out []Object
|
||||
err := filepath.WalkDir(b.root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(d.Name(), ".sfs-tmp-") {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(b.root, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
key := filepath.ToSlash(rel)
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out = append(out, Object{Key: key, Size: info.Size()})
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (b *localBackend) Exists(_ context.Context, key string) (bool, error) {
|
||||
_, err := os.Stat(b.path(key))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (b *localBackend) Close() error { return nil }
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package remote abstracts the cloud object store a volume syncs through.
|
||||
// sfs is provider-agnostic: any backend that can put/get/list immutable
|
||||
// objects works. Built-in schemes:
|
||||
//
|
||||
// file:///abs/path local or network-drive directory (also used in tests)
|
||||
// s3://bucket/prefix Amazon S3 (or S3-compatible via AWS_ENDPOINT_URL)
|
||||
// gs://bucket/prefix Google Cloud Storage
|
||||
//
|
||||
// Remote layout: blobs/<sha256> for content, journal/<device>.jsonl for op
|
||||
// logs. Each device writes only its own journal, so there are no concurrent
|
||||
// writers per object and no server-side coordination is needed.
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Object struct {
|
||||
Key string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type Backend interface {
|
||||
Put(ctx context.Context, key string, r io.Reader, size int64) error
|
||||
Get(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
List(ctx context.Context, prefix string) ([]Object, error)
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Open creates a backend from a remote URL.
|
||||
func Open(ctx context.Context, raw string) (Backend, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid remote %q: %w", raw, err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "file":
|
||||
return newLocal(u.Path)
|
||||
case "s3":
|
||||
return newS3(ctx, u.Host, strings.Trim(u.Path, "/"))
|
||||
case "gs":
|
||||
return newGCS(ctx, u.Host, strings.Trim(u.Path, "/"))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported remote scheme %q (supported: file://, s3://, gs://)", u.Scheme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
// s3Backend stores objects in Amazon S3 (or any S3-compatible store via the
|
||||
// standard AWS_ENDPOINT_URL / AWS_PROFILE environment configuration).
|
||||
type s3Backend struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
prefix string
|
||||
}
|
||||
|
||||
func newS3(ctx context.Context, bucket, prefix string) (*s3Backend, error) {
|
||||
if bucket == "" {
|
||||
return nil, fmt.Errorf("s3 remote needs a bucket: s3://bucket/prefix")
|
||||
}
|
||||
cfg, err := awsconfig.LoadDefaultConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load AWS config: %w", err)
|
||||
}
|
||||
return &s3Backend{client: s3.NewFromConfig(cfg), bucket: bucket, prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func (b *s3Backend) key(key string) string {
|
||||
if b.prefix == "" {
|
||||
return key
|
||||
}
|
||||
return path.Join(b.prefix, key)
|
||||
}
|
||||
|
||||
func (b *s3Backend) Put(ctx context.Context, key string, r io.Reader, size int64) error {
|
||||
_, err := b.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(b.key(key)),
|
||||
Body: r,
|
||||
ContentLength: aws.Int64(size),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (b *s3Backend) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
out, err := b.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(b.key(key)),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Body, nil
|
||||
}
|
||||
|
||||
func (b *s3Backend) List(ctx context.Context, prefix string) ([]Object, error) {
|
||||
full := b.key(prefix)
|
||||
var out []Object
|
||||
p := s3.NewListObjectsV2Paginator(b.client, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Prefix: aws.String(full),
|
||||
})
|
||||
strip := b.prefix
|
||||
if strip != "" {
|
||||
strip += "/"
|
||||
}
|
||||
for p.HasMorePages() {
|
||||
page, err := p.NextPage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range page.Contents {
|
||||
key := strings.TrimPrefix(aws.ToString(o.Key), strip)
|
||||
out = append(out, Object{Key: key, Size: aws.ToInt64(o.Size)})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *s3Backend) Exists(ctx context.Context, key string) (bool, error) {
|
||||
_, err := b.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(b.bucket),
|
||||
Key: aws.String(b.key(key)),
|
||||
})
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
var nf *types.NotFound
|
||||
if errors.As(err, &nf) {
|
||||
return false, nil
|
||||
}
|
||||
var ae smithy.APIError
|
||||
if errors.As(err, &ae) && (ae.ErrorCode() == "NotFound" || ae.ErrorCode() == "404") {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (b *s3Backend) Close() error { return nil }
|
||||
@@ -0,0 +1,255 @@
|
||||
// Package store manages a volume's local on-disk state: a content-addressed
|
||||
// blob store, the per-device journals, and small JSON state files. Everything
|
||||
// a volume needs works offline; the remote is only used to exchange blobs and
|
||||
// journals.
|
||||
//
|
||||
// Layout under <sfs home>/volumes/<volume>/:
|
||||
//
|
||||
// blobs/<aa>/<sha256> content-addressed file contents (immutable)
|
||||
// journal/<device>.jsonl per-device op logs (own + cached copies of peers)
|
||||
// state.json what is currently materialized in the folder
|
||||
// sync.json lamport clock + push cursor
|
||||
// lock flock guarding cycles
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/journal"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
func Open(dir string) (*Store, error) {
|
||||
for _, d := range []string{dir, filepath.Join(dir, "blobs"), filepath.Join(dir, "journal"), filepath.Join(dir, "tmp")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
func (s *Store) tmpDir() string { return filepath.Join(s.dir, "tmp") }
|
||||
|
||||
// ---- blobs ----
|
||||
|
||||
func (s *Store) BlobPath(sum string) string {
|
||||
return filepath.Join(s.dir, "blobs", sum[:2], sum)
|
||||
}
|
||||
|
||||
func (s *Store) HasBlob(sum string) bool {
|
||||
if len(sum) < 3 {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(s.BlobPath(sum))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// PutBlobReader streams r into the blob store, returning its sha256 and size.
|
||||
func (s *Store) PutBlobReader(r io.Reader) (string, int64, error) {
|
||||
tmp, err := os.CreateTemp(s.tmpDir(), "blob-*")
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(tmp, h), r)
|
||||
if cerr := tmp.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
if s.HasBlob(sum) {
|
||||
return sum, n, nil
|
||||
}
|
||||
dst := s.BlobPath(sum)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return sum, n, nil
|
||||
}
|
||||
|
||||
func (s *Store) PutBlobFile(path string) (string, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
return s.PutBlobReader(f)
|
||||
}
|
||||
|
||||
func (s *Store) PutBlobBytes(b []byte) (string, int64, error) {
|
||||
return s.PutBlobReader(strings.NewReader(string(b)))
|
||||
}
|
||||
|
||||
func (s *Store) OpenBlob(sum string) (*os.File, error) {
|
||||
return os.Open(s.BlobPath(sum))
|
||||
}
|
||||
|
||||
// ---- journals ----
|
||||
|
||||
func (s *Store) JournalPath(device string) string {
|
||||
return filepath.Join(s.dir, "journal", device+".jsonl")
|
||||
}
|
||||
|
||||
func (s *Store) AppendOps(device string, ops []journal.Op) error {
|
||||
return journal.Append(s.JournalPath(device), ops)
|
||||
}
|
||||
|
||||
func (s *Store) DeviceOps(device string) ([]journal.Op, error) {
|
||||
return journal.ReadFile(s.JournalPath(device))
|
||||
}
|
||||
|
||||
// Devices lists device IDs that have a local journal copy.
|
||||
func (s *Store) Devices() ([]string, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(s.dir, "journal"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".jsonl") {
|
||||
out = append(out, strings.TrimSuffix(e.Name(), ".jsonl"))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AllOps returns the union of every journal known locally.
|
||||
func (s *Store) AllOps() ([]journal.Op, error) {
|
||||
devs, err := s.Devices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []journal.Op
|
||||
for _, d := range devs {
|
||||
ops, err := s.DeviceOps(d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("journal %s: %w", d, err)
|
||||
}
|
||||
all = append(all, ops...)
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ---- materialized-state cache (state.json) ----
|
||||
|
||||
// CachedFile records what sfs last wrote to / observed in the working folder
|
||||
// for a path. Size+MTimeNS make change detection cheap; Blob ties it back to
|
||||
// content.
|
||||
type CachedFile struct {
|
||||
Blob string `json:"blob"`
|
||||
Size int64 `json:"size"`
|
||||
Mode uint32 `json:"mode"`
|
||||
MTimeNS int64 `json:"mtime_ns"`
|
||||
}
|
||||
|
||||
func (s *Store) LoadCache() (map[string]CachedFile, error) {
|
||||
out := map[string]CachedFile{}
|
||||
if err := readJSON(filepath.Join(s.dir, "state.json"), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveCache(c map[string]CachedFile) error {
|
||||
return WriteJSONAtomic(filepath.Join(s.dir, "state.json"), c)
|
||||
}
|
||||
|
||||
// ---- sync state (sync.json) ----
|
||||
|
||||
type SyncState struct {
|
||||
Lamport int64 `json:"lamport"`
|
||||
PushedOps int64 `json:"pushed_ops"` // how many of our own ops the remote has
|
||||
}
|
||||
|
||||
func (s *Store) LoadSync() (SyncState, error) {
|
||||
var st SyncState
|
||||
if err := readJSON(filepath.Join(s.dir, "sync.json"), &st); err != nil {
|
||||
return st, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveSync(st SyncState) error {
|
||||
return WriteJSONAtomic(filepath.Join(s.dir, "sync.json"), st)
|
||||
}
|
||||
|
||||
// ---- locking ----
|
||||
|
||||
// Lock takes an exclusive flock for the volume, serializing sync cycles
|
||||
// between the daemon and one-shot commands. Blocks until acquired.
|
||||
func (s *Store) Lock() (func() error, error) {
|
||||
f, err := os.OpenFile(filepath.Join(s.dir, "lock"), os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
return func() error {
|
||||
defer f.Close()
|
||||
return syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- small JSON helpers ----
|
||||
|
||||
func readJSON(path string, v any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
// WriteJSONAtomic writes v as JSON via temp-file + rename.
|
||||
func WriteJSONAtomic(path string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return WriteFileAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
// WriteFileAtomic writes data via a temp file in the same directory + rename.
|
||||
func WriteFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".sfs-tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/journal"
|
||||
)
|
||||
|
||||
func TestBlobRoundtrip(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum, n, err := s.PutBlobBytes([]byte("hello sfs"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 9 {
|
||||
t.Fatalf("size = %d, want 9", n)
|
||||
}
|
||||
if !s.HasBlob(sum) {
|
||||
t.Fatal("blob not stored")
|
||||
}
|
||||
// dedupe: same content, same sum, no error
|
||||
sum2, _, err := s.PutBlobBytes([]byte("hello sfs"))
|
||||
if err != nil || sum2 != sum {
|
||||
t.Fatalf("dedupe failed: %v %v", sum2, err)
|
||||
}
|
||||
f, err := s.OpenBlob(sum)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
data := make([]byte, 16)
|
||||
k, _ := f.Read(data)
|
||||
if string(data[:k]) != "hello sfs" {
|
||||
t.Fatalf("content mismatch: %q", data[:k])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalAndState(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ops := []journal.Op{{Seq: 1, Lamport: 1, Device: "devA", Kind: journal.KindPut, Path: "f", Blob: "b"}}
|
||||
if err := s.AppendOps("devA", ops); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AppendOps("devB", []journal.Op{{Seq: 1, Lamport: 2, Device: "devB", Kind: journal.KindDelete, Path: "f"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err := s.AllOps()
|
||||
if err != nil || len(all) != 2 {
|
||||
t.Fatalf("AllOps = %v, %v", all, err)
|
||||
}
|
||||
devs, _ := s.Devices()
|
||||
if len(devs) != 2 {
|
||||
t.Fatalf("Devices = %v", devs)
|
||||
}
|
||||
|
||||
cache := map[string]CachedFile{"f": {Blob: "b", Size: 1, MTimeNS: 42}}
|
||||
if err := s.SaveCache(cache); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.LoadCache()
|
||||
if err != nil || got["f"].MTimeNS != 42 {
|
||||
t.Fatalf("cache roundtrip: %v %v", got, err)
|
||||
}
|
||||
|
||||
st := SyncState{Lamport: 7, PushedOps: 3}
|
||||
if err := s.SaveSync(st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotSt, err := s.LoadSync()
|
||||
if err != nil || gotSt != st {
|
||||
t.Fatalf("sync state roundtrip: %v %v", gotSt, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLock(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock, err := s.Lock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unlock(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// re-acquirable after unlock
|
||||
unlock2, err := s.Lock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unlock2()
|
||||
}
|
||||
|
||||
func TestWriteFileAtomic(t *testing.T) {
|
||||
p := t.TempDir() + "/x.json"
|
||||
if err := WriteFileAtomic(p, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil || string(b) != "data" {
|
||||
t.Fatalf("got %q %v", b, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
// Package syncer drives a volume's sync cycle:
|
||||
//
|
||||
// scan → commit local ops → pull peer journals → preserve conflicts →
|
||||
// materialize merged state → push blobs + own journal
|
||||
//
|
||||
// Scanning always happens before pulling, so local edits are committed to the
|
||||
// journal (and their content captured in the blob store) before any remote
|
||||
// state can overwrite the working folder. Concurrent edits resolve
|
||||
// deterministically last-writer-wins; the losing local version is preserved
|
||||
// as a "<name>.sfs-conflict-<device>-<time>" file that syncs like any other.
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/config"
|
||||
"github.com/runbear-io/sfs/internal/journal"
|
||||
"github.com/runbear-io/sfs/internal/remote"
|
||||
"github.com/runbear-io/sfs/internal/store"
|
||||
)
|
||||
|
||||
// Session ties a working folder to its volume store and (optionally) remote.
|
||||
type Session struct {
|
||||
Folder string
|
||||
Store *store.Store
|
||||
Device config.Device
|
||||
Backend remote.Backend // nil = work offline
|
||||
}
|
||||
|
||||
// Result summarizes one sync cycle.
|
||||
type Result struct {
|
||||
LocalOps int // local changes committed to the journal
|
||||
PulledOps int // ops received from other devices
|
||||
Conflicts int // conflict copies created
|
||||
Materialized int // files written/removed in the working folder
|
||||
Pushed bool // own journal/blobs uploaded
|
||||
Offline bool // remote configured but unreachable this cycle
|
||||
OfflineErr error
|
||||
}
|
||||
|
||||
func (r *Result) Activity() bool {
|
||||
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Materialized > 0
|
||||
}
|
||||
|
||||
var ignoreNames = map[string]bool{".DS_Store": true}
|
||||
var ignoreDirs = map[string]bool{".git": true, ".sfs": true}
|
||||
|
||||
func ignoredFile(name string) bool {
|
||||
return ignoreNames[name] || strings.HasPrefix(name, ".sfs-tmp-")
|
||||
}
|
||||
|
||||
// Cycle runs one full scan/sync/materialize pass under the volume lock.
|
||||
func (s *Session) Cycle(ctx context.Context) (*Result, error) {
|
||||
unlock, err := s.Store.Lock()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock volume: %w", err)
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
res := &Result{}
|
||||
cache, err := s.Store.LoadCache()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load state: %w", err)
|
||||
}
|
||||
st, err := s.Store.LoadSync()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load sync state: %w", err)
|
||||
}
|
||||
myOps, err := s.Store.DeviceOps(s.Device.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read own journal: %w", err)
|
||||
}
|
||||
|
||||
// 1. Scan the working folder and journal any local changes.
|
||||
localOps, err := s.scan(cache, &st, int64(len(myOps)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
if len(localOps) > 0 {
|
||||
if err := s.Store.AppendOps(s.Device.ID, localOps); err != nil {
|
||||
return nil, fmt.Errorf("append journal: %w", err)
|
||||
}
|
||||
myOps = append(myOps, localOps...)
|
||||
res.LocalOps = len(localOps)
|
||||
}
|
||||
|
||||
// 2. Pull journals + blobs from other devices.
|
||||
var pulled []journal.Op
|
||||
if s.Backend != nil {
|
||||
pulled, err = s.pull(ctx)
|
||||
if err != nil {
|
||||
res.Offline = true
|
||||
res.OfflineErr = err
|
||||
}
|
||||
res.PulledOps = len(pulled)
|
||||
for _, op := range pulled {
|
||||
if op.Lamport > st.Lamport {
|
||||
st.Lamport = op.Lamport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Preserve losing local edits as conflict copies.
|
||||
if len(pulled) > 0 {
|
||||
conflictOps, err := s.conflictCopies(myOps, st.PushedOps, pulled, &st)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(conflictOps) > 0 {
|
||||
if err := s.Store.AppendOps(s.Device.ID, conflictOps); err != nil {
|
||||
return nil, fmt.Errorf("append conflict ops: %w", err)
|
||||
}
|
||||
myOps = append(myOps, conflictOps...)
|
||||
res.Conflicts = len(conflictOps)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Materialize the merged state into the working folder.
|
||||
all, err := s.Store.AllOps()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read journals: %w", err)
|
||||
}
|
||||
target := journal.Replay(all)
|
||||
n, err := s.materialize(target, cache)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("materialize: %w", err)
|
||||
}
|
||||
res.Materialized = n
|
||||
|
||||
// 5. Push our blobs and journal.
|
||||
if s.Backend != nil && !res.Offline && int64(len(myOps)) > st.PushedOps {
|
||||
if err := s.push(ctx, myOps, &st); err != nil {
|
||||
res.Offline = true
|
||||
res.OfflineErr = err
|
||||
} else {
|
||||
res.Pushed = true
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.Store.SaveCache(cache); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Store.SaveSync(st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// scan diffs the working folder against the state cache and returns ops for
|
||||
// every local change, storing new content in the blob store.
|
||||
func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, seqBase int64) ([]journal.Op, error) {
|
||||
seen := make(map[string]bool, len(cache))
|
||||
var ops []journal.Op
|
||||
nextOp := func(kind, rel string) journal.Op {
|
||||
st.Lamport++
|
||||
seqBase++
|
||||
return journal.Op{
|
||||
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
|
||||
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
|
||||
Kind: kind, Path: rel,
|
||||
}
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(s.Folder, func(p string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return nil // skip unreadable entries
|
||||
}
|
||||
rel, err := filepath.Rel(s.Folder, p)
|
||||
if err != nil || rel == "." {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
if ignoreDirs[d.Name()] {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !d.Type().IsRegular() || ignoredFile(d.Name()) {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen[rel] = true
|
||||
size, mt := info.Size(), info.ModTime().UnixNano()
|
||||
mode := uint32(info.Mode().Perm())
|
||||
c, ok := cache[rel]
|
||||
if ok && c.Size == size && c.MTimeNS == mt {
|
||||
return nil // unchanged (cheap path)
|
||||
}
|
||||
sum, n, err := s.Store.PutBlobFile(p)
|
||||
if err != nil {
|
||||
return nil // file vanished or unreadable; next cycle
|
||||
}
|
||||
if ok && c.Blob == sum {
|
||||
// content unchanged, just touched
|
||||
c.Size, c.MTimeNS, c.Mode = n, mt, mode
|
||||
cache[rel] = c
|
||||
return nil
|
||||
}
|
||||
op := nextOp(journal.KindPut, rel)
|
||||
op.Blob, op.Size, op.Mode = sum, n, mode
|
||||
ops = append(ops, op)
|
||||
cache[rel] = store.CachedFile{Blob: sum, Size: n, Mode: mode, MTimeNS: mt}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rel := range cache {
|
||||
if !seen[rel] {
|
||||
ops = append(ops, nextOp(journal.KindDelete, rel))
|
||||
delete(cache, rel)
|
||||
}
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// pull fetches journals that grew on the remote and any blobs we are missing
|
||||
// for the new ops. Returns only the ops we had not seen before.
|
||||
func (s *Session) pull(ctx context.Context) ([]journal.Op, error) {
|
||||
objs, err := s.Backend.List(ctx, "journal/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var newOps []journal.Op
|
||||
for _, o := range objs {
|
||||
name := strings.TrimPrefix(o.Key, "journal/")
|
||||
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, "/") {
|
||||
continue
|
||||
}
|
||||
dev := strings.TrimSuffix(name, ".jsonl")
|
||||
if dev == s.Device.ID {
|
||||
continue
|
||||
}
|
||||
lp := s.Store.JournalPath(dev)
|
||||
var localSize int64
|
||||
if fi, err := os.Stat(lp); err == nil {
|
||||
localSize = fi.Size()
|
||||
}
|
||||
if o.Size <= localSize && localSize > 0 {
|
||||
continue
|
||||
}
|
||||
rc, err := s.Backend.Get(ctx, o.Key)
|
||||
if err != nil {
|
||||
return newOps, err
|
||||
}
|
||||
data, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return newOps, err
|
||||
}
|
||||
fresh, err := journal.Parse(data)
|
||||
if err != nil {
|
||||
continue // corrupt remote journal; ignore rather than break sync
|
||||
}
|
||||
prev, err := s.Store.DeviceOps(dev)
|
||||
if err != nil {
|
||||
return newOps, err
|
||||
}
|
||||
if len(fresh) <= len(prev) {
|
||||
continue
|
||||
}
|
||||
if err := store.WriteFileAtomic(lp, data, 0o644); err != nil {
|
||||
return newOps, err
|
||||
}
|
||||
newOps = append(newOps, fresh[len(prev):]...)
|
||||
}
|
||||
|
||||
// Fetch content for new ops. Blobs are uploaded before journals on push,
|
||||
// so anything referenced should exist.
|
||||
for _, op := range newOps {
|
||||
if op.Kind != journal.KindPut || op.Blob == "" || s.Store.HasBlob(op.Blob) {
|
||||
continue
|
||||
}
|
||||
rc, err := s.Backend.Get(ctx, "blobs/"+op.Blob)
|
||||
if err != nil {
|
||||
return newOps, fmt.Errorf("fetch blob %s: %w", op.Blob[:12], err)
|
||||
}
|
||||
sum, _, err := s.Store.PutBlobReader(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return newOps, err
|
||||
}
|
||||
if sum != op.Blob {
|
||||
return newOps, fmt.Errorf("blob %s corrupt on remote (got %s)", op.Blob[:12], sum[:12])
|
||||
}
|
||||
}
|
||||
return newOps, nil
|
||||
}
|
||||
|
||||
// conflictCopies detects paths edited concurrently — we hold a not-yet-pushed
|
||||
// op and just pulled a competing op for the same path. Last-writer-wins
|
||||
// resolves the path itself deterministically; here the device that observed
|
||||
// the concurrency preserves the losing version (ours or the pulled one) as a
|
||||
// conflict-copy file so no content is silently dropped.
|
||||
func (s *Session) conflictCopies(myOps []journal.Op, pushed int64, pulled []journal.Op, st *store.SyncState) ([]journal.Op, error) {
|
||||
if pushed > int64(len(myOps)) {
|
||||
pushed = int64(len(myOps))
|
||||
}
|
||||
unpushed := map[string]journal.Op{}
|
||||
for _, op := range myOps[pushed:] {
|
||||
unpushed[op.Path] = op // latest local op per path
|
||||
}
|
||||
pulledLatest := map[string]journal.Op{}
|
||||
for _, op := range pulled {
|
||||
if _, ok := unpushed[op.Path]; !ok {
|
||||
continue
|
||||
}
|
||||
if prev, ok := pulledLatest[op.Path]; !ok || journal.Less(prev, op) {
|
||||
pulledLatest[op.Path] = op
|
||||
}
|
||||
}
|
||||
if len(pulledLatest) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
all, err := s.Store.AllOps()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := journal.Replay(all)
|
||||
seqBase := int64(len(myOps))
|
||||
var out []journal.Op
|
||||
for p, theirs := range pulledLatest {
|
||||
mine := unpushed[p]
|
||||
cur, exists := state[p]
|
||||
mineWon := (mine.Kind == journal.KindPut && exists && cur.Blob == mine.Blob) ||
|
||||
(mine.Kind == journal.KindDelete && !exists)
|
||||
loser := mine
|
||||
if mineWon {
|
||||
loser = theirs
|
||||
}
|
||||
if loser.Kind != journal.KindPut || loser.Blob == "" {
|
||||
continue // a lost delete needs no preservation
|
||||
}
|
||||
if exists && cur.Blob == loser.Blob {
|
||||
continue // identical content; nothing actually lost
|
||||
}
|
||||
if !s.Store.HasBlob(loser.Blob) {
|
||||
continue // content unavailable (partial pull); skip rather than fail
|
||||
}
|
||||
st.Lamport++
|
||||
seqBase++
|
||||
out = append(out, journal.Op{
|
||||
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
|
||||
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
|
||||
Kind: journal.KindPut, Path: conflictName(p, loser.DeviceName, loser.Time),
|
||||
Blob: loser.Blob, Size: loser.Size, Mode: loser.Mode,
|
||||
Note: "conflict copy of " + p,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func conflictName(p, deviceName string, t time.Time) string {
|
||||
return p + ".sfs-conflict-" + sanitize(deviceName) + "-" + t.UTC().Format("20060102T150405Z")
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, s)
|
||||
}
|
||||
|
||||
// materialize applies the merged state to the working folder, never
|
||||
// clobbering files that changed since the scan earlier in this cycle.
|
||||
func (s *Session) materialize(target map[string]journal.FileState, cache map[string]store.CachedFile) (int, error) {
|
||||
changed := 0
|
||||
for rel, want := range target {
|
||||
c, ok := cache[rel]
|
||||
if ok && c.Blob == want.Blob && c.Mode == want.Mode {
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
|
||||
if fi, err := os.Stat(abs); err == nil {
|
||||
if ok && (fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS) {
|
||||
continue // dirty: changed mid-cycle, next scan commits it
|
||||
}
|
||||
if !ok {
|
||||
// Untracked file already at this path: adopt if identical,
|
||||
// otherwise leave it for the next scan to journal.
|
||||
sum, err := hashFile(abs)
|
||||
if err != nil || sum != want.Blob {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if !s.Store.HasBlob(want.Blob) {
|
||||
continue // content not fetched yet; retry next cycle
|
||||
}
|
||||
if err := s.writeFile(abs, want); err != nil {
|
||||
return changed, fmt.Errorf("write %s: %w", rel, err)
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
|
||||
changed++
|
||||
}
|
||||
|
||||
for rel, c := range cache {
|
||||
if _, ok := target[rel]; ok {
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
|
||||
if fi, err := os.Stat(abs); err == nil {
|
||||
if fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS {
|
||||
continue // dirty; do not delete fresh local edits
|
||||
}
|
||||
if err := os.Remove(abs); err != nil {
|
||||
return changed, err
|
||||
}
|
||||
pruneEmptyDirs(s.Folder, filepath.Dir(abs))
|
||||
}
|
||||
delete(cache, rel)
|
||||
changed++
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *Session) writeFile(abs string, want journal.FileState) error {
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := s.Store.OpenBlob(want.Blob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
tmp, err := os.CreateTemp(filepath.Dir(abs), ".sfs-tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err := io.Copy(tmp, src); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
mode := os.FileMode(want.Mode)
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), abs)
|
||||
}
|
||||
|
||||
// push uploads blobs referenced by unpushed ops, then the journal itself.
|
||||
// Blob-before-journal ordering means peers never see an op whose content is
|
||||
// missing.
|
||||
func (s *Session) push(ctx context.Context, myOps []journal.Op, st *store.SyncState) error {
|
||||
if st.PushedOps > int64(len(myOps)) {
|
||||
st.PushedOps = int64(len(myOps))
|
||||
}
|
||||
uploaded := map[string]bool{}
|
||||
for _, op := range myOps[st.PushedOps:] {
|
||||
if op.Kind != journal.KindPut || op.Blob == "" || uploaded[op.Blob] {
|
||||
continue
|
||||
}
|
||||
key := "blobs/" + op.Blob
|
||||
ok, err := s.Backend.Exists(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
f, err := s.Store.OpenBlob(op.Blob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
err = s.Backend.Put(ctx, key, f, fi.Size())
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
uploaded[op.Blob] = true
|
||||
}
|
||||
|
||||
jp := s.Store.JournalPath(s.Device.ID)
|
||||
f, err := os.Open(jp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Backend.Put(ctx, "journal/"+s.Device.ID+".jsonl", f, fi.Size()); err != nil {
|
||||
return err
|
||||
}
|
||||
st.PushedOps = int64(len(myOps))
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func pruneEmptyDirs(root, dir string) {
|
||||
root = filepath.Clean(root)
|
||||
for {
|
||||
dir = filepath.Clean(dir)
|
||||
if dir == root || !strings.HasPrefix(dir, root+string(filepath.Separator)) {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) > 0 {
|
||||
return
|
||||
}
|
||||
if err := os.Remove(dir); err != nil {
|
||||
return
|
||||
}
|
||||
dir = filepath.Dir(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// LogEntries returns the volume history, newest first.
|
||||
func LogEntries(st *store.Store, pathFilter string, limit int) ([]journal.Op, error) {
|
||||
all, err := st.AllOps()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
journal.Sort(all)
|
||||
// reverse
|
||||
for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 {
|
||||
all[i], all[j] = all[j], all[i]
|
||||
}
|
||||
if pathFilter != "" {
|
||||
filtered := all[:0]
|
||||
for _, op := range all {
|
||||
if op.Path == pathFilter || strings.HasPrefix(op.Path, pathFilter+"/") || path.Dir(op.Path) == pathFilter {
|
||||
filtered = append(filtered, op)
|
||||
}
|
||||
}
|
||||
all = filtered
|
||||
}
|
||||
if limit > 0 && len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/sfs/internal/config"
|
||||
"github.com/runbear-io/sfs/internal/remote"
|
||||
"github.com/runbear-io/sfs/internal/store"
|
||||
)
|
||||
|
||||
// newDevice simulates one device: its own folder, volume store, and identity,
|
||||
// all syncing through a shared file:// remote.
|
||||
func newDevice(t *testing.T, name string, backend remote.Backend) *Session {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Session{
|
||||
Folder: t.TempDir(),
|
||||
Store: st,
|
||||
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
|
||||
Backend: backend,
|
||||
}
|
||||
}
|
||||
|
||||
func sharedRemote(t *testing.T) remote.Backend {
|
||||
t.Helper()
|
||||
be, err := remote.Open(context.Background(), "file://"+t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return be
|
||||
}
|
||||
|
||||
func write(t *testing.T, folder, rel, content string) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(folder, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func read(t *testing.T, folder, rel string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(folder, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", rel, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func cycle(t *testing.T, s *Session) *Result {
|
||||
t.Helper()
|
||||
res, err := s.Cycle(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Offline {
|
||||
t.Fatalf("unexpected offline: %v", res.OfflineErr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func TestOfflineCycle(t *testing.T) {
|
||||
a := newDevice(t, "deva", nil)
|
||||
write(t, a.Folder, "notes/hello.md", "hi")
|
||||
res := cycle(t, a)
|
||||
if res.LocalOps != 1 {
|
||||
t.Fatalf("LocalOps = %d, want 1", res.LocalOps)
|
||||
}
|
||||
// idempotent: second cycle sees no changes
|
||||
res = cycle(t, a)
|
||||
if res.Activity() {
|
||||
t.Fatalf("second cycle should be quiet, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoDeviceSync(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
// A creates files, B receives them
|
||||
write(t, a.Folder, "doc.txt", "v1")
|
||||
write(t, a.Folder, "sub/nested.txt", "deep")
|
||||
cycle(t, a)
|
||||
res := cycle(t, b)
|
||||
if res.PulledOps != 2 || res.Materialized != 2 {
|
||||
t.Fatalf("b pull: %+v", res)
|
||||
}
|
||||
if read(t, b.Folder, "doc.txt") != "v1" || read(t, b.Folder, "sub/nested.txt") != "deep" {
|
||||
t.Fatal("content mismatch after sync")
|
||||
}
|
||||
|
||||
// B edits, A receives the update
|
||||
time.Sleep(10 * time.Millisecond) // ensure mtime moves
|
||||
write(t, b.Folder, "doc.txt", "v2 from b")
|
||||
cycle(t, b)
|
||||
cycle(t, a)
|
||||
if got := read(t, a.Folder, "doc.txt"); got != "v2 from b" {
|
||||
t.Fatalf("a got %q", got)
|
||||
}
|
||||
|
||||
// B deletes, A's copy disappears
|
||||
os.Remove(filepath.Join(b.Folder, "sub", "nested.txt"))
|
||||
cycle(t, b)
|
||||
cycle(t, a)
|
||||
if _, err := os.Stat(filepath.Join(a.Folder, "sub", "nested.txt")); !os.IsNotExist(err) {
|
||||
t.Fatal("delete did not propagate")
|
||||
}
|
||||
// empty dir pruned
|
||||
if _, err := os.Stat(filepath.Join(a.Folder, "sub")); !os.IsNotExist(err) {
|
||||
t.Fatal("empty dir not pruned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryTracksDeviceAndAuthor(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
write(t, a.Folder, "f.txt", "from a")
|
||||
cycle(t, a)
|
||||
cycle(t, b)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
write(t, b.Folder, "f.txt", "from b")
|
||||
cycle(t, b)
|
||||
cycle(t, a)
|
||||
|
||||
entries, err := LogEntries(a.Store, "f.txt", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("want 2 history entries, got %d: %+v", len(entries), entries)
|
||||
}
|
||||
// newest first
|
||||
if entries[0].Author != "devb@test" || entries[0].DeviceName != "devb" {
|
||||
t.Fatalf("newest entry should be devb's: %+v", entries[0])
|
||||
}
|
||||
if entries[1].Author != "deva@test" {
|
||||
t.Fatalf("oldest entry should be deva's: %+v", entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentEditConflictPreserved(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
// shared base
|
||||
write(t, a.Folder, "shared.txt", "base")
|
||||
cycle(t, a)
|
||||
cycle(t, b)
|
||||
|
||||
// both edit before syncing
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
write(t, a.Folder, "shared.txt", "edit from a")
|
||||
write(t, b.Folder, "shared.txt", "edit from b")
|
||||
cycle(t, a) // a pushes first
|
||||
cycle(t, b) // b scans its edit, pulls a's, loses or wins deterministically
|
||||
cycle(t, a) // a converges
|
||||
cycle(t, b)
|
||||
|
||||
aContent := read(t, a.Folder, "shared.txt")
|
||||
bContent := read(t, b.Folder, "shared.txt")
|
||||
if aContent != bContent {
|
||||
t.Fatalf("devices diverged: %q vs %q", aContent, bContent)
|
||||
}
|
||||
|
||||
// both versions must survive somewhere (winner at path, loser as conflict copy)
|
||||
all := map[string]bool{aContent: true}
|
||||
for _, folder := range []string{a.Folder, b.Folder} {
|
||||
entries, err := os.ReadDir(folder)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), ".sfs-conflict-") {
|
||||
all[read(t, folder, e.Name())] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !all["edit from a"] || !all["edit from b"] {
|
||||
t.Fatalf("a version was lost; surviving: %v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountExistingFolderImports(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
write(t, a.Folder, "pre-existing.txt", "I was here first")
|
||||
res := cycle(t, a)
|
||||
if res.LocalOps != 1 || !res.Pushed {
|
||||
t.Fatalf("import failed: %+v", res)
|
||||
}
|
||||
|
||||
b := newDevice(t, "devb", be)
|
||||
cycle(t, b)
|
||||
if read(t, b.Folder, "pre-existing.txt") != "I was here first" {
|
||||
t.Fatal("existing file not imported/synced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoredFiles(t *testing.T) {
|
||||
a := newDevice(t, "deva", nil)
|
||||
write(t, a.Folder, ".DS_Store", "junk")
|
||||
write(t, a.Folder, ".git/config", "gitstuff")
|
||||
write(t, a.Folder, "real.txt", "data")
|
||||
res := cycle(t, a)
|
||||
if res.LocalOps != 1 {
|
||||
t.Fatalf("ignores leaked into journal: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineThenReconnect(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
|
||||
// work offline
|
||||
a.Backend = nil
|
||||
write(t, a.Folder, "offline.txt", "written offline")
|
||||
cycle(t, a)
|
||||
|
||||
// reconnect: pending ops push
|
||||
a.Backend = be
|
||||
res := cycle(t, a)
|
||||
if !res.Pushed {
|
||||
t.Fatalf("reconnect should push pending ops: %+v", res)
|
||||
}
|
||||
|
||||
b := newDevice(t, "devb", be)
|
||||
cycle(t, b)
|
||||
if read(t, b.Folder, "offline.txt") != "written offline" {
|
||||
t.Fatal("offline edit did not propagate after reconnect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutableBitPreserved(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
abs := filepath.Join(a.Folder, "run.sh")
|
||||
os.WriteFile(abs, []byte("#!/bin/sh\necho hi\n"), 0o755)
|
||||
cycle(t, a)
|
||||
|
||||
b := newDevice(t, "devb", be)
|
||||
cycle(t, b)
|
||||
fi, err := os.Stat(filepath.Join(b.Folder, "run.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm()&0o100 == 0 {
|
||||
t.Fatalf("exec bit lost: %v", fi.Mode())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Homebrew formula for sfs.
|
||||
#
|
||||
# This is the source-build formula for the runbear-io/homebrew-tap repo.
|
||||
# Releases via goreleaser (.goreleaser.yaml) generate a bottle-style formula
|
||||
# with prebuilt binaries automatically; this file is the manual fallback and
|
||||
# the template for the first tap publication. Update `url` and `sha256` per
|
||||
# release (sha256: `curl -L <url> | shasum -a 256`).
|
||||
class Sfs < Formula
|
||||
desc "Synced file system for AI agents: mount, sync, and track folders"
|
||||
homepage "https://github.com/runbear-io/sfs"
|
||||
url "https://github.com/runbear-io/sfs/archive/refs/tags/v0.1.0.tar.gz"
|
||||
sha256 "0000000000000000000000000000000000000000000000000000000000000000" # update per release
|
||||
license "MIT"
|
||||
head "https://github.com/runbear-io/sfs.git", branch: "main"
|
||||
|
||||
depends_on "go" => :build
|
||||
|
||||
def install
|
||||
system "go", "build", *std_go_args(ldflags: "-s -w -X main.version=#{version}"), "./cmd/sfs"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "sfs", shell_output("#{bin}/sfs version")
|
||||
ENV["SFS_HOME"] = testpath/".sfs"
|
||||
system bin/"sfs", "whoami"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user