* 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)
117 lines
3.0 KiB
Go
117 lines
3.0 KiB
Go
package vfs_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/luxfi/age"
|
|
|
|
"github.com/hanzoai/vfs/pkg/backend"
|
|
_ "github.com/hanzoai/vfs/pkg/backend/file"
|
|
"github.com/hanzoai/vfs"
|
|
)
|
|
|
|
func newRoundTripVFS(t *testing.T) *vfs.VFS {
|
|
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,
|
|
CacheMax: 0, // unbounded for the test
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("vfs.New: %v", err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestRoundTrip(t *testing.T) {
|
|
v := newRoundTripVFS(t)
|
|
ctx := context.Background()
|
|
|
|
plaintext := []byte("hello, vfs world — this is a small block")
|
|
id, err := v.PutBlock(ctx, plaintext)
|
|
if err != nil {
|
|
t.Fatalf("PutBlock: %v", err)
|
|
}
|
|
if !strings.HasPrefix(string(id), "") || len(id) != 64 {
|
|
t.Fatalf("BlockID looks wrong: %q (len %d, want 64)", id, len(id))
|
|
}
|
|
|
|
got, err := v.GetBlock(ctx, id)
|
|
if err != nil {
|
|
t.Fatalf("GetBlock: %v", err)
|
|
}
|
|
|
|
// Decrypted block is zero-padded to BlockSize. Logical content is at the start.
|
|
if len(got) != vfs.BlockSize {
|
|
t.Fatalf("GetBlock returned %d bytes, want BlockSize=%d", len(got), vfs.BlockSize)
|
|
}
|
|
if string(got[:len(plaintext)]) != string(plaintext) {
|
|
t.Fatalf("plaintext mismatch:\n got=%q\n want=%q", got[:len(plaintext)], plaintext)
|
|
}
|
|
for i := len(plaintext); i < vfs.BlockSize; i++ {
|
|
if got[i] != 0 {
|
|
t.Fatalf("byte %d should be zero pad, got 0x%x", i, got[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRoundTripDeduplicates(t *testing.T) {
|
|
// Same plaintext + same recipient + same identity → identical ciphertext is NOT
|
|
// guaranteed by age (it includes a random ephemeral key). What we DO guarantee
|
|
// is that repeated PutBlock calls of the same plaintext return DIFFERENT block
|
|
// IDs but each is independently retrievable. (Cross-fleet dedup is a separate
|
|
// design with deterministic key derivation; that lands in 0.2.0+.)
|
|
v := newRoundTripVFS(t)
|
|
ctx := context.Background()
|
|
|
|
id1, err := v.PutBlock(ctx, []byte("identical"))
|
|
if err != nil {
|
|
t.Fatalf("PutBlock 1: %v", err)
|
|
}
|
|
id2, err := v.PutBlock(ctx, []byte("identical"))
|
|
if err != nil {
|
|
t.Fatalf("PutBlock 2: %v", err)
|
|
}
|
|
// Both must round-trip even if the IDs differ.
|
|
for _, id := range []vfs.BlockID{id1, id2} {
|
|
if _, err := v.GetBlock(ctx, id); err != nil {
|
|
t.Fatalf("GetBlock(%s): %v", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStats(t *testing.T) {
|
|
v := newRoundTripVFS(t)
|
|
ctx := context.Background()
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := v.PutBlock(ctx, []byte{byte(i)}); err != nil {
|
|
t.Fatalf("PutBlock: %v", err)
|
|
}
|
|
}
|
|
s := v.Stats()
|
|
if s.CacheBlocks != 3 {
|
|
t.Fatalf("CacheBlocks = %d, want 3", s.CacheBlocks)
|
|
}
|
|
if s.Backend == "" {
|
|
t.Fatal("Stats.Backend empty")
|
|
}
|
|
}
|