Files
vfs/pkg/mount/fuse_darwin.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

381 lines
9.7 KiB
Go

//go:build fuse && darwin
// macOS FUSE driver. bazil.org/fuse dropped macOS support upstream
// (commit 65cc252+); we use github.com/jacobsa/fuse instead, which
// still supports both macFUSE and fuse-t.
//
// Build with: `go build -tags fuse ./...`
// Requires either macFUSE (https://macfuse.io) or fuse-t
// (https://www.fuse-t.org). fuse-t is preferred — no kernel extension,
// runs as a userspace NFSv4 server, works under SIP.
package mount
import (
"context"
"errors"
"fmt"
"io"
"os"
"syscall"
"time"
"github.com/jacobsa/fuse"
"github.com/jacobsa/fuse/fuseops"
"github.com/jacobsa/fuse/fuseutil"
"github.com/hanzoai/vfs"
)
// Mount mounts the given *vfs.FS at mountpoint and serves until the
// context is cancelled or the kernel unmounts the filesystem.
func Mount(ctx context.Context, fs *vfs.FS, mountpoint string) error {
if fs == nil {
return errors.New("mount: vfs.FS required")
}
if mountpoint == "" {
return errors.New("mount: mountpoint required")
}
srv := newServer(ctx, fs)
mfs, err := fuse.Mount(mountpoint, fuseutil.NewFileSystemServer(srv), &fuse.MountConfig{
FSName: "vfs",
Subtype: "hanzovfs",
ReadOnly: false,
EnableNoOpenSupport: true,
EnableNoOpendirSupport: true,
DisableDefaultPermissions: false,
})
if err != nil {
return fmt.Errorf("fuse.Mount %q: %w", mountpoint, err)
}
go func() {
<-ctx.Done()
_ = fuse.Unmount(mountpoint)
}()
return mfs.Join(context.Background())
}
// MountWithVFSAdapter mirrors the linux helper.
func MountWithVFSAdapter(ctx context.Context, v *vfs.VFS, mountpoint string) error {
fs, err := vfs.NewFS(ctx, v)
if err != nil {
return fmt.Errorf("mount: NewFS: %w", err)
}
return Mount(ctx, fs, mountpoint)
}
// ============================================================================
// FileSystem implementation
// ============================================================================
type server struct {
fuseutil.NotImplementedFileSystem
ctx context.Context
fs *vfs.FS
// FUSE inode IDs are u64 starting at 1 (fuseops.RootInodeID).
// We map directly: VFS InodeID == FUSE InodeID.
//
// Open file handles are u64; we allocate sequential IDs.
nextHandle fuseops.HandleID
handles map[fuseops.HandleID]*vfs.File
}
func newServer(ctx context.Context, fs *vfs.FS) *server {
return &server{
ctx: ctx,
fs: fs,
nextHandle: 1,
handles: map[fuseops.HandleID]*vfs.File{},
}
}
func (s *server) inodeAttrs(in *vfs.Inode) fuseops.InodeAttributes {
return fuseops.InodeAttributes{
Size: in.Size,
Nlink: 1,
Mode: os.FileMode(in.Mode),
Atime: in.Atime,
Mtime: in.Mtime,
Ctime: in.Ctime,
Crtime: in.Ctime,
Uid: uint32(os.Getuid()),
Gid: uint32(os.Getgid()),
}
}
// pathOfInode walks the FS metadata tree to recover the absolute path
// for a given inode ID. Used by every FUSE op the kernel sends as
// (parent_id, name) tuples.
func (s *server) pathOfInode(id fuseops.InodeID) (string, error) {
if id == fuseops.RootInodeID {
return "/", nil
}
// vfs.FS doesn't currently expose a reverse map (id → path) — walk
// from the root by joining names up the parent chain. The tree is
// in memory; this is O(depth).
return s.fs.PathOfInode(vfs.InodeID(id))
}
// LookUpInode (parent + name) → child attrs.
func (s *server) LookUpInode(_ context.Context, op *fuseops.LookUpInodeOp) error {
parentPath, err := s.pathOfInode(op.Parent)
if err != nil {
return err
}
child := joinPath(parentPath, op.Name)
in, err := s.fs.Lookup(child)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fuse.ENOENT
}
return err
}
op.Entry = fuseops.ChildInodeEntry{
Child: fuseops.InodeID(in.ID),
Attributes: s.inodeAttrs(in),
AttributesExpiration: time.Now().Add(time.Second),
EntryExpiration: time.Now().Add(time.Second),
}
return nil
}
func (s *server) GetInodeAttributes(_ context.Context, op *fuseops.GetInodeAttributesOp) error {
p, err := s.pathOfInode(op.Inode)
if err != nil {
return err
}
in, err := s.fs.Lookup(p)
if err != nil {
return err
}
op.Attributes = s.inodeAttrs(in)
op.AttributesExpiration = time.Now().Add(time.Second)
return nil
}
func (s *server) SetInodeAttributes(ctx context.Context, op *fuseops.SetInodeAttributesOp) error {
p, err := s.pathOfInode(op.Inode)
if err != nil {
return err
}
if op.Size != nil {
f, err := s.fs.Open(s.ctx, p)
if err != nil {
return err
}
if err := f.Truncate(*op.Size); err != nil {
_ = f.Close()
return err
}
if err := f.Sync(); err != nil {
_ = f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
}
in, err := s.fs.Lookup(p)
if err != nil {
return err
}
op.Attributes = s.inodeAttrs(in)
op.AttributesExpiration = time.Now().Add(time.Second)
return nil
}
func (s *server) MkDir(_ context.Context, op *fuseops.MkDirOp) error {
parentPath, err := s.pathOfInode(op.Parent)
if err != nil {
return err
}
in, err := s.fs.Mkdir(joinPath(parentPath, op.Name), uint32(op.Mode.Perm()))
if err != nil {
return err
}
op.Entry = fuseops.ChildInodeEntry{
Child: fuseops.InodeID(in.ID),
Attributes: s.inodeAttrs(in),
AttributesExpiration: time.Now().Add(time.Second),
EntryExpiration: time.Now().Add(time.Second),
}
return nil
}
func (s *server) CreateFile(ctx context.Context, op *fuseops.CreateFileOp) error {
parentPath, err := s.pathOfInode(op.Parent)
if err != nil {
return err
}
child := joinPath(parentPath, op.Name)
in, err := s.fs.Create(child, uint32(op.Mode.Perm()))
if err != nil {
return err
}
f, err := s.fs.Open(s.ctx, child)
if err != nil {
return err
}
op.Entry = fuseops.ChildInodeEntry{
Child: fuseops.InodeID(in.ID),
Attributes: s.inodeAttrs(in),
AttributesExpiration: time.Now().Add(time.Second),
EntryExpiration: time.Now().Add(time.Second),
}
op.Handle = s.allocHandle(f)
return nil
}
func (s *server) Unlink(_ context.Context, op *fuseops.UnlinkOp) error {
parentPath, err := s.pathOfInode(op.Parent)
if err != nil {
return err
}
return s.fs.Remove(joinPath(parentPath, op.Name))
}
func (s *server) RmDir(_ context.Context, op *fuseops.RmDirOp) error {
parentPath, err := s.pathOfInode(op.Parent)
if err != nil {
return err
}
return s.fs.Remove(joinPath(parentPath, op.Name))
}
// ─── directory ops ──────────────────────────────────────────────────────────
func (s *server) OpenDir(_ context.Context, op *fuseops.OpenDirOp) error {
op.Handle = s.allocHandle(nil) // dirs use handle ID but no *vfs.File
return nil
}
func (s *server) ReadDir(_ context.Context, op *fuseops.ReadDirOp) error {
p, err := s.pathOfInode(op.Inode)
if err != nil {
return err
}
children, err := s.fs.ReadDir(p)
if err != nil {
return err
}
off := int(op.Offset)
for i, c := range children {
if i < off {
continue
}
typ := fuseutil.DT_File
if c.IsDir() {
typ = fuseutil.DT_Directory
}
entry := fuseutil.Dirent{
Offset: fuseops.DirOffset(i + 1),
Inode: fuseops.InodeID(c.ID),
Name: c.Name,
Type: typ,
}
n := fuseutil.WriteDirent(op.Dst[op.BytesRead:], entry)
if n == 0 {
break
}
op.BytesRead += n
}
return nil
}
func (s *server) ReleaseDirHandle(_ context.Context, op *fuseops.ReleaseDirHandleOp) error {
delete(s.handles, op.Handle)
return nil
}
// ─── file ops ───────────────────────────────────────────────────────────────
func (s *server) OpenFile(_ context.Context, op *fuseops.OpenFileOp) error {
p, err := s.pathOfInode(op.Inode)
if err != nil {
return err
}
f, err := s.fs.Open(s.ctx, p)
if err != nil {
return err
}
op.Handle = s.allocHandle(f)
op.KeepPageCache = false
return nil
}
func (s *server) ReadFile(_ context.Context, op *fuseops.ReadFileOp) error {
f := s.handles[op.Handle]
if f == nil {
return syscall.EBADF
}
n, err := f.ReadAt(op.Dst, op.Offset)
op.BytesRead = n
if err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
func (s *server) WriteFile(_ context.Context, op *fuseops.WriteFileOp) error {
f := s.handles[op.Handle]
if f == nil {
return syscall.EBADF
}
_, err := f.WriteAt(op.Data, op.Offset)
return err
}
func (s *server) SyncFile(_ context.Context, op *fuseops.SyncFileOp) error {
f := s.handles[op.Handle]
if f == nil {
return nil
}
return f.Sync()
}
func (s *server) FlushFile(_ context.Context, op *fuseops.FlushFileOp) error {
f := s.handles[op.Handle]
if f == nil {
return nil
}
return f.Sync()
}
func (s *server) ReleaseFileHandle(_ context.Context, op *fuseops.ReleaseFileHandleOp) error {
if f, ok := s.handles[op.Handle]; ok && f != nil {
_ = f.Close()
}
delete(s.handles, op.Handle)
return nil
}
func (s *server) StatFS(_ context.Context, op *fuseops.StatFSOp) error {
op.BlockSize = vfs.BlockSize
// Pretend to have ~1 PiB available. Backend is "unlimited".
op.Blocks = 1 << 38
op.BlocksFree = 1 << 38
op.BlocksAvailable = 1 << 38
op.IoSize = vfs.BlockSize
return nil
}
// ─── handle table ───────────────────────────────────────────────────────────
func (s *server) allocHandle(f *vfs.File) fuseops.HandleID {
id := s.nextHandle
s.nextHandle++
s.handles[id] = f
return id
}
// joinPath is shared with fuse_unix.go via the package; declared here
// for the build-tag-isolated darwin file.
func joinPath(parent, name string) string {
if parent == "/" {
return "/" + name
}
return parent + "/" + name
}