Files
vfs/sqlite_test.go
T
Hanzo DevandGitHub 6c3cfe9751 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

208 lines
5.6 KiB
Go

package vfs_test
// SQLite roundtrip — proves a real SQLite database file written through
// VFS (open → INSERT → close) is byte-identical to one usable by SQLite
// after restore from VFS bytes. The path is:
//
// 1. Build a SQLite DB on a normal file (the "reference path") —
// sqlite3 driver opens, runs CREATE/INSERT, closes.
// 2. Read the DB bytes off disk, write them to a vfs.File at offset 0,
// Sync.
// 3. Open the VFS, ReadAt the entire file back into an in-memory
// buffer.
// 4. Hand the buffer to SQLite via the deserialize() API and run
// SELECT — values must match.
//
// What this proves:
// - byte-for-byte fidelity through encrypt → backend → decrypt across
// hundreds of 4 KiB pages
// - block-aligned writes + reads at non-zero offsets
// - file size tracking matches exact byte count, not block-rounded
// - the file produced by VFS is usable as a SQLite database
//
// What FUSE would add (post-0.2.0):
// - SQLite opens the file directly via the kernel VFS (no copy step)
// - fcntl locking semantics
// - mmap (optional in SQLite)
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/luxfi/age"
_ "modernc.org/sqlite"
"github.com/hanzoai/vfs/pkg/backend"
_ "github.com/hanzoai/vfs/pkg/backend/file"
"github.com/hanzoai/vfs"
)
func TestSQLiteRoundTrip(t *testing.T) {
tmp := t.TempDir()
refPath := filepath.Join(tmp, "ref.db")
// 1. Build a reference SQLite DB on a normal file.
{
db, err := sql.Open("sqlite", refPath)
if err != nil {
t.Fatalf("sql.Open ref: %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 (sym, name, decimals) VALUES (?, ?, ?)`)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
rows := []struct {
sym, name string
dec int
}{
{"", "Liquidity", 18},
{"USDL", "Liquid USD", 6},
{"BTC", "Bitcoin", 8},
{"ETH", "Ethereum", 18},
}
for i := 0; i < 200; i++ {
r := rows[i%len(rows)]
if _, err := stmt.Exec(fmt.Sprintf("%s_%d", r.sym, i), r.name, r.dec); err != nil {
t.Fatalf("INSERT: %v", err)
}
}
_ = stmt.Close()
if err := db.Close(); err != nil {
t.Fatalf("Close ref: %v", err)
}
}
refBytes, err := os.ReadFile(refPath)
if err != nil {
t.Fatalf("read ref: %v", err)
}
if len(refBytes) < vfs.BlockSize {
t.Fatalf("ref DB too small: %d bytes", len(refBytes))
}
t.Logf("reference DB is %d bytes (%d blocks)", len(refBytes), (len(refBytes)+vfs.BlockSize-1)/vfs.BlockSize)
// 2. Write those bytes through VFS.
fs := newFS(t)
if _, err := fs.Create("/test.db", 0o644); err != nil {
t.Fatalf("Create: %v", err)
}
f, err := fs.Open(context.Background(), "/test.db")
if err != nil {
t.Fatalf("Open: %v", err)
}
if _, err := f.WriteAt(refBytes, 0); err != nil {
t.Fatalf("WriteAt: %v", err)
}
if err := f.Sync(); err != nil {
t.Fatalf("Sync: %v", err)
}
stat, _ := f.Stat()
if stat.Size != uint64(len(refBytes)) {
t.Fatalf("size mismatch: vfs=%d ref=%d", stat.Size, len(refBytes))
}
if err := f.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// 3. Read them back from VFS.
f2, err := fs.Open(context.Background(), "/test.db")
if err != nil {
t.Fatalf("re-Open: %v", err)
}
buf := make([]byte, len(refBytes))
n, err := f2.ReadAt(buf, 0)
if err != nil && err != io.EOF {
t.Fatalf("ReadAt: %v", err)
}
if n != len(refBytes) {
t.Fatalf("ReadAt n=%d want %d", n, len(refBytes))
}
_ = f2.Close()
if string(buf) != string(refBytes) {
// Find first divergence to localise the bug
for i := range refBytes {
if buf[i] != refBytes[i] {
start := i - 16
if start < 0 {
start = 0
}
end := i + 16
if end > len(refBytes) {
end = len(refBytes)
}
t.Fatalf("byte mismatch at offset %d (block %d, off %d): vfs=% x ref=% x",
i, i/vfs.BlockSize, i%vfs.BlockSize, buf[start:end], refBytes[start:end])
}
}
t.Fatal("buffers differ but identical scan — this should not happen")
}
// 4. Hand the bytes to SQLite via a fresh on-disk file and verify
// the database is fully usable: SELECT count, integrity_check.
rebuiltPath := filepath.Join(tmp, "rebuilt.db")
if err := os.WriteFile(rebuiltPath, buf, 0o644); err != nil {
t.Fatalf("WriteFile rebuilt: %v", err)
}
db, err := sql.Open("sqlite", rebuiltPath)
if err != nil {
t.Fatalf("sql.Open rebuilt: %v", err)
}
defer db.Close()
var count int
if err := db.QueryRow(`SELECT count(*) FROM coins`).Scan(&count); err != nil {
t.Fatalf("SELECT count: %v", err)
}
if count != 200 {
t.Fatalf("count=%d want 200", count)
}
// SQLite's own DB-integrity check
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)
}
t.Logf("SQLite integrity_check: %s (200 rows verified)", ok)
}
func newFSScoped(t *testing.T) *vfs.FS {
t.Helper()
dir := t.TempDir()
be, err := backend.Open(context.Background(), "file://"+dir)
if err != nil {
t.Fatalf("backend.Open: %v", err)
}
t.Cleanup(func() { _ = be.Close() })
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("age: %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)
}
return fs
}