Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/kernel/hypeman/lib/imageretention"
"github.com/kernel/hypeman/lib/images"
"github.com/kernel/hypeman/lib/instances"
"github.com/kernel/hypeman/lib/network"
loglib "github.com/kernel/hypeman/lib/logger"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/oapi"
Expand All @@ -37,6 +38,7 @@ import (
"github.com/kernel/hypeman/lib/registry"
"github.com/kernel/hypeman/lib/scopes"
"github.com/kernel/hypeman/lib/vmm"
"github.com/kernel/hypeman/lib/volumes"
nethttpmiddleware "github.com/oapi-codegen/nethttp-middleware"
"github.com/riandyrn/otelchi"
"go.opentelemetry.io/otel/metric"
Expand Down Expand Up @@ -265,6 +267,19 @@ func run() error {
return fmt.Errorf("initialize network manager: %w", err)
}

// Configure NFS host IP for ReadWriteMany volume support.
// The gateway IP is the host's address on the VM bridge — VMs can reach NFS at this address.
if nfsSetter, ok := app.VolumeManager.(volumes.NFSHostSetter); ok {
gateway := app.Config.Network.SubnetGateway
if gateway == "" {
gateway, _ = network.DeriveGateway(app.Config.Network.SubnetCIDR)
}
if gateway != "" {
nfsSetter.SetNFSHost(gateway)
logger.Info("NFS host configured for ReadWriteMany volumes", "host", gateway)
}
}

// Set up HTB qdisc on bridge for network fair sharing
networkCapacity := app.ResourceManager.NetworkCapacity()
if err := app.NetworkManager.SetupHTB(app.Ctx, networkCapacity); err != nil {
Expand Down
4 changes: 4 additions & 0 deletions lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ func (m *mockVolumeManager) TotalVolumeBytes(ctx context.Context) (int64, error)
return 0, nil
}

func (m *mockVolumeManager) GetVolumeNFSInfo(ctx context.Context, id string) (*volumes.NFSInfo, error) {
return nil, nil
}

// mockSecretProvider implements SecretProvider for testing
type mockSecretProvider struct{}

Expand Down
15 changes: 15 additions & 0 deletions lib/instances/configdisk.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,23 @@ func (m *manager) buildGuestConfig(ctx context.Context, inst *Instance, imageInf

// Volume mounts
// Volumes are attached as /dev/vdd, /dev/vde, etc. (after vda=rootfs, vdb=overlay, vdc=config)
// NFS-served volumes do not consume a device slot — they mount via network.
deviceIdx := 0
for _, vol := range inst.Volumes {
// Check if this volume is NFS-served (ReadWriteMany)
nfsInfo, _ := m.volumeManager.GetVolumeNFSInfo(ctx, vol.VolumeID)
if nfsInfo != nil && !vol.Readonly {
// NFS mount — no block device needed
mount := vmconfig.VolumeMount{
Path: vol.MountPath,
Mode: "nfs",
NFSHost: nfsInfo.Host,
NFSExport: nfsInfo.ExportPath,
}
cfg.VolumeMounts = append(cfg.VolumeMounts, mount)
continue
}

device := fmt.Sprintf("/dev/vd%c", 'd'+deviceIdx)
mount := vmconfig.VolumeMount{
Device: device,
Expand Down
9 changes: 8 additions & 1 deletion lib/instances/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -765,8 +765,15 @@ func (m *manager) buildHypervisorConfig(ctx context.Context, inst *Instance, ima
{Path: m.paths.InstanceConfigDisk(inst.Id), Readonly: true, IOBps: ioBps, IOBurstBps: burstBps},
}

// Add attached volumes as additional disks
// Add attached volumes as additional disks.
// NFS-served volumes are mounted via the network and do NOT get a block device.
for _, volAttach := range inst.Volumes {
// Skip NFS-served volumes — they're mounted via NFS in the guest, not as block devices
nfsInfo, _ := m.volumeManager.GetVolumeNFSInfo(ctx, volAttach.VolumeID)
if nfsInfo != nil && !volAttach.Readonly {
continue
}

volumePath := m.volumeManager.GetVolumePath(volAttach.VolumeID)
if volAttach.Overlay {
// Base volume is always read-only when overlay is enabled
Expand Down
555 changes: 288 additions & 267 deletions lib/oapi/oapi.go

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions lib/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ func (p *Paths) VolumeMetadata(id string) string {
return filepath.Join(p.VolumeDir(id), "metadata.json")
}

// VolumeNFSMount returns the directory where a volume's data.raw is loop-mounted for NFS export.
func (p *Paths) VolumeNFSMount(id string) string {
return filepath.Join(p.VolumeDir(id), "nfs_mount")
}

// Caddy path methods

// CaddyDir returns the caddy data directory.
Expand Down
20 changes: 19 additions & 1 deletion lib/system/init/volumes.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

// mountVolumes mounts attached volumes according to the configuration.
// Supports three modes: ro (read-only), rw (read-write), and overlay.
// Supports four modes: ro (read-only), rw (read-write), overlay, and nfs.
func mountVolumes(log *Logger, cfg *vmconfig.Config) error {
log.Info("hypeman-init:volumes", "mounting volumes")

Expand All @@ -32,6 +32,10 @@ func mountVolumes(log *Logger, cfg *vmconfig.Config) error {
if err := mountVolumeReadOnly(log, vol, mountPath); err != nil {
log.Error("hypeman-init:volumes", fmt.Sprintf("mount ro %s failed", vol.Path), err)
}
case "nfs":
if err := mountVolumeNFS(log, vol, mountPath); err != nil {
log.Error("hypeman-init:volumes", fmt.Sprintf("mount nfs %s failed", vol.Path), err)
}
default: // "rw"
if err := mountVolumeReadWrite(log, vol, mountPath); err != nil {
log.Error("hypeman-init:volumes", fmt.Sprintf("mount rw %s failed", vol.Path), err)
Expand Down Expand Up @@ -110,3 +114,17 @@ func mountVolumeReadWrite(log *Logger, vol vmconfig.VolumeMount, mountPath strin
log.Info("hypeman-init:volumes", fmt.Sprintf("mounted %s at %s (rw)", vol.Device, vol.Path))
return nil
}

// mountVolumeNFS mounts a volume via NFS from the host.
// Used for ReadWriteMany volumes where multiple VMs need concurrent rw access.
func mountVolumeNFS(log *Logger, vol vmconfig.VolumeMount, mountPath string) error {
nfsSource := fmt.Sprintf("%s:%s", vol.NFSHost, vol.NFSExport)
// Use NFSv4 with tcp, hard mount for data safety, relatively short timeo for responsiveness
cmd := exec.Command("/bin/mount", "-t", "nfs", "-o", "vers=4,tcp,hard,timeo=100,retrans=3", nfsSource, mountPath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("mount nfs %s: %s: %s", nfsSource, err, output)
}

log.Info("hypeman-init:volumes", fmt.Sprintf("mounted %s at %s (nfs)", nfsSource, vol.Path))
return nil
}
4 changes: 3 additions & 1 deletion lib/vmconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ type Config struct {
type VolumeMount struct {
Device string `json:"device"`
Path string `json:"path"`
Mode string `json:"mode"` // "ro", "rw", or "overlay"
Mode string `json:"mode"` // "ro", "rw", "overlay", or "nfs"
OverlayDevice string `json:"overlay_device,omitempty"`
NFSHost string `json:"nfs_host,omitempty"` // Host IP for NFS mount (mode=nfs)
NFSExport string `json:"nfs_export,omitempty"` // Export path on host (mode=nfs)
}

// EgressProxyConfig configures guest-side trust and proxy endpoint wiring.
Expand Down
Loading
Loading