Files
vfs/fs_test.go
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

285 lines
6.6 KiB
Go

package vfs_test
import (
"context"
"crypto/rand"
"errors"
"io"
"os"
"strings"
"testing"
"github.com/luxfi/age"
"github.com/hanzoai/vfs/pkg/backend"
_ "github.com/hanzoai/vfs/pkg/backend/file"
"github.com/hanzoai/vfs"
)
func newFS(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.GenerateX25519Identity: %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
}
func TestFSCreateAndStat(t *testing.T) {
fs := newFS(t)
in, err := fs.Create("/hello.txt", 0o644)
if err != nil {
t.Fatalf("Create: %v", err)
}
if in.Size != 0 {
t.Fatalf("new file size = %d, want 0", in.Size)
}
got, err := fs.Lookup("/hello.txt")
if err != nil {
t.Fatalf("Lookup: %v", err)
}
if got.ID != in.ID {
t.Fatalf("Lookup id mismatch: got %d want %d", got.ID, in.ID)
}
}
func TestFSDirOps(t *testing.T) {
fs := newFS(t)
if _, err := fs.Mkdir("/sub", 0o755); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if _, err := fs.Create("/sub/a.txt", 0o644); err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := fs.Create("/sub/b.txt", 0o644); err != nil {
t.Fatalf("Create: %v", err)
}
entries, err := fs.ReadDir("/sub")
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
if len(entries) != 2 {
t.Fatalf("ReadDir count: got %d want 2", len(entries))
}
if err := fs.Remove("/sub/a.txt"); err != nil {
t.Fatalf("Remove: %v", err)
}
if _, err := fs.Lookup("/sub/a.txt"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("expected ErrNotExist after remove, got %v", err)
}
}
func TestFileWriteRead(t *testing.T) {
fs := newFS(t)
ctx := context.Background()
if _, err := fs.Create("/data.bin", 0o644); err != nil {
t.Fatalf("Create: %v", err)
}
f, err := fs.Open(ctx, "/data.bin")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer f.Close()
payload := []byte(strings.Repeat("abcdefgh", 100)) // 800 bytes
n, err := f.WriteAt(payload, 0)
if err != nil {
t.Fatalf("WriteAt: %v", err)
}
if n != len(payload) {
t.Fatalf("WriteAt n=%d, want %d", n, len(payload))
}
if err := f.Sync(); err != nil {
t.Fatalf("Sync: %v", err)
}
// Read back
buf := make([]byte, len(payload))
got, err := f.ReadAt(buf, 0)
if err != nil && !errors.Is(err, io.EOF) {
t.Fatalf("ReadAt: %v", err)
}
if got != len(payload) {
t.Fatalf("ReadAt n=%d, want %d", got, len(payload))
}
if string(buf) != string(payload) {
t.Fatalf("readback mismatch")
}
}
func TestFileMultiBlock(t *testing.T) {
fs := newFS(t)
ctx := context.Background()
if _, err := fs.Create("/big.bin", 0o644); err != nil {
t.Fatalf("Create: %v", err)
}
f, err := fs.Open(ctx, "/big.bin")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer f.Close()
// 16 KiB = 4 full blocks
payload := make([]byte, 4*vfs.BlockSize)
if _, err := rand.Read(payload); err != nil {
t.Fatalf("rand: %v", err)
}
if _, err := f.WriteAt(payload, 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(payload)) {
t.Fatalf("size=%d want %d", stat.Size, len(payload))
}
if len(stat.Blocks) != 4 {
t.Fatalf("blocks=%d want 4", len(stat.Blocks))
}
// Read back full
got := make([]byte, len(payload))
if _, err := f.ReadAt(got, 0); err != nil && !errors.Is(err, io.EOF) {
t.Fatalf("ReadAt: %v", err)
}
if string(got) != string(payload) {
t.Fatalf("payload mismatch")
}
}
func TestFilePartialBlockRMW(t *testing.T) {
fs := newFS(t)
ctx := context.Background()
if _, err := fs.Create("/rmw.bin", 0o644); err != nil {
t.Fatalf("Create: %v", err)
}
f, err := fs.Open(ctx, "/rmw.bin")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer f.Close()
// Write a full block of 'A'
full := make([]byte, vfs.BlockSize)
for i := range full {
full[i] = 'A'
}
if _, err := f.WriteAt(full, 0); err != nil {
t.Fatalf("WriteAt full: %v", err)
}
if err := f.Sync(); err != nil {
t.Fatalf("Sync 1: %v", err)
}
// Partial overwrite at offset 100, 50 bytes of 'B'
bs := []byte(strings.Repeat("B", 50))
if _, err := f.WriteAt(bs, 100); err != nil {
t.Fatalf("WriteAt partial: %v", err)
}
if err := f.Sync(); err != nil {
t.Fatalf("Sync 2: %v", err)
}
// Read back full block
got := make([]byte, vfs.BlockSize)
if _, err := f.ReadAt(got, 0); err != nil && !errors.Is(err, io.EOF) {
t.Fatalf("ReadAt: %v", err)
}
for i := 0; i < 100; i++ {
if got[i] != 'A' {
t.Fatalf("byte %d: want A, got %c", i, got[i])
}
}
for i := 100; i < 150; i++ {
if got[i] != 'B' {
t.Fatalf("byte %d: want B, got %c", i, got[i])
}
}
for i := 150; i < vfs.BlockSize; i++ {
if got[i] != 'A' {
t.Fatalf("byte %d: want A, got %c", i, got[i])
}
}
}
func TestFSPersistAcrossReopen(t *testing.T) {
dir := t.TempDir()
id, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("age: %v", err)
}
open := func() *vfs.FS {
be, err := backend.Open(context.Background(), "file://"+dir)
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)
}
return fs
}
// First mount: create a file with content
{
fs := open()
if _, err := fs.Create("/persist.txt", 0o644); err != nil {
t.Fatalf("Create: %v", err)
}
f, _ := fs.Open(context.Background(), "/persist.txt")
if _, err := f.WriteAt([]byte("hello after remount"), 0); err != nil {
t.Fatalf("WriteAt: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
// Second mount: file should still be there
{
fs := open()
f, err := fs.Open(context.Background(), "/persist.txt")
if err != nil {
t.Fatalf("Open after remount: %v", err)
}
buf := make([]byte, 19)
n, err := f.ReadAt(buf, 0)
if err != nil && !errors.Is(err, io.EOF) {
t.Fatalf("ReadAt: %v", err)
}
if string(buf[:n]) != "hello after remount" {
t.Fatalf("got %q want %q", buf[:n], "hello after remount")
}
}
}