Files
vfs/pkg/mount/fuse_sqlite_test.go
zeekayandGitHub 1f89443577 feat: cloud Mount() per HIP-0106 (#1)
* 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)
2026-05-18 23:37:45 -07:00

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)
}
}