Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
88 changes: 84 additions & 4 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,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 @@ -177,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 @@ -200,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 @@ -228,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 @@ -283,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 @@ -294,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
33 changes: 32 additions & 1 deletion 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 Down
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
5 changes: 4 additions & 1 deletion cmd/api/wire_gen.go

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

54 changes: 52 additions & 2 deletions lib/instances/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,31 @@ var systemDirectories = []string{
"/var",
}

// AggregateUsage represents total resource usage across all instances
type AggregateUsage struct {
TotalVcpus int
TotalMemory int64 // in bytes
}

// calculateAggregateUsage calculates total resource usage across all running instances
func (m *manager) calculateAggregateUsage(ctx context.Context) (AggregateUsage, error) {
instances, err := m.listInstances(ctx)
if err != nil {
return AggregateUsage{}, err
}

var usage AggregateUsage
for _, inst := range instances {
// Only count running/paused instances (those consuming resources)
if inst.State == StateRunning || inst.State == StatePaused || inst.State == StateCreated {
usage.TotalVcpus += inst.Vcpus
usage.TotalMemory += inst.Size + inst.HotplugSize
}
}

return usage, nil
}

// generateVsockCID converts first 8 chars of instance ID to a unique CID
// CIDs 0-2 are reserved (hypervisor, loopback, host)
// Returns value in range 3 to 4294967295
Expand Down Expand Up @@ -121,13 +146,38 @@ func (m *manager) createInstance(
overlaySize = 10 * 1024 * 1024 * 1024 // 10GB default
}
// Validate overlay size against max
if overlaySize > m.maxOverlaySize {
return nil, fmt.Errorf("overlay size %d exceeds maximum allowed size %d", overlaySize, m.maxOverlaySize)
if overlaySize > m.limits.MaxOverlaySize {
return nil, fmt.Errorf("overlay size %d exceeds maximum allowed size %d", overlaySize, m.limits.MaxOverlaySize)
}
vcpus := req.Vcpus
if vcpus == 0 {
vcpus = 2
}

// Validate per-instance resource limits
if m.limits.MaxVcpusPerInstance > 0 && vcpus > m.limits.MaxVcpusPerInstance {
return nil, fmt.Errorf("vcpus %d exceeds maximum allowed %d per instance", vcpus, m.limits.MaxVcpusPerInstance)
}
totalMemory := size + hotplugSize
if m.limits.MaxMemoryPerInstance > 0 && totalMemory > m.limits.MaxMemoryPerInstance {
return nil, fmt.Errorf("total memory %d (size + hotplug_size) exceeds maximum allowed %d per instance", totalMemory, m.limits.MaxMemoryPerInstance)
}

// Validate aggregate resource limits
if m.limits.MaxTotalVcpus > 0 || m.limits.MaxTotalMemory > 0 {
usage, err := m.calculateAggregateUsage(ctx)
if err != nil {
log.WarnContext(ctx, "failed to calculate aggregate usage, skipping limit check", "error", err)
} else {
if m.limits.MaxTotalVcpus > 0 && usage.TotalVcpus+vcpus > m.limits.MaxTotalVcpus {
return nil, fmt.Errorf("total vcpus would be %d, exceeds aggregate limit of %d", usage.TotalVcpus+vcpus, m.limits.MaxTotalVcpus)
}
if m.limits.MaxTotalMemory > 0 && usage.TotalMemory+totalMemory > m.limits.MaxTotalMemory {
return nil, fmt.Errorf("total memory would be %d, exceeds aggregate limit of %d", usage.TotalMemory+totalMemory, m.limits.MaxTotalMemory)
}
}
}

if req.Env == nil {
req.Env = make(map[string]string)
}
Expand Down
3 changes: 3 additions & 0 deletions lib/instances/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ var (

// ErrImageNotReady is returned when the image is not ready for use
ErrImageNotReady = errors.New("image not ready")

// ErrAmbiguousName is returned when multiple instances have the same name
ErrAmbiguousName = errors.New("multiple instances with the same name")
)
Loading