mirror of
https://github.com/hanzoai/vfs.git
synced 2026-10-30 18:18:21 +00:00
* feat: cloud Mount() per HIP-0106
Adds pkg/vfs.Mount(*zip.App, cloud.Deps) error so the unified Hanzo
Cloud binary can blank-import and register VFS alongside every
other Hanzo subsystem.
VFS has no public HTTP surface today — it's an S3-backed virtual
block filesystem driven through FUSE/CLI/sidecar. Mount() exposes
liveness/readiness probes plus a minimal block-CRUD HTTP API so
co-resident subsystems can talk to VFS without falling back to the
CLI:
- GET /v1/vfs/health — always 200
- GET /v1/vfs/readyz — 200 only when an instance is
attached via vfs.SetInstance
- PUT /v1/vfs/blocks — payload upload
- GET /v1/vfs/blocks/:id — payload download
- DELETE /v1/vfs/blocks/:id — backend delete
- GET /v1/vfs/stats — cache/backend counters
cmd/vfsd is a HIP-0106 thin shim that wires backend + age keys from
env (VFSD_BACKEND, VFSD_AGE_KEY) and listens on cloud.Config.ListenAddr.
The existing cmd/vfs CLI (put/get/stats/mount) is untouched.
init() registers with cloud.Register("vfs", 20, …). schema/vfs.zap
adds the minimal Health + Block ZAP interface; full multi-block
File surface lands in follow-up PRs.
Tests:
- pkg/vfs: TestMount_HealthReadyz + TestMount_PutGetRoundtrip green
- existing TestRoundTrip + TestStats unchanged
* refactor: collapse pkg/vfs/ → root for clean import path
Subsystem code lives in package vfs at repo root. Importers now use
`github.com/hanzoai/vfs` (no /pkg/vfs/ nesting). Standalone shim moved
to cmd/vfs/ where applicable.
Per Hanzo Go-stdlib pattern: repo IS the package.
* refactor: flatten import path — github.com/hanzoai/cloud (drop /pkg/cloud)
178 lines
4.8 KiB
Go
178 lines
4.8 KiB
Go
//go:build fuse && (linux || darwin)
|
|
|
|
// End-to-end FUSE + SQLite test. Mounts a *vfs.FS at a tempdir,
|
|
// opens SQLite directly on the mountpoint (no copy step), runs CREATE
|
|
// + INSERT + SELECT + integrity_check, unmounts, remounts, verifies
|
|
// the database survived the round trip.
|
|
//
|
|
// Run with:
|
|
// VFS_FUSE_E2E=1 go test -tags fuse -run TestFUSESQLite ./pkg/mount/
|
|
//
|
|
// Linux: requires kernel ≥ 3.10 with FUSE support (default on every
|
|
// modern distro).
|
|
// macOS: requires either macFUSE (https://macfuse.io) or fuse-t
|
|
// (https://www.fuse-t.org). fuse-t is preferred — userspace, no kext.
|
|
package mount_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/age"
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/hanzoai/vfs/pkg/backend"
|
|
_ "github.com/hanzoai/vfs/pkg/backend/file"
|
|
"github.com/hanzoai/vfs/pkg/mount"
|
|
"github.com/hanzoai/vfs"
|
|
)
|
|
|
|
func TestFUSESQLite(t *testing.T) {
|
|
if os.Getenv("VFS_FUSE_E2E") == "" {
|
|
t.Skip("set VFS_FUSE_E2E=1 to run FUSE e2e tests (requires Linux kernel FUSE + privileges)")
|
|
}
|
|
|
|
tmp := t.TempDir()
|
|
storeDir := filepath.Join(tmp, "store")
|
|
mountpoint := filepath.Join(tmp, "mnt")
|
|
if err := os.MkdirAll(mountpoint, 0o755); err != nil {
|
|
t.Fatalf("mkdir mountpoint: %v", err)
|
|
}
|
|
defer func() {
|
|
// Best-effort unmount on test exit; Mount's ctx-cancel goroutine
|
|
// has already triggered the FUSE driver's own Unmount, but we
|
|
// also try the platform tool as a belt-and-braces.
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
_ = exec.Command("fusermount", "-u", mountpoint).Run()
|
|
_ = exec.Command("fusermount3", "-u", mountpoint).Run()
|
|
case "darwin":
|
|
_ = exec.Command("umount", mountpoint).Run()
|
|
_ = exec.Command("diskutil", "unmount", mountpoint).Run()
|
|
}
|
|
}()
|
|
|
|
id, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
t.Fatalf("age: %v", err)
|
|
}
|
|
|
|
mountFS := func() (*vfs.FS, context.CancelFunc, *sync.WaitGroup) {
|
|
t.Helper()
|
|
be, err := backend.Open(context.Background(), "file://"+storeDir)
|
|
if err != nil {
|
|
t.Fatalf("backend.Open: %v", err)
|
|
}
|
|
c, err := vfs.NewCrypto([]age.Recipient{id.Recipient()}, []age.Identity{id})
|
|
if err != nil {
|
|
t.Fatalf("NewCrypto: %v", err)
|
|
}
|
|
v, err := vfs.New(vfs.Config{Backend: be, Crypto: c})
|
|
if err != nil {
|
|
t.Fatalf("vfs.New: %v", err)
|
|
}
|
|
fs, err := vfs.NewFS(context.Background(), v)
|
|
if err != nil {
|
|
t.Fatalf("NewFS: %v", err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := mount.Mount(ctx, fs, mountpoint); err != nil &&
|
|
!strings.Contains(err.Error(), "transport endpoint is not connected") {
|
|
t.Logf("Mount returned: %v", err)
|
|
}
|
|
}()
|
|
// Wait for mount to be live.
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, err := os.Stat(mountpoint); err == nil {
|
|
if entries, _ := os.ReadDir(mountpoint); entries != nil {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
return fs, cancel, &wg
|
|
}
|
|
|
|
dbPath := filepath.Join(mountpoint, "test.db")
|
|
|
|
// === First mount: create + populate the DB ===
|
|
{
|
|
_, cancel, wg := mountFS()
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
cancel()
|
|
wg.Wait()
|
|
t.Fatalf("sql.Open via FUSE: %v", err)
|
|
}
|
|
if _, err := db.Exec(`CREATE TABLE coins (sym TEXT PRIMARY KEY, name TEXT, decimals INT)`); err != nil {
|
|
t.Fatalf("CREATE: %v", err)
|
|
}
|
|
stmt, err := db.Prepare(`INSERT INTO coins VALUES (?, ?, ?)`)
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
for i := 0; i < 100; i++ {
|
|
if _, err := stmt.Exec(fmt.Sprintf("LQDTY_%d", i), "Liquidity", 18); err != nil {
|
|
t.Fatalf("INSERT %d: %v", i, err)
|
|
}
|
|
}
|
|
_ = stmt.Close()
|
|
var got int
|
|
if err := db.QueryRow(`SELECT count(*) FROM coins`).Scan(&got); err != nil {
|
|
t.Fatalf("SELECT: %v", err)
|
|
}
|
|
if got != 100 {
|
|
t.Fatalf("count=%d want 100", got)
|
|
}
|
|
if err := db.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
cancel()
|
|
wg.Wait()
|
|
}
|
|
|
|
// === Second mount: re-open the DB, verify integrity ===
|
|
{
|
|
_, cancel, wg := mountFS()
|
|
defer func() { cancel(); wg.Wait() }()
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("sql.Open after remount: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
var ok string
|
|
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&ok); err != nil {
|
|
t.Fatalf("integrity_check: %v", err)
|
|
}
|
|
if !strings.EqualFold(ok, "ok") {
|
|
t.Fatalf("integrity_check = %q want ok", ok)
|
|
}
|
|
var count int
|
|
if err := db.QueryRow(`SELECT count(*) FROM coins`).Scan(&count); err != nil {
|
|
t.Fatalf("SELECT count after remount: %v", err)
|
|
}
|
|
if count != 100 {
|
|
t.Fatalf("after remount count=%d want 100", count)
|
|
}
|
|
t.Logf("FUSE+SQLite e2e: %d rows survived umount+remount, integrity_check=%s", count, ok)
|
|
}
|
|
}
|