Skip to content
Merged
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
58 changes: 58 additions & 0 deletions cmd/api/api/volumes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package api
import (
"context"
"errors"
"fmt"
"sort"

"github.com/kernel/hypeman/lib/logger"
mw "github.com/kernel/hypeman/lib/middleware"
Expand Down Expand Up @@ -46,6 +48,21 @@ func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolume
}, nil
}

if request.Body.Id != nil {
if prefix := volumes.ReservedVolumeIDPrefix(*request.Body.Id); prefix != "" {
return oapi.CreateVolume400JSONResponse{
Code: "invalid_request",
Message: fmt.Sprintf("volume IDs with the prefix %q are reserved for internal use", prefix),
}, nil
}
}
if key := reservedTagKey(request.Body.Tags); key != "" {
return oapi.CreateVolume400JSONResponse{
Code: "invalid_request",
Message: fmt.Sprintf("tag key %q is reserved for internal use", key),
}, nil
}

domainReq := volumes.CreateVolumeRequest{
Name: request.Body.Name,
SizeGb: request.Body.SizeGb,
Expand Down Expand Up @@ -97,6 +114,21 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C
// Note: request.Body is never nil in Go's net/http (empty body = http.NoBody)
// Empty/invalid archives will fail with a clear gzip error downstream

if request.Params.Id != nil {
if prefix := volumes.ReservedVolumeIDPrefix(*request.Params.Id); prefix != "" {
return oapi.CreateVolumeFromArchive400JSONResponse{
Code: "invalid_request",
Message: fmt.Sprintf("volume IDs with the prefix %q are reserved for internal use", prefix),
}, nil
}
}
if key := reservedTagKey(request.Params.Tags); key != "" {
return oapi.CreateVolumeFromArchive400JSONResponse{
Code: "invalid_request",
Message: fmt.Sprintf("tag key %q is reserved for internal use", key),
}, nil
}

// Create the volume from archive - stream directly without buffering
domainReq := volumes.CreateVolumeFromArchiveRequest{
Name: request.Params.Name,
Expand Down Expand Up @@ -162,6 +194,13 @@ func (s *ApiService) DeleteVolume(ctx context.Context, request oapi.DeleteVolume
}
log := logger.FromContext(ctx)

if prefix := volumes.ReservedVolumeIDPrefix(vol.Id); prefix != "" {
return oapi.DeleteVolume409JSONResponse{
Code: "conflict",
Message: fmt.Sprintf("volume IDs with the prefix %q are reserved for internal use", prefix),
}, nil
}

err := s.VolumeManager.DeleteVolume(ctx, vol.Id)
if err != nil {
switch {
Expand All @@ -181,6 +220,25 @@ func (s *ApiService) DeleteVolume(ctx context.Context, request oapi.DeleteVolume
return oapi.DeleteVolume204Response{}, nil
}

// reservedTagKey returns the first caller-supplied tag key in the reserved
// internal namespace, or "" when all keys are usable by API callers.
func reservedTagKey(resourceTags *oapi.Tags) string {
if resourceTags == nil {
return ""
}
keys := make([]string, 0, len(*resourceTags))
for key := range *resourceTags {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if volumes.ReservedTagNamespace(key) != "" {
return key
}
}
return ""
}

func volumeToOAPI(vol volumes.Volume) oapi.Volume {
oapiVol := oapi.Volume{
Id: vol.Id,
Expand Down
94 changes: 94 additions & 0 deletions cmd/api/api/volumes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/kernel/hypeman/lib/oapi"
"github.com/kernel/hypeman/lib/volumes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -77,3 +78,96 @@ func TestDeleteVolume_ByName(t *testing.T) {
_, ok := resp.(oapi.DeleteVolume204Response)
assert.True(t, ok, "expected 204 response")
}

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

id := "builder-disk-abc123"
resp, err := svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
Body: &oapi.CreateVolumeRequest{
Name: "squat",
SizeGb: 1,
Id: &id,
},
})
require.NoError(t, err)
bad, ok := resp.(oapi.CreateVolume400JSONResponse)
require.True(t, ok, "expected 400 for reserved ID prefix")
assert.Contains(t, bad.Message, "reserved for internal use")

// A near-prefix that does not match exactly stays allowed.
id = "builder-disks"
resp, err = svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
Body: &oapi.CreateVolumeRequest{
Name: "not-reserved",
SizeGb: 1,
Id: &id,
},
})
require.NoError(t, err)
_, ok = resp.(oapi.CreateVolume201JSONResponse)
assert.True(t, ok, "expected 201 for non-reserved ID")
}

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

resourceTags := oapi.Tags{"hypeman.system/managed-by": "builder"}
resp, err := svc.CreateVolume(ctx(), oapi.CreateVolumeRequestObject{
Body: &oapi.CreateVolumeRequest{
Name: "spoofed",
SizeGb: 1,
Tags: &resourceTags,
},
})
require.NoError(t, err)
bad, ok := resp.(oapi.CreateVolume400JSONResponse)
require.True(t, ok, "expected 400 for reserved tag key")
assert.Contains(t, bad.Message, "reserved for internal use")

vols, err := svc.VolumeManager.ListVolumes(ctx())
require.NoError(t, err)
assert.Empty(t, vols, "spoofed volume should not be created")
}

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

resourceTags := oapi.Tags{"hypeman.system/managed-by": "builder"}
resp, err := svc.CreateVolumeFromArchive(ctx(), oapi.CreateVolumeFromArchiveRequestObject{
Params: oapi.CreateVolumeFromArchiveParams{
Name: "spoofed",
SizeGb: 1,
Tags: &resourceTags,
},
})
require.NoError(t, err)
bad, ok := resp.(oapi.CreateVolumeFromArchive400JSONResponse)
require.True(t, ok, "expected 400 for reserved tag key")
assert.Contains(t, bad.Message, "reserved for internal use")
}

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

id := "builder-disk-abc123"
_, err := svc.VolumeManager.CreateVolume(ctx(), volumes.CreateVolumeRequest{
Id: &id,
Name: id,
SizeGb: 1,
})
require.NoError(t, err)

resp, err := svc.DeleteVolume(ctxWithVolume(svc, id), oapi.DeleteVolumeRequestObject{Id: id})
require.NoError(t, err)
conflict, ok := resp.(oapi.DeleteVolume409JSONResponse)
require.True(t, ok, "expected 409 for reserved ID prefix")
assert.Contains(t, conflict.Message, "reserved for internal use")

_, err = svc.VolumeManager.GetVolume(ctx(), id)
require.NoError(t, err, "reserved volume must not be deleted")
}
29 changes: 29 additions & 0 deletions lib/builders/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package builders

import "errors"

var (
// ErrNotFound is returned when a builder is not found
ErrNotFound = errors.New("builder not found")

// ErrAlreadyExists is returned when a builder with the same ID already exists
ErrAlreadyExists = errors.New("builder already exists")

// ErrInUse is returned when a builder is acquired by a build, has its
// disk attached, or is mid-prune/delete
ErrInUse = errors.New("builder is in use")

// ErrInvalidID is returned when a caller-supplied builder ID is invalid
ErrInvalidID = errors.New("invalid builder id")

// ErrQuotaExceeded is returned when a create would exceed the configured
// builder count limit
ErrQuotaExceeded = errors.New("builder quota exceeded")

// ErrInvalidDiskSize is returned when a requested disk size is negative
ErrInvalidDiskSize = errors.New("invalid builder disk size")

// ErrDiskSizeExceeded is returned when a requested disk size exceeds the
// configured maximum
ErrDiskSizeExceeded = errors.New("builder disk size exceeds maximum")
)
Loading
Loading