mirror of
https://github.com/hanzoai/vfs.git
synced 2026-10-30 18:18:21 +00:00
* 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)
344 lines
8.8 KiB
Go
344 lines
8.8 KiB
Go
//go:build fuse && linux
|
|
|
|
// NOTE: bazil.org/fuse dropped macOS support (commit 65cc252+). On
|
|
// macOS use the default no-fuse build for in-process testing, or
|
|
// switch to github.com/jacobsa/fuse if you need a real FUSE mount
|
|
// (a 0.3.1 follow-up adds a parallel mount_darwin.go using jacobsa).
|
|
|
|
// Package mount wires the in-process *vfs.FS and *vfs.File to the
|
|
// kernel via bazil.org/fuse so any POSIX consumer (SQLite, Postgres,
|
|
// Redis, ripgrep, anything) can read/write the encrypted block VFS as
|
|
// if it were a normal mounted filesystem.
|
|
//
|
|
// Build-tag gated: `go build -tags fuse ./...`. Default builds stay
|
|
// dependency-light (no FUSE link).
|
|
package mount
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"syscall"
|
|
"time"
|
|
|
|
"bazil.org/fuse"
|
|
bzfs "bazil.org/fuse/fs"
|
|
|
|
"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.
|
|
//
|
|
// The caller is responsible for unmount-on-shutdown — typically:
|
|
//
|
|
// go func() { <-ctx.Done(); _ = fuse.Unmount(mountpoint) }()
|
|
//
|
|
// On clean exit Mount returns nil; on kernel-side error it returns
|
|
// the underlying fuse error.
|
|
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")
|
|
}
|
|
|
|
conn, err := fuse.Mount(mountpoint,
|
|
fuse.FSName("vfs"),
|
|
fuse.Subtype("hanzovfs"),
|
|
fuse.AllowNonEmptyMount(),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("fuse.Mount %q: %w", mountpoint, err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Ensure unmount on context cancel.
|
|
go func() {
|
|
<-ctx.Done()
|
|
_ = fuse.Unmount(mountpoint)
|
|
}()
|
|
|
|
srv := bzfs.New(conn, nil)
|
|
if err := srv.Serve(&fsRoot{fs: fs, ctx: ctx}); err != nil {
|
|
return fmt.Errorf("fuse.Serve: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MountWithVFSAdapter is the alias used by the CLI when it constructs
|
|
// a *vfs.VFS first and only wraps it as a *vfs.FS at mount time.
|
|
//
|
|
// It exists so the public Mount(*vfs.FS, mp) signature stays canonical
|
|
// while the cmd path can pass the cheaper *vfs.VFS form.
|
|
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)
|
|
}
|
|
|
|
// ============================================================================
|
|
// fs.FS — root + node lookup
|
|
// ============================================================================
|
|
|
|
type fsRoot struct {
|
|
fs *vfs.FS
|
|
ctx context.Context
|
|
}
|
|
|
|
var _ bzfs.FS = (*fsRoot)(nil)
|
|
|
|
func (r *fsRoot) Root() (bzfs.Node, error) {
|
|
in, err := r.fs.Lookup("/")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &node{root: r, inode: in, path: "/"}, nil
|
|
}
|
|
|
|
// ============================================================================
|
|
// fs.Node — dir + file
|
|
// ============================================================================
|
|
|
|
type node struct {
|
|
root *fsRoot
|
|
inode *vfs.Inode
|
|
path string
|
|
}
|
|
|
|
var (
|
|
_ bzfs.Node = (*node)(nil)
|
|
_ bzfs.NodeStringLookuper = (*node)(nil)
|
|
_ bzfs.HandleReadDirAller = (*node)(nil)
|
|
_ bzfs.NodeMkdirer = (*node)(nil)
|
|
_ bzfs.NodeCreater = (*node)(nil)
|
|
_ bzfs.NodeRemover = (*node)(nil)
|
|
_ bzfs.NodeSetattrer = (*node)(nil)
|
|
_ bzfs.NodeFsyncer = (*node)(nil)
|
|
)
|
|
|
|
func (n *node) Attr(_ context.Context, a *fuse.Attr) error {
|
|
// Re-fetch in case another writer mutated the inode.
|
|
fresh, err := n.root.fs.Lookup(n.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n.inode = fresh
|
|
a.Inode = uint64(fresh.ID)
|
|
a.Mode = os.FileMode(fresh.Mode)
|
|
a.Size = fresh.Size
|
|
a.Blocks = (fresh.Size + 511) / 512
|
|
a.Mtime = fresh.Mtime
|
|
a.Atime = fresh.Atime
|
|
a.Ctime = fresh.Ctime
|
|
a.Nlink = 1
|
|
a.Uid = uint32(os.Getuid())
|
|
a.Gid = uint32(os.Getgid())
|
|
a.BlockSize = vfs.BlockSize
|
|
return nil
|
|
}
|
|
|
|
func (n *node) Lookup(_ context.Context, name string) (bzfs.Node, error) {
|
|
child := joinPath(n.path, name)
|
|
in, err := n.root.fs.Lookup(child)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, syscall.ENOENT
|
|
}
|
|
return nil, err
|
|
}
|
|
return &node{root: n.root, inode: in, path: child}, nil
|
|
}
|
|
|
|
func (n *node) ReadDirAll(_ context.Context) ([]fuse.Dirent, error) {
|
|
if !n.inode.IsDir() {
|
|
return nil, syscall.ENOTDIR
|
|
}
|
|
children, err := n.root.fs.ReadDir(n.path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]fuse.Dirent, 0, len(children))
|
|
for _, c := range children {
|
|
typ := fuse.DT_File
|
|
if c.IsDir() {
|
|
typ = fuse.DT_Dir
|
|
}
|
|
out = append(out, fuse.Dirent{
|
|
Inode: uint64(c.ID),
|
|
Name: c.Name,
|
|
Type: typ,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (n *node) Mkdir(_ context.Context, req *fuse.MkdirRequest) (bzfs.Node, error) {
|
|
child := joinPath(n.path, req.Name)
|
|
in, err := n.root.fs.Mkdir(child, uint32(req.Mode.Perm()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &node{root: n.root, inode: in, path: child}, nil
|
|
}
|
|
|
|
func (n *node) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (bzfs.Node, bzfs.Handle, error) {
|
|
child := joinPath(n.path, req.Name)
|
|
in, err := n.root.fs.Create(child, uint32(req.Mode.Perm()))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
cn := &node{root: n.root, inode: in, path: child}
|
|
f, err := n.root.fs.Open(ctx, child)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
resp.Attr.Inode = uint64(in.ID)
|
|
resp.Attr.Mode = os.FileMode(in.Mode)
|
|
resp.Attr.Size = in.Size
|
|
resp.Attr.Mtime = in.Mtime
|
|
resp.Attr.Atime = in.Atime
|
|
resp.Attr.Ctime = in.Ctime
|
|
resp.Attr.Nlink = 1
|
|
resp.Attr.Uid = uint32(os.Getuid())
|
|
resp.Attr.Gid = uint32(os.Getgid())
|
|
resp.Attr.BlockSize = vfs.BlockSize
|
|
return cn, &handle{node: cn, file: f}, nil
|
|
}
|
|
|
|
func (n *node) Remove(_ context.Context, req *fuse.RemoveRequest) error {
|
|
child := joinPath(n.path, req.Name)
|
|
if err := n.root.fs.Remove(child); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *node) Setattr(_ context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
|
|
if req.Valid.Size() {
|
|
f, err := n.root.fs.Open(n.root.ctx, n.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if err := f.Truncate(req.Size); err != nil {
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Refresh attr for response.
|
|
fresh, err := n.root.fs.Lookup(n.path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n.inode = fresh
|
|
resp.Attr.Inode = uint64(fresh.ID)
|
|
resp.Attr.Mode = os.FileMode(fresh.Mode)
|
|
resp.Attr.Size = fresh.Size
|
|
resp.Attr.Mtime = fresh.Mtime
|
|
resp.Attr.Atime = fresh.Atime
|
|
resp.Attr.Ctime = fresh.Ctime
|
|
resp.Attr.Nlink = 1
|
|
resp.Attr.Uid = uint32(os.Getuid())
|
|
resp.Attr.Gid = uint32(os.Getgid())
|
|
resp.Attr.BlockSize = vfs.BlockSize
|
|
return nil
|
|
}
|
|
|
|
func (n *node) Open(ctx context.Context, req *fuse.OpenRequest, resp *fuse.OpenResponse) (bzfs.Handle, error) {
|
|
if n.inode.IsDir() {
|
|
return n, nil // fs.HandleReadDirAller
|
|
}
|
|
// O_TRUNC: caller wants size==0
|
|
if req.Flags&fuse.OpenFlags(os.O_TRUNC) != 0 {
|
|
f, err := n.root.fs.Open(ctx, n.path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := f.Truncate(0); err != nil {
|
|
_ = f.Close()
|
|
return nil, err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
return nil, err
|
|
}
|
|
return &handle{node: n, file: f}, nil
|
|
}
|
|
f, err := n.root.fs.Open(ctx, n.path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &handle{node: n, file: f}, nil
|
|
}
|
|
|
|
func (n *node) Fsync(_ context.Context, _ *fuse.FsyncRequest) error {
|
|
// Directory fsync: persist FS metadata.
|
|
return n.root.fs.Sync(n.root.ctx)
|
|
}
|
|
|
|
// ============================================================================
|
|
// fs.Handle — open file
|
|
// ============================================================================
|
|
|
|
type handle struct {
|
|
node *node
|
|
file *vfs.File
|
|
}
|
|
|
|
var (
|
|
_ bzfs.Handle = (*handle)(nil)
|
|
_ bzfs.HandleReader = (*handle)(nil)
|
|
_ bzfs.HandleWriter = (*handle)(nil)
|
|
_ bzfs.HandleReleaser = (*handle)(nil)
|
|
_ bzfs.HandleFlusher = (*handle)(nil)
|
|
)
|
|
|
|
func (h *handle) Read(_ context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
|
|
buf := make([]byte, req.Size)
|
|
n, err := h.file.ReadAt(buf, req.Offset)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return err
|
|
}
|
|
resp.Data = buf[:n]
|
|
return nil
|
|
}
|
|
|
|
func (h *handle) Write(_ context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
|
|
n, err := h.file.WriteAt(req.Data, req.Offset)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp.Size = n
|
|
return nil
|
|
}
|
|
|
|
func (h *handle) Flush(_ context.Context, _ *fuse.FlushRequest) error {
|
|
return h.file.Sync()
|
|
}
|
|
|
|
func (h *handle) Release(_ context.Context, _ *fuse.ReleaseRequest) error {
|
|
return h.file.Close()
|
|
}
|
|
|
|
// ============================================================================
|
|
// helpers
|
|
// ============================================================================
|
|
|
|
func joinPath(parent, name string) string {
|
|
if parent == "/" {
|
|
return "/" + name
|
|
}
|
|
return parent + "/" + name
|
|
}
|
|
|
|
// keep imports referenced if a build mode strips one
|
|
var _ = time.Now
|