Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
8 changes: 5 additions & 3 deletions cmd/api/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ func newTestService(t *testing.T) *ApiService {

systemMgr := system.NewManager(p)
networkMgr := network.NewManager(p, cfg)
maxOverlaySize := int64(100 * 1024 * 1024 * 1024) // 100GB for tests
instanceMgr := instances.NewManager(p, imageMgr, systemMgr, networkMgr, maxOverlaySize)
volumeMgr := volumes.NewManager(p)
volumeMgr := volumes.NewManager(p, 0) // 0 = unlimited storage
limits := instances.ResourceLimits{
MaxOverlaySize: 100 * 1024 * 1024 * 1024, // 100GB
}
instanceMgr := instances.NewManager(p, imageMgr, systemMgr, networkMgr, volumeMgr, limits)

// Register cleanup for orphaned Cloud Hypervisor processes
t.Cleanup(func() {
Expand Down
119 changes: 115 additions & 4 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
networkEnabled = *request.Body.Network.Enabled
}

// Parse volumes
var volumes []instances.VolumeAttachment
if request.Body.Volumes != nil {
volumes = make([]instances.VolumeAttachment, len(*request.Body.Volumes))
for i, vol := range *request.Body.Volumes {
readonly := false
if vol.Readonly != nil {
readonly = *vol.Readonly
}
volumes[i] = instances.VolumeAttachment{
VolumeID: vol.VolumeId,
MountPath: vol.MountPath,
Readonly: readonly,
}
}
}

domainReq := instances.CreateInstanceRequest{
Name: request.Body.Name,
Image: request.Body.Image,
Expand All @@ -104,6 +121,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
Vcpus: vcpus,
Env: env,
NetworkEnabled: networkEnabled,
Volumes: volumes,
}

inst, err := s.InstanceManager.CreateInstance(ctx, domainReq)
Expand Down Expand Up @@ -136,17 +154,29 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
}

// GetInstance gets instance details
// The id parameter can be either an instance ID or name
func (s *ApiService) GetInstance(ctx context.Context, request oapi.GetInstanceRequestObject) (oapi.GetInstanceResponseObject, error) {
log := logger.FromContext(ctx)

// Try lookup by ID first
inst, err := s.InstanceManager.GetInstance(ctx, request.Id)
if errors.Is(err, instances.ErrNotFound) {
// Try lookup by name
inst, err = s.InstanceManager.GetInstanceByName(ctx, request.Id)
}

if err != nil {
switch {
case errors.Is(err, instances.ErrNotFound):
return oapi.GetInstance404JSONResponse{
Code: "not_found",
Message: "instance not found",
}, nil
case errors.Is(err, instances.ErrAmbiguousName):
return oapi.GetInstance404JSONResponse{
Code: "ambiguous_name",
Message: "multiple instances have this name, use instance ID instead",
}, nil
default:
log.Error("failed to get instance", "error", err, "id", request.Id)
return oapi.GetInstance500JSONResponse{
Expand All @@ -159,10 +189,27 @@ func (s *ApiService) GetInstance(ctx context.Context, request oapi.GetInstanceRe
}

// DeleteInstance stops and deletes an instance
// The id parameter can be either an instance ID or name
func (s *ApiService) DeleteInstance(ctx context.Context, request oapi.DeleteInstanceRequestObject) (oapi.DeleteInstanceResponseObject, error) {
log := logger.FromContext(ctx)

err := s.InstanceManager.DeleteInstance(ctx, request.Id)
// Resolve ID - try direct ID first, then name lookup
instanceID := request.Id
_, err := s.InstanceManager.GetInstance(ctx, request.Id)
if errors.Is(err, instances.ErrNotFound) {
// Try lookup by name
inst, nameErr := s.InstanceManager.GetInstanceByName(ctx, request.Id)
if nameErr == nil {
instanceID = inst.Id
} else if errors.Is(nameErr, instances.ErrAmbiguousName) {
return oapi.DeleteInstance404JSONResponse{
Code: "ambiguous_name",
Message: "multiple instances have this name, use instance ID instead",
}, nil
}
}

err = s.InstanceManager.DeleteInstance(ctx, instanceID)
if err != nil {
switch {
case errors.Is(err, instances.ErrNotFound):
Expand All @@ -182,10 +229,27 @@ func (s *ApiService) DeleteInstance(ctx context.Context, request oapi.DeleteInst
}

// StandbyInstance puts an instance in standby (pause, snapshot, delete VMM)
// The id parameter can be either an instance ID or name
func (s *ApiService) StandbyInstance(ctx context.Context, request oapi.StandbyInstanceRequestObject) (oapi.StandbyInstanceResponseObject, error) {
log := logger.FromContext(ctx)

inst, err := s.InstanceManager.StandbyInstance(ctx, request.Id)
// Resolve ID - try direct ID first, then name lookup
instanceID := request.Id
_, err := s.InstanceManager.GetInstance(ctx, request.Id)
if errors.Is(err, instances.ErrNotFound) {
// Try lookup by name
inst, nameErr := s.InstanceManager.GetInstanceByName(ctx, request.Id)
if nameErr == nil {
instanceID = inst.Id
} else if errors.Is(nameErr, instances.ErrAmbiguousName) {
return oapi.StandbyInstance404JSONResponse{
Code: "ambiguous_name",
Message: "multiple instances have this name, use instance ID instead",
}, nil
}
}

inst, err := s.InstanceManager.StandbyInstance(ctx, instanceID)
if err != nil {
switch {
case errors.Is(err, instances.ErrNotFound):
Expand All @@ -210,10 +274,27 @@ func (s *ApiService) StandbyInstance(ctx context.Context, request oapi.StandbyIn
}

// RestoreInstance restores an instance from standby
// The id parameter can be either an instance ID or name
func (s *ApiService) RestoreInstance(ctx context.Context, request oapi.RestoreInstanceRequestObject) (oapi.RestoreInstanceResponseObject, error) {
log := logger.FromContext(ctx)

inst, err := s.InstanceManager.RestoreInstance(ctx, request.Id)
// Resolve ID - try direct ID first, then name lookup
instanceID := request.Id
_, err := s.InstanceManager.GetInstance(ctx, request.Id)
if errors.Is(err, instances.ErrNotFound) {
// Try lookup by name
inst, nameErr := s.InstanceManager.GetInstanceByName(ctx, request.Id)
if nameErr == nil {
instanceID = inst.Id
} else if errors.Is(nameErr, instances.ErrAmbiguousName) {
return oapi.RestoreInstance404JSONResponse{
Code: "ambiguous_name",
Message: "multiple instances have this name, use instance ID instead",
}, nil
}
}

inst, err := s.InstanceManager.RestoreInstance(ctx, instanceID)
if err != nil {
switch {
case errors.Is(err, instances.ErrNotFound):
Expand Down Expand Up @@ -265,6 +346,7 @@ func (r logsStreamResponse) VisitGetInstanceLogsResponse(w http.ResponseWriter)
// GetInstanceLogs streams instance logs via SSE
// With follow=false (default), streams last N lines then closes
// With follow=true, streams last N lines then continues following new output
// The id parameter can be either an instance ID or name
func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstanceLogsRequestObject) (oapi.GetInstanceLogsResponseObject, error) {
tail := 100
if request.Params.Tail != nil {
Expand All @@ -276,7 +358,23 @@ func (s *ApiService) GetInstanceLogs(ctx context.Context, request oapi.GetInstan
follow = *request.Params.Follow
}

logChan, err := s.InstanceManager.StreamInstanceLogs(ctx, request.Id, tail, follow)
// Resolve ID - try direct ID first, then name lookup
instanceID := request.Id
_, err := s.InstanceManager.GetInstance(ctx, request.Id)
if errors.Is(err, instances.ErrNotFound) {
// Try lookup by name
inst, nameErr := s.InstanceManager.GetInstanceByName(ctx, request.Id)
if nameErr == nil {
instanceID = inst.Id
} else if errors.Is(nameErr, instances.ErrAmbiguousName) {
return oapi.GetInstanceLogs404JSONResponse{
Code: "ambiguous_name",
Message: "multiple instances have this name, use instance ID instead",
}, nil
}
}

logChan, err := s.InstanceManager.StreamInstanceLogs(ctx, instanceID, tail, follow)
if err != nil {
switch {
case errors.Is(err, instances.ErrNotFound):
Expand Down Expand Up @@ -359,5 +457,18 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
oapiInst.Env = &inst.Env
}

// Convert volume attachments
if len(inst.Volumes) > 0 {
oapiVolumes := make([]oapi.VolumeAttachment, len(inst.Volumes))
for i, vol := range inst.Volumes {
oapiVolumes[i] = oapi.VolumeAttachment{
VolumeId: vol.VolumeID,
MountPath: vol.MountPath,
Readonly: lo.ToPtr(vol.Readonly),
}
}
oapiInst.Volumes = &oapiVolumes
}

return oapiInst
}
42 changes: 40 additions & 2 deletions cmd/api/api/volumes.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,29 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume
}

// GetVolume gets volume details
// The id parameter can be either a volume ID or name
func (s *ApiService) GetVolume(ctx context.Context, request oapi.GetVolumeRequestObject) (oapi.GetVolumeResponseObject, error) {
log := logger.FromContext(ctx)

// Try lookup by ID first
vol, err := s.VolumeManager.GetVolume(ctx, request.Id)
if errors.Is(err, volumes.ErrNotFound) {
// Try lookup by name
vol, err = s.VolumeManager.GetVolumeByName(ctx, request.Id)
}

if err != nil {
switch {
case errors.Is(err, volumes.ErrNotFound):
return oapi.GetVolume404JSONResponse{
Code: "not_found",
Message: "volume not found",
}, nil
case errors.Is(err, volumes.ErrAmbiguousName):
return oapi.GetVolume404JSONResponse{
Code: "ambiguous_name",
Message: "multiple volumes have this name, use volume ID instead",
}, nil
default:
log.Error("failed to get volume", "error", err, "id", request.Id)
return oapi.GetVolume500JSONResponse{
Expand All @@ -75,10 +87,29 @@ func (s *ApiService) GetVolume(ctx context.Context, request oapi.GetVolumeReques
}

// DeleteVolume deletes a volume
// The id parameter can be either a volume ID or name
func (s *ApiService) DeleteVolume(ctx context.Context, request oapi.DeleteVolumeRequestObject) (oapi.DeleteVolumeResponseObject, error) {
log := logger.FromContext(ctx)

err := s.VolumeManager.DeleteVolume(ctx, request.Id)
// Resolve ID - try direct ID first, then name lookup
volumeID := request.Id
_, err := s.VolumeManager.GetVolume(ctx, request.Id)
if errors.Is(err, volumes.ErrNotFound) {
// Try lookup by name
vol, nameErr := s.VolumeManager.GetVolumeByName(ctx, request.Id)
if nameErr == nil {
volumeID = vol.Id
} else if errors.Is(nameErr, volumes.ErrAmbiguousName) {
return oapi.DeleteVolume404JSONResponse{
Code: "ambiguous_name",
Message: "multiple volumes have this name, use volume ID instead",
}, nil
}
// If name lookup also fails with ErrNotFound, we'll proceed with original ID
// and let DeleteVolume return the proper 404
}

err = s.VolumeManager.DeleteVolume(ctx, volumeID)
if err != nil {
switch {
case errors.Is(err, volumes.ErrNotFound):
Expand All @@ -103,11 +134,18 @@ func (s *ApiService) DeleteVolume(ctx context.Context, request oapi.DeleteVolume
}

func volumeToOAPI(vol volumes.Volume) oapi.Volume {
return oapi.Volume{
oapiVol := oapi.Volume{
Id: vol.Id,
Name: vol.Name,
SizeGb: vol.SizeGb,
CreatedAt: vol.CreatedAt,
}
if vol.AttachedTo != nil {
oapiVol.AttachedTo = vol.AttachedTo
}
if vol.MountPath != nil {
oapiVol.MountPath = vol.MountPath
}
return oapiVol
}

46 changes: 46 additions & 0 deletions cmd/api/api/volumes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,49 @@ func TestGetVolume_NotFound(t *testing.T) {
assert.Equal(t, "not_found", notFound.Code)
}

func TestGetVolume_ByName(t *testing.T) {
svc := newTestService(t)

// Create a volume
createResp, err := svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
Body: &oapi.CreateVolumeRequest{
Name: "my-data",
SizeGb: 1,
},
})
require.NoError(t, err)
created := createResp.(oapi.CreateVolume201JSONResponse)

// Get by name (not ID)
resp, err := svc.GetVolume(ctx(), oapi.GetVolumeRequestObject{
Id: "my-data", // using name instead of ID
})
require.NoError(t, err)

vol, ok := resp.(oapi.GetVolume200JSONResponse)
require.True(t, ok, "expected 200 response")
assert.Equal(t, created.Id, vol.Id)
assert.Equal(t, "my-data", vol.Name)
}

func TestDeleteVolume_ByName(t *testing.T) {
svc := newTestService(t)

// Create a volume
_, err := svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
Body: &oapi.CreateVolumeRequest{
Name: "to-delete",
SizeGb: 1,
},
})
require.NoError(t, err)

// Delete by name
resp, err := svc.DeleteVolume(ctx(), oapi.DeleteVolumeRequestObject{
Id: "to-delete",
})
require.NoError(t, err)
_, ok := resp.(oapi.DeleteVolume204Response)
assert.True(t, ok, "expected 204 response")
}

18 changes: 18 additions & 0 deletions cmd/api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ type Config struct {
LogMaxSize string
LogMaxFiles int
LogRotateInterval string

// Resource limits - per instance
MaxVcpusPerInstance int // Max vCPUs for a single VM (0 = unlimited)
MaxMemoryPerInstance string // Max memory for a single VM (0 = unlimited)

// Resource limits - aggregate
MaxTotalVcpus int // Aggregate vCPU limit across all instances (0 = unlimited)
MaxTotalMemory string // Aggregate memory limit across all instances (0 = unlimited)
MaxTotalVolumeStorage string // Total volume storage limit (0 = unlimited)
}

// Load loads configuration from environment variables
Expand All @@ -43,6 +52,15 @@ func Load() *Config {
LogMaxSize: getEnv("LOG_MAX_SIZE", "50MB"),
LogMaxFiles: getEnvInt("LOG_MAX_FILES", 1),
LogRotateInterval: getEnv("LOG_ROTATE_INTERVAL", "5m"),

// Resource limits - per instance (0 = unlimited)
MaxVcpusPerInstance: getEnvInt("MAX_VCPUS_PER_INSTANCE", 16),
MaxMemoryPerInstance: getEnv("MAX_MEMORY_PER_INSTANCE", "32GB"),

// Resource limits - aggregate (0 or empty = unlimited)
MaxTotalVcpus: getEnvInt("MAX_TOTAL_VCPUS", 0),
MaxTotalMemory: getEnv("MAX_TOTAL_MEMORY", ""),
MaxTotalVolumeStorage: getEnv("MAX_TOTAL_VOLUME_STORAGE", ""),
}

return cfg
Expand Down
7 changes: 5 additions & 2 deletions cmd/api/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading