Skip to content

Commit 9928550

Browse files
sjmiller609claude
andcommitted
Add ReadWriteMany (RWX) volume support via NFS
Introduce a volume access mode system with three modes: - ReadWriteOnce (default): single rw or multiple ro, block-backed - ReadOnlyMany: multiple ro only, block-backed - ReadWriteMany: multiple rw/ro via NFS backing NFS volumes don't create local disk images — they store server connection details (server, export path, version, options) in metadata and pass them through to the guest init config. The guest mounts volumes via NFS instead of block devices. Key changes: - Volume domain types: AccessMode enum, NFSConfig struct - Volume manager: RWX-aware attachment rules, NFS creation flow - Instance layer: NFS config propagation, device count skip for NFS - Guest config: "nfs"/"nfs_ro" mount modes with server details - OpenAPI spec: access_mode, nfs fields on create/response - Validation: NFS requires networking, no overlay for NFS volumes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c34825 commit 9928550

File tree

12 files changed

+685
-341
lines changed

12 files changed

+685
-341
lines changed

cmd/api/api/volumes.go

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,31 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume
4646
}, nil
4747
}
4848

49+
var accessMode volumes.AccessMode
50+
if request.Body.AccessMode != nil {
51+
accessMode = volumes.AccessMode(*request.Body.AccessMode)
52+
}
53+
var nfsConfig *volumes.NFSConfig
54+
if request.Body.Nfs != nil {
55+
nfsConfig = &volumes.NFSConfig{
56+
Server: request.Body.Nfs.Server,
57+
ExportPath: request.Body.Nfs.ExportPath,
58+
}
59+
if request.Body.Nfs.Version != nil {
60+
nfsConfig.Version = *request.Body.Nfs.Version
61+
}
62+
if request.Body.Nfs.Options != nil {
63+
nfsConfig.Options = *request.Body.Nfs.Options
64+
}
65+
}
66+
4967
domainReq := volumes.CreateVolumeRequest{
50-
Name: request.Body.Name,
51-
SizeGb: request.Body.SizeGb,
52-
Id: request.Body.Id,
53-
Tags: toMapTags(request.Body.Tags),
68+
Name: request.Body.Name,
69+
SizeGb: request.Body.SizeGb,
70+
AccessMode: accessMode,
71+
NFS: nfsConfig,
72+
Id: request.Body.Id,
73+
Tags: toMapTags(request.Body.Tags),
5474
}
5575

5676
vol, err := s.VolumeManager.CreateVolume(ctx, domainReq)
@@ -61,7 +81,7 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume
6181
Message: "volume with this ID already exists",
6282
}, nil
6383
}
64-
if errors.Is(err, tags.ErrInvalidTags) {
84+
if errors.Is(err, tags.ErrInvalidTags) || errors.Is(err, volumes.ErrInvalidRequest) {
6585
return oapi.CreateVolume400JSONResponse{
6686
Code: "invalid_request",
6787
Message: err.Error(),
@@ -182,12 +202,28 @@ func (s *ApiService) DeleteVolume(ctx context.Context, request oapi.DeleteVolume
182202
}
183203

184204
func volumeToOAPI(vol volumes.Volume) oapi.Volume {
205+
accessMode := oapi.VolumeAccessMode(vol.AccessMode)
185206
oapiVol := oapi.Volume{
186-
Id: vol.Id,
187-
Name: vol.Name,
188-
SizeGb: vol.SizeGb,
189-
Tags: toOAPITags(vol.Tags),
190-
CreatedAt: vol.CreatedAt,
207+
Id: vol.Id,
208+
Name: vol.Name,
209+
SizeGb: vol.SizeGb,
210+
AccessMode: &accessMode,
211+
Tags: toOAPITags(vol.Tags),
212+
CreatedAt: vol.CreatedAt,
213+
}
214+
215+
if vol.NFS != nil {
216+
nfs := &oapi.NFSConfig{
217+
Server: vol.NFS.Server,
218+
ExportPath: vol.NFS.ExportPath,
219+
}
220+
if vol.NFS.Version != "" {
221+
nfs.Version = &vol.NFS.Version
222+
}
223+
if vol.NFS.Options != "" {
224+
nfs.Options = &vol.NFS.Options
225+
}
226+
oapiVol.Nfs = nfs
191227
}
192228

193229
// Convert attachments

lib/instances/admission.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package instances
33
func volumeOverlayReservationBytes(volumes []VolumeAttachment) int64 {
44
var total int64
55
for _, vol := range volumes {
6-
if vol.Overlay {
6+
if vol.Overlay && vol.NFS == nil {
77
total += vol.OverlaySize
88
}
99
}

lib/instances/configdisk.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,28 @@ func (m *manager) buildGuestConfig(ctx context.Context, inst *Instance, imageInf
9898
}
9999

100100
// Volume mounts
101-
// Volumes are attached as /dev/vdd, /dev/vde, etc. (after vda=rootfs, vdb=overlay, vdc=config)
101+
// Block volumes are attached as /dev/vdd, /dev/vde, etc. (after vda=rootfs, vdb=overlay, vdc=config)
102+
// NFS volumes don't consume a block device — they're mounted via the network.
102103
deviceIdx := 0
103104
for _, vol := range inst.Volumes {
105+
if vol.NFS != nil {
106+
// NFS-backed volume (ReadWriteMany) — no block device, guest mounts via NFS
107+
mount := vmconfig.VolumeMount{
108+
Path: vol.MountPath,
109+
Mode: "nfs",
110+
NFSServer: vol.NFS.Server,
111+
NFSExportPath: vol.NFS.ExportPath,
112+
NFSVersion: vol.NFS.Version,
113+
NFSOptions: vol.NFS.Options,
114+
}
115+
if vol.Readonly {
116+
mount.Mode = "nfs_ro"
117+
}
118+
cfg.VolumeMounts = append(cfg.VolumeMounts, mount)
119+
continue
120+
}
121+
122+
// Block-backed volume
104123
device := fmt.Sprintf("/dev/vd%c", 'd'+deviceIdx)
105124
mount := vmconfig.VolumeMount{
106125
Device: device,

lib/instances/create.go

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -420,14 +420,31 @@ func (m *manager) createInstance(
420420
// 15. Validate and attach volumes
421421
if len(req.Volumes) > 0 {
422422
log.DebugContext(ctx, "validating volumes", "instance_id", id, "count", len(req.Volumes))
423-
for _, volAttach := range req.Volumes {
424-
// Check volume exists
425-
_, err := m.volumeManager.GetVolume(ctx, volAttach.VolumeID)
423+
resolvedVolumes := make([]VolumeAttachment, len(req.Volumes))
424+
for i, volAttach := range req.Volumes {
425+
// Check volume exists and get its details
426+
vol, err := m.volumeManager.GetVolume(ctx, volAttach.VolumeID)
426427
if err != nil {
427428
log.ErrorContext(ctx, "volume not found", "instance_id", id, "volume_id", volAttach.VolumeID, "error", err)
428429
return nil, fmt.Errorf("volume %s: %w", volAttach.VolumeID, err)
429430
}
430431

432+
resolvedVolumes[i] = volAttach
433+
434+
// Populate NFS config from the volume for RWX volumes
435+
if vol.NFS != nil {
436+
resolvedVolumes[i].NFS = &VolumeNFSConfig{
437+
Server: vol.NFS.Server,
438+
ExportPath: vol.NFS.ExportPath,
439+
Version: vol.NFS.Version,
440+
Options: vol.NFS.Options,
441+
}
442+
// NFS volumes require networking for NFS client access
443+
if !req.NetworkEnabled {
444+
return nil, fmt.Errorf("volume %s: NFS volumes require network.enabled=true", volAttach.VolumeID)
445+
}
446+
}
447+
431448
// Mark volume as attached (AttachVolume handles multi-attach validation)
432449
if err := m.volumeManager.AttachVolume(ctx, volAttach.VolumeID, volumes.AttachVolumeRequest{
433450
InstanceID: id,
@@ -444,7 +461,7 @@ func (m *manager) createInstance(
444461
m.volumeManager.DetachVolume(ctx, volumeID, id)
445462
})
446463

447-
// Create overlay disk for volumes with overlay enabled
464+
// Create overlay disk for volumes with overlay enabled (not for NFS)
448465
if volAttach.Overlay {
449466
log.DebugContext(ctx, "creating volume overlay disk", "instance_id", id, "volume_id", volAttach.VolumeID, "size", volAttach.OverlaySize)
450467
if err := m.createVolumeOverlayDisk(id, volAttach.VolumeID, volAttach.OverlaySize); err != nil {
@@ -453,8 +470,12 @@ func (m *manager) createInstance(
453470
}
454471
}
455472
}
456-
// Store volume attachments in metadata
457-
stored.Volumes = req.Volumes
473+
// Re-validate with NFS info populated
474+
if err := validateVolumeAttachments(resolvedVolumes); err != nil {
475+
return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err)
476+
}
477+
// Store resolved volume attachments in metadata
478+
stored.Volumes = resolvedVolumes
458479
}
459480

460481
// 16. Create config disk (needs Instance for buildVMConfig)
@@ -614,10 +635,13 @@ func validateCreateRequest(req *CreateInstanceRequest) error {
614635
}
615636

616637
// validateVolumeAttachments validates volume attachment requests
617-
func validateVolumeAttachments(volumes []VolumeAttachment) error {
618-
// Count total devices needed (each overlay volume needs 2 devices: base + overlay)
638+
func validateVolumeAttachments(vols []VolumeAttachment) error {
639+
// Count total block devices needed (NFS volumes don't consume a device)
619640
totalDevices := 0
620-
for _, vol := range volumes {
641+
for _, vol := range vols {
642+
if vol.NFS != nil {
643+
continue // NFS volumes don't use block devices
644+
}
621645
totalDevices++
622646
if vol.Overlay {
623647
totalDevices++ // Overlay needs an additional device
@@ -628,7 +652,7 @@ func validateVolumeAttachments(volumes []VolumeAttachment) error {
628652
}
629653

630654
seenPaths := make(map[string]bool)
631-
for _, vol := range volumes {
655+
for _, vol := range vols {
632656
// Validate mount path is absolute
633657
if !filepath.IsAbs(vol.MountPath) {
634658
return fmt.Errorf("volume %s: mount path %q must be absolute", vol.VolumeID, vol.MountPath)
@@ -648,6 +672,11 @@ func validateVolumeAttachments(volumes []VolumeAttachment) error {
648672
}
649673
seenPaths[cleanPath] = true
650674

675+
// NFS volumes cannot use overlay mode
676+
if vol.NFS != nil && vol.Overlay {
677+
return fmt.Errorf("volume %s: overlay mode is not supported for NFS volumes", vol.VolumeID)
678+
}
679+
651680
// Validate overlay mode requirements
652681
if vol.Overlay {
653682
if !vol.Readonly {
@@ -765,8 +794,11 @@ func (m *manager) buildHypervisorConfig(ctx context.Context, inst *Instance, ima
765794
{Path: m.paths.InstanceConfigDisk(inst.Id), Readonly: true, IOBps: ioBps, IOBurstBps: burstBps},
766795
}
767796

768-
// Add attached volumes as additional disks
797+
// Add attached volumes as additional disks (skip NFS volumes — they're network-mounted)
769798
for _, volAttach := range inst.Volumes {
799+
if volAttach.NFS != nil {
800+
continue // NFS volumes don't have a local block device
801+
}
770802
volumePath := m.volumeManager.GetVolumePath(volAttach.VolumeID)
771803
if volAttach.Overlay {
772804
// Base volume is always read-only when overlay is enabled

lib/instances/types.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,22 @@ const (
3030
EgressEnforcementModeHTTPHTTPSOnly EgressEnforcementMode = "http_https_only"
3131
)
3232

33+
// VolumeNFSConfig holds NFS connection details for an NFS-backed volume attachment.
34+
type VolumeNFSConfig struct {
35+
Server string // NFS server hostname or IP
36+
ExportPath string // NFS export path
37+
Version string // NFS protocol version (e.g., "4.1")
38+
Options string // Additional mount options
39+
}
40+
3341
// VolumeAttachment represents a volume attached to an instance
3442
type VolumeAttachment struct {
35-
VolumeID string // Volume ID
36-
MountPath string // Mount path in guest
37-
Readonly bool // Whether mounted read-only
38-
Overlay bool // If true, create per-instance overlay for writes (requires Readonly=true)
39-
OverlaySize int64 // Size of overlay disk in bytes (max diff from base)
43+
VolumeID string // Volume ID
44+
MountPath string // Mount path in guest
45+
Readonly bool // Whether mounted read-only
46+
Overlay bool // If true, create per-instance overlay for writes (requires Readonly=true)
47+
OverlaySize int64 // Size of overlay disk in bytes (max diff from base)
48+
NFS *VolumeNFSConfig // NFS config (set for ReadWriteMany volumes, nil for block volumes)
4049
}
4150

4251
// NetworkEgressPolicy configures host-mediated outbound networking behavior.

0 commit comments

Comments
 (0)