diff --git a/cmd/api/api/volumes.go b/cmd/api/api/volumes.go index 63d9f134..8b4afd88 100644 --- a/cmd/api/api/volumes.go +++ b/cmd/api/api/volumes.go @@ -3,6 +3,9 @@ package api import ( "context" "errors" + "io" + "mime/multipart" + "strconv" "github.com/onkernel/hypeman/lib/logger" "github.com/onkernel/hypeman/lib/oapi" @@ -31,24 +34,167 @@ func (s *ApiService) ListVolumes(ctx context.Context, request oapi.ListVolumesRe } // CreateVolume creates a new volume +// Supports two modes: +// - JSON body: Creates an empty volume of the specified size +// - Multipart form: Creates a volume pre-populated with content from a tar.gz archive func (s *ApiService) CreateVolume(ctx context.Context, request oapi.CreateVolumeRequestObject) (oapi.CreateVolumeResponseObject, error) { log := logger.FromContext(ctx) - domainReq := volumes.CreateVolumeRequest{ - Name: request.Body.Name, - SizeGb: request.Body.SizeGb, - Id: request.Body.Id, + // Handle JSON request (empty volume) + if request.JSONBody != nil { + domainReq := volumes.CreateVolumeRequest{ + Name: request.JSONBody.Name, + SizeGb: request.JSONBody.SizeGb, + Id: request.JSONBody.Id, + } + + vol, err := s.VolumeManager.CreateVolume(ctx, domainReq) + if err != nil { + if errors.Is(err, volumes.ErrAlreadyExists) { + return oapi.CreateVolume409JSONResponse{ + Code: "already_exists", + Message: "volume with this ID already exists", + }, nil + } + log.Error("failed to create volume", "error", err, "name", request.JSONBody.Name) + return oapi.CreateVolume500JSONResponse{ + Code: "internal_error", + Message: "failed to create volume", + }, nil + } + return oapi.CreateVolume201JSONResponse(volumeToOAPI(*vol)), nil } - vol, err := s.VolumeManager.CreateVolume(ctx, domainReq) - if err != nil { - log.Error("failed to create volume", "error", err, "name", request.Body.Name) - return oapi.CreateVolume500JSONResponse{ - Code: "internal_error", - Message: "failed to create volume", + // Handle multipart request (volume with archive content) + if request.MultipartBody != nil { + return s.createVolumeFromMultipart(ctx, request.MultipartBody) + } + + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_request", + Message: "request body is required", + }, nil +} + +// createVolumeFromMultipart handles creating a volume from multipart form data with archive content +func (s *ApiService) createVolumeFromMultipart(ctx context.Context, multipartReader *multipart.Reader) (oapi.CreateVolumeResponseObject, error) { + log := logger.FromContext(ctx) + + var name string + var sizeGb int + var id *string + var archiveReader io.Reader + + for { + part, err := multipartReader.NextPart() + if err == io.EOF { + break + } + if err != nil { + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_form", + Message: "failed to parse multipart form: " + err.Error(), + }, nil + } + + switch part.FormName() { + case "name": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_field", + Message: "failed to read name field", + }, nil + } + name = string(data) + case "size_gb": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_field", + Message: "failed to read size_gb field", + }, nil + } + sizeGb, err = strconv.Atoi(string(data)) + if err != nil || sizeGb <= 0 { + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_field", + Message: "size_gb must be a positive integer", + }, nil + } + case "id": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_field", + Message: "failed to read id field", + }, nil + } + idStr := string(data) + if idStr != "" { + id = &idStr + } + case "content": + archiveReader = part + // Process the archive immediately while we have the reader + if name == "" { + return oapi.CreateVolume400JSONResponse{ + Code: "missing_field", + Message: "name is required", + }, nil + } + if sizeGb <= 0 { + return oapi.CreateVolume400JSONResponse{ + Code: "missing_field", + Message: "size_gb is required", + }, nil + } + + // Create the volume from archive + domainReq := volumes.CreateVolumeFromArchiveRequest{ + Name: name, + SizeGb: sizeGb, + Id: id, + } + + vol, err := s.VolumeManager.CreateVolumeFromArchive(ctx, domainReq, archiveReader) + if err != nil { + if errors.Is(err, volumes.ErrArchiveTooLarge) { + return oapi.CreateVolume400JSONResponse{ + Code: "archive_too_large", + Message: err.Error(), + }, nil + } + if errors.Is(err, volumes.ErrAlreadyExists) { + return oapi.CreateVolume409JSONResponse{ + Code: "already_exists", + Message: "volume with this ID already exists", + }, nil + } + log.Error("failed to create volume from archive", "error", err, "name", name) + return oapi.CreateVolume500JSONResponse{ + Code: "internal_error", + Message: "failed to create volume", + }, nil + } + + return oapi.CreateVolume201JSONResponse(volumeToOAPI(*vol)), nil + } + } + + // If we get here without processing content, it means content was not provided + if archiveReader == nil { + return oapi.CreateVolume400JSONResponse{ + Code: "missing_file", + Message: "content file is required for multipart requests", }, nil } - return oapi.CreateVolume201JSONResponse(volumeToOAPI(*vol)), nil + + // Should not reach here + return oapi.CreateVolume500JSONResponse{ + Code: "internal_error", + Message: "unexpected error processing request", + }, nil } // GetVolume gets volume details @@ -157,3 +303,4 @@ func volumeToOAPI(vol volumes.Volume) oapi.Volume { return oapiVol } + diff --git a/go.mod b/go.mod index c46db705..0f4f6cb0 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.4 require ( github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/creack/pty v1.1.24 + github.com/cyphar/filepath-securejoin v0.6.1 github.com/distribution/reference v0.6.0 github.com/getkin/kin-openapi v0.133.0 github.com/ghodss/yaml v1.0.0 @@ -39,7 +40,6 @@ require ( github.com/apex/log v1.9.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect - github.com/cyphar/filepath-securejoin v0.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect diff --git a/go.sum b/go.sum index 0507d43b..ac155882 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRcc github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.5.0 h1:hIAhkRBMQ8nIeuVwcAoymp7MY4oherZdAxD+m0u9zaw= -github.com/cyphar/filepath-securejoin v0.5.0/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -66,8 +66,6 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -205,8 +203,6 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -231,8 +227,6 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= diff --git a/lib/instances/volumes_test.go b/lib/instances/volumes_test.go index 9c877352..c8a36816 100644 --- a/lib/instances/volumes_test.go +++ b/lib/instances/volumes_test.go @@ -1,6 +1,9 @@ package instances import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "os" "strings" @@ -313,3 +316,151 @@ func TestOverlayDiskCleanupOnDelete(t *testing.T) { // Cleanup volumeManager.DeleteVolume(ctx, vol.Id) } + +// createTestTarGz creates a tar.gz archive with the given files +func createTestTarGz(t *testing.T, files map[string][]byte) *bytes.Buffer { + t.Helper() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for name, content := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0644, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) + } + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + return &buf +} + +// TestVolumeFromArchive tests that a volume can be created from a tar.gz archive +// and the files are accessible when mounted to an instance +func TestVolumeFromArchive(t *testing.T) { + // Require KVM + if _, err := os.Stat("/dev/kvm"); os.IsNotExist(err) { + t.Fatal("/dev/kvm not available - ensure KVM is enabled and user is in 'kvm' group") + } + + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + manager, tmpDir := setupTestManager(t) + ctx := context.Background() + p := paths.New(tmpDir) + + // Setup: prepare image and system files + imageManager, err := images.NewManager(p, 1) + require.NoError(t, err) + + t.Log("Pulling alpine image...") + _, err = imageManager.CreateImage(ctx, images.CreateImageRequest{ + Name: "docker.io/library/alpine:latest", + }) + require.NoError(t, err) + + for i := 0; i < 60; i++ { + img, err := imageManager.GetImage(ctx, "docker.io/library/alpine:latest") + if err == nil && img.Status == images.StatusReady { + break + } + time.Sleep(1 * time.Second) + } + t.Log("Image ready") + + systemManager := system.NewManager(p) + t.Log("Ensuring system files...") + err = systemManager.EnsureSystemFiles(ctx) + require.NoError(t, err) + t.Log("System files ready") + + // Create a tar.gz archive with test files + t.Log("Creating test archive...") + testFiles := map[string][]byte{ + "greeting.txt": []byte("Hello from archive!"), + "data/config.json": []byte(`{"key": "value", "number": 42}`), + "data/nested/deep.txt": []byte("Deep nested file content"), + } + archive := createTestTarGz(t, testFiles) + + // Create volume from archive + volumeManager := volumes.NewManager(p, 0) + t.Log("Creating volume from archive...") + vol, err := volumeManager.CreateVolumeFromArchive(ctx, volumes.CreateVolumeFromArchiveRequest{ + Name: "archive-data", + SizeGb: 1, + }, archive) + require.NoError(t, err) + t.Logf("Volume created: %s (size: %dGB)", vol.Id, vol.SizeGb) + + // Create instance with the volume attached + t.Log("Creating instance with archive volume...") + inst, err := manager.CreateInstance(ctx, CreateInstanceRequest{ + Name: "archive-reader", + Image: "docker.io/library/alpine:latest", + Size: 512 * 1024 * 1024, + HotplugSize: 512 * 1024 * 1024, + OverlaySize: 1024 * 1024 * 1024, + Vcpus: 1, + NetworkEnabled: false, + Volumes: []VolumeAttachment{ + {VolumeID: vol.Id, MountPath: "/archive", Readonly: true}, + }, + }) + require.NoError(t, err) + t.Logf("Instance created: %s", inst.Id) + + // Wait for exec-agent + err = waitForExecAgent(ctx, manager, inst.Id, 15*time.Second) + require.NoError(t, err, "exec-agent should be ready") + + // Verify files from archive are present + t.Log("Verifying archive files are accessible...") + + // Check greeting.txt + output, code, err := execWithRetry(ctx, inst.VsockSocket, []string{"cat", "/archive/greeting.txt"}) + require.NoError(t, err) + require.Equal(t, 0, code, "cat greeting.txt should succeed") + assert.Equal(t, "Hello from archive!", strings.TrimSpace(output)) + t.Log("✓ greeting.txt verified") + + // Check data/config.json + output, code, err = execWithRetry(ctx, inst.VsockSocket, []string{"cat", "/archive/data/config.json"}) + require.NoError(t, err) + require.Equal(t, 0, code, "cat config.json should succeed") + assert.Contains(t, output, `"key": "value"`) + assert.Contains(t, output, `"number": 42`) + t.Log("✓ data/config.json verified") + + // Check deeply nested file + output, code, err = execWithRetry(ctx, inst.VsockSocket, []string{"cat", "/archive/data/nested/deep.txt"}) + require.NoError(t, err) + require.Equal(t, 0, code, "cat deep.txt should succeed") + assert.Equal(t, "Deep nested file content", strings.TrimSpace(output)) + t.Log("✓ data/nested/deep.txt verified") + + // List directory to confirm structure + output, code, err = execWithRetry(ctx, inst.VsockSocket, []string{"find", "/archive", "-type", "f"}) + require.NoError(t, err) + require.Equal(t, 0, code, "find should succeed") + assert.Contains(t, output, "/archive/greeting.txt") + assert.Contains(t, output, "/archive/data/config.json") + assert.Contains(t, output, "/archive/data/nested/deep.txt") + t.Log("✓ Directory structure verified") + + t.Log("Volume from archive test passed!") + + // Cleanup + t.Log("Cleaning up...") + manager.DeleteInstance(ctx, inst.Id) + volumeManager.DeleteVolume(ctx, vol.Id) +} diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index 9629cf2a..0b069ed5 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" "path" @@ -21,6 +22,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/oapi-codegen/runtime" strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" + openapi_types "github.com/oapi-codegen/runtime/types" ) const ( @@ -312,6 +314,21 @@ type GetInstanceLogsParams struct { Follow *bool `form:"follow,omitempty" json:"follow,omitempty"` } +// CreateVolumeMultipartBody defines parameters for CreateVolume. +type CreateVolumeMultipartBody struct { + // Content tar.gz archive file containing the volume content + Content openapi_types.File `json:"content"` + + // Id Optional custom volume ID (auto-generated if not provided) + Id *string `json:"id,omitempty"` + + // Name Volume name + Name string `json:"name"` + + // SizeGb Maximum size in GB (extraction fails if content exceeds this) + SizeGb int `json:"size_gb"` +} + // CreateImageJSONRequestBody defines body for CreateImage for application/json ContentType. type CreateImageJSONRequestBody = CreateImageRequest @@ -324,6 +341,9 @@ type AttachVolumeJSONRequestBody = AttachVolumeRequest // CreateVolumeJSONRequestBody defines body for CreateVolume for application/json ContentType. type CreateVolumeJSONRequestBody = CreateVolumeRequest +// CreateVolumeMultipartRequestBody defines body for CreateVolume for multipart/form-data ContentType. +type CreateVolumeMultipartRequestBody CreateVolumeMultipartBody + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -1864,6 +1884,7 @@ type CreateVolumeResponse struct { JSON201 *Volume JSON400 *Error JSON401 *Error + JSON409 *Error JSON500 *Error } @@ -2767,6 +2788,13 @@ func ParseCreateVolumeResponse(rsp *http.Response) (*CreateVolumeResponse, error } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -4291,7 +4319,8 @@ func (response ListVolumes500JSONResponse) VisitListVolumesResponse(w http.Respo } type CreateVolumeRequestObject struct { - Body *CreateVolumeJSONRequestBody + JSONBody *CreateVolumeJSONRequestBody + MultipartBody *multipart.Reader } type CreateVolumeResponseObject interface { @@ -4325,6 +4354,15 @@ func (response CreateVolume401JSONResponse) VisitCreateVolumeResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type CreateVolume409JSONResponse Error + +func (response CreateVolume409JSONResponse) VisitCreateVolumeResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + type CreateVolume500JSONResponse Error func (response CreateVolume500JSONResponse) VisitCreateVolumeResponse(w http.ResponseWriter) error { @@ -4905,12 +4943,23 @@ func (sh *strictHandler) ListVolumes(w http.ResponseWriter, r *http.Request) { func (sh *strictHandler) CreateVolume(w http.ResponseWriter, r *http.Request) { var request CreateVolumeRequestObject - var body CreateVolumeJSONRequestBody - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) - return + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + + var body CreateVolumeJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.JSONBody = &body + } + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + if reader, err := r.MultipartReader(); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err)) + return + } else { + request.MultipartBody = reader + } } - request.Body = &body handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.CreateVolume(ctx, request.(CreateVolumeRequestObject)) @@ -4987,67 +5036,71 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "4dPe0x6+/XD7/wEAAP//oKJwbNdUAAA=", + "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", + "So+fDp4Ogut31/8XAAD//1tiJLd5WAAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/volumes/README.md b/lib/volumes/README.md index 10bbb9b9..7e6d6a8d 100644 --- a/lib/volumes/README.md +++ b/lib/volumes/README.md @@ -5,10 +5,11 @@ Volumes are persistent block storage that exist independently of instances. They ## Lifecycle 1. **Create** - `POST /volumes` creates an ext4-formatted sparse disk file of the specified size -2. **Attach** - Specify volumes in `CreateInstanceRequest.volumes` with a mount path -3. **Use** - Volume appears as a block device inside the guest, mounted at the specified path -4. **Detach** - Volumes are automatically detached when an instance is deleted -5. **Delete** - `DELETE /volumes/{id}` removes the volume (fails if still attached) +2. **Create from Archive** - `POST /volumes/from-archive` creates a volume pre-populated with content from a tar.gz file +3. **Attach** - Specify volumes in `CreateInstanceRequest.volumes` with a mount path +4. **Use** - Volume appears as a block device inside the guest, mounted at the specified path +5. **Detach** - Volumes are automatically detached when an instance is deleted +6. **Delete** - `DELETE /volumes/{id}` removes the volume (fails if still attached) ## Cloud Hypervisor Integration @@ -41,6 +42,23 @@ When attaching a volume with `overlay: true`, the instance gets copy-on-write se This allows multiple instances to share a common base (e.g., dataset, model weights) while each can make local modifications without affecting others. Requires `readonly: true` and `overlay_size` specifying the max size of per-instance writes. +## Creating Volumes from Archives + +Volumes can be created with initial content by uploading a tar.gz archive via `POST /volumes/from-archive`. This is useful for pre-populating volumes with datasets, configuration files, or application data. + +**Request:** Multipart form with fields: +- `name` - Volume name (required) +- `size_gb` - Maximum size in GB (required, extraction fails if content exceeds this) +- `id` - Optional custom volume ID +- `content` - tar.gz file (required) + +**Safety:** The extraction process protects against adversarial archives: +- Tracks cumulative extracted size and aborts if limit exceeded +- Validates paths to prevent directory traversal attacks +- Rejects absolute paths and symlinks that escape the destination + +The resulting volume size is automatically calculated from the extracted content (with filesystem overhead), not the specified `size_gb` which serves as an upper limit. + ## Constraints - Volumes can only be attached at instance creation time (no hot-attach) diff --git a/lib/volumes/archive.go b/lib/volumes/archive.go new file mode 100644 index 00000000..c51a4201 --- /dev/null +++ b/lib/volumes/archive.go @@ -0,0 +1,197 @@ +package volumes + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + securejoin "github.com/cyphar/filepath-securejoin" +) + +var ( + // ErrArchiveTooLarge is returned when extracted content exceeds the size limit + ErrArchiveTooLarge = errors.New("archive content exceeds size limit") + // ErrInvalidArchivePath is returned when a tar entry has a malicious path + ErrInvalidArchivePath = errors.New("invalid archive path") +) + +// validateArchivePath checks if a path from an archive is safe. +// We reject obviously malicious paths rather than silently sanitizing them, +// since a legitimate archive should not contain path traversal attempts. +func validateArchivePath(name string) error { + // Clean the path first + cleaned := filepath.Clean(name) + + // Reject absolute paths + if filepath.IsAbs(cleaned) || filepath.IsAbs(name) { + return fmt.Errorf("%w: absolute path %q", ErrInvalidArchivePath, name) + } + + // Reject paths with .. components + if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, string(filepath.Separator)+"..") { + return fmt.Errorf("%w: path traversal in %q", ErrInvalidArchivePath, name) + } + + return nil +} + +// ExtractTarGz extracts a tar.gz archive to destDir, aborting if the extracted +// content exceeds maxBytes. Returns the total extracted bytes on success. +// +// Security considerations (runs with elevated privileges): +// This function implements multiple layers of defense against malicious archives: +// 1. Path validation - rejects absolute paths and path traversal attempts upfront +// 2. securejoin - safe path joining that resolves symlinks within the root +// 3. O_NOFOLLOW - prevents following symlinks when creating files (defense in depth) +// 4. Size limiting - tracks cumulative size and aborts if limit exceeded +// 5. io.LimitReader - secondary protection when copying file contents +// +// The destination directory should be a freshly created temp directory to minimize +// TOCTOU attack surface. The same approach is used by umoci and containerd. +func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { + // Create destination directory + if err := os.MkdirAll(destDir, 0755); err != nil { + return 0, fmt.Errorf("create dest dir: %w", err) + } + + // Wrap in gzip reader + gzr, err := gzip.NewReader(r) + if err != nil { + return 0, fmt.Errorf("gzip reader: %w", err) + } + defer gzr.Close() + + // Create tar reader + tr := tar.NewReader(gzr) + + var extractedBytes int64 + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return extractedBytes, fmt.Errorf("read tar header: %w", err) + } + + // Validate path - reject archives with malicious entries + if err := validateArchivePath(header.Name); err != nil { + return extractedBytes, err + } + + // Use securejoin for safe path joining (resolves symlinks safely within root) + targetPath, err := securejoin.SecureJoin(destDir, header.Name) + if err != nil { + return extractedBytes, fmt.Errorf("%w: %v", ErrInvalidArchivePath, err) + } + + // Check if adding this entry would exceed the limit + if extractedBytes+header.Size > maxBytes { + return extractedBytes, fmt.Errorf("%w: would exceed %d bytes", ErrArchiveTooLarge, maxBytes) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { + return extractedBytes, fmt.Errorf("create dir %s: %w", header.Name, err) + } + + case tar.TypeReg: + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return extractedBytes, fmt.Errorf("create parent dir: %w", err) + } + + // Create file with O_NOFOLLOW to prevent symlink attacks + // syscall.O_NOFOLLOW ensures we don't follow a symlink if one was + // maliciously created at targetPath during extraction + f, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC|syscall.O_NOFOLLOW, os.FileMode(header.Mode)) + if err != nil { + return extractedBytes, fmt.Errorf("create file %s: %w", header.Name, err) + } + + // Copy with limit as secondary protection + remaining := maxBytes - extractedBytes + limitedReader := io.LimitReader(tr, remaining+1) // +1 to detect overflow + + n, err := io.Copy(f, limitedReader) + f.Close() + + if err != nil { + return extractedBytes, fmt.Errorf("write file %s: %w", header.Name, err) + } + + extractedBytes += n + + // Check if we hit the limit + if extractedBytes > maxBytes { + return extractedBytes, fmt.Errorf("%w: exceeded %d bytes", ErrArchiveTooLarge, maxBytes) + } + + case tar.TypeSymlink: + // Reject absolute symlink targets + if filepath.IsAbs(header.Linkname) { + return extractedBytes, fmt.Errorf("%w: absolute symlink target %q", ErrInvalidArchivePath, header.Linkname) + } + + // Reject symlinks with path traversal attempts + // We check this explicitly because securejoin sanitizes rather than errors + cleanedLink := filepath.Clean(header.Linkname) + if strings.HasPrefix(cleanedLink, ".."+string(filepath.Separator)) || cleanedLink == ".." { + return extractedBytes, fmt.Errorf("%w: symlink %q escapes destination", ErrInvalidArchivePath, header.Linkname) + } + + // Validate symlink target - resolve relative to symlink's directory + symlinkDir := filepath.Dir(targetPath) + resolvedTarget, err := securejoin.SecureJoin(symlinkDir, header.Linkname) + if err != nil { + return extractedBytes, fmt.Errorf("%w: symlink target unsafe: %v", ErrInvalidArchivePath, err) + } + + // Verify the resolved target is within destDir (defense in depth) + cleanDest := filepath.Clean(destDir) + if !strings.HasPrefix(resolvedTarget, cleanDest+string(filepath.Separator)) && resolvedTarget != cleanDest { + return extractedBytes, fmt.Errorf("%w: symlink %q escapes destination", ErrInvalidArchivePath, header.Linkname) + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return extractedBytes, fmt.Errorf("create parent dir for symlink: %w", err) + } + + if err := os.Symlink(header.Linkname, targetPath); err != nil { + return extractedBytes, fmt.Errorf("create symlink %s: %w", header.Name, err) + } + + case tar.TypeLink: + // Hard links - validate target is within destDir using securejoin + linkTarget, err := securejoin.SecureJoin(destDir, header.Linkname) + if err != nil { + return extractedBytes, fmt.Errorf("%w: hardlink target unsafe: %v", ErrInvalidArchivePath, err) + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + return extractedBytes, fmt.Errorf("create parent dir for hardlink: %w", err) + } + + if err := os.Link(linkTarget, targetPath); err != nil { + return extractedBytes, fmt.Errorf("create hardlink %s: %w", header.Name, err) + } + + default: + // Skip other types (devices, fifos, etc.) + continue + } + } + + return extractedBytes, nil +} + diff --git a/lib/volumes/archive_test.go b/lib/volumes/archive_test.go new file mode 100644 index 00000000..810b8a85 --- /dev/null +++ b/lib/volumes/archive_test.go @@ -0,0 +1,414 @@ +package volumes + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestTarGz creates a tar.gz archive with the given files +func createTestTarGz(t *testing.T, files map[string][]byte) *bytes.Buffer { + t.Helper() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for name, content := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0644, + Size: int64(len(content)), + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(content) + require.NoError(t, err) + } + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + return &buf +} + +func TestExtractTarGz_Basic(t *testing.T) { + // Create a simple archive + files := map[string][]byte{ + "hello.txt": []byte("Hello, World!"), + "dir/nested.txt": []byte("Nested content"), + } + archive := createTestTarGz(t, files) + + // Extract to temp dir + destDir := t.TempDir() + extracted, err := ExtractTarGz(archive, destDir, 1024*1024) // 1MB limit + + require.NoError(t, err) + assert.Equal(t, int64(len("Hello, World!")+len("Nested content")), extracted) + + // Verify files were extracted + content, err := os.ReadFile(filepath.Join(destDir, "hello.txt")) + require.NoError(t, err) + assert.Equal(t, "Hello, World!", string(content)) + + content, err = os.ReadFile(filepath.Join(destDir, "dir/nested.txt")) + require.NoError(t, err) + assert.Equal(t, "Nested content", string(content)) +} + +func TestExtractTarGz_SizeLimitExceeded(t *testing.T) { + // Create an archive with content that exceeds the limit + files := map[string][]byte{ + "large.txt": bytes.Repeat([]byte("x"), 1000), + } + archive := createTestTarGz(t, files) + + destDir := t.TempDir() + _, err := ExtractTarGz(archive, destDir, 500) // 500 byte limit + + require.Error(t, err) + assert.ErrorIs(t, err, ErrArchiveTooLarge) +} + +func TestExtractTarGz_PathTraversal(t *testing.T) { + // Create archive with path traversal attempt + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: "../../../etc/passwd", + Mode: 0644, + Size: 4, + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte("evil")) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err = ExtractTarGz(&buf, destDir, 1024*1024) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidArchivePath) +} + +func TestExtractTarGz_AbsolutePath(t *testing.T) { + // Create archive with absolute path + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: "/etc/passwd", + Mode: 0644, + Size: 4, + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte("evil")) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err = ExtractTarGz(&buf, destDir, 1024*1024) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidArchivePath) +} + +func TestExtractTarGz_Symlink(t *testing.T) { + // Create archive with a valid symlink + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // Add a regular file first + hdr := &tar.Header{ + Name: "target.txt", + Mode: 0644, + Size: 5, + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte("hello")) + require.NoError(t, err) + + // Add a valid symlink + hdr = &tar.Header{ + Name: "link.txt", + Mode: 0777, + Typeflag: tar.TypeSymlink, + Linkname: "target.txt", + } + require.NoError(t, tw.WriteHeader(hdr)) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err = ExtractTarGz(&buf, destDir, 1024*1024) + require.NoError(t, err) + + // Verify symlink was created + linkPath := filepath.Join(destDir, "link.txt") + target, err := os.Readlink(linkPath) + require.NoError(t, err) + assert.Equal(t, "target.txt", target) +} + +func TestExtractTarGz_SymlinkEscape(t *testing.T) { + // Create archive with symlink that escapes destination + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: "escape.txt", + Mode: 0777, + Typeflag: tar.TypeSymlink, + Linkname: "../../etc/passwd", + } + require.NoError(t, tw.WriteHeader(hdr)) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err := ExtractTarGz(&buf, destDir, 1024*1024) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidArchivePath) +} + +func TestExtractTarGz_AbsoluteSymlink(t *testing.T) { + // Create archive with absolute symlink target + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: "abs.txt", + Mode: 0777, + Typeflag: tar.TypeSymlink, + Linkname: "/etc/passwd", + } + require.NoError(t, tw.WriteHeader(hdr)) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err := ExtractTarGz(&buf, destDir, 1024*1024) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidArchivePath) +} + +func TestExtractTarGz_PreventsTarBomb(t *testing.T) { + // Create a "tar bomb" - many small files that together exceed the limit + files := make(map[string][]byte) + for i := 0; i < 100; i++ { + // Use unique file names (file_000.txt, file_001.txt, etc.) + files[fmt.Sprintf("dir/file_%03d.txt", i)] = bytes.Repeat([]byte("x"), 100) + } + archive := createTestTarGz(t, files) + + destDir := t.TempDir() + _, err := ExtractTarGz(archive, destDir, 5000) // 5KB limit, but archive has 10KB + + require.Error(t, err) + assert.ErrorIs(t, err, ErrArchiveTooLarge) +} + +// ============================================================================= +// Attack scenario tests - verify defense against common tar-based attacks +// ============================================================================= + +func TestExtractTarGz_Attack_DotDotSlashVariants(t *testing.T) { + // Test various path traversal patterns that attackers commonly try + testCases := []struct { + name string + path string + wantErr bool + }{ + {"double dot basic", "../etc/passwd", true}, + {"double dot nested", "foo/../../etc/passwd", true}, + {"double dot at start", "..\\etc\\passwd", true}, // Windows-style + {"hidden in middle", "safe/dir/../../../etc/passwd", true}, + {"percent encoded slashes", "foo%2F..%2Fbar/file.txt", false}, // Percent signs are literal chars in paths + {"safe relative path", "subdir/file.txt", false}, + {"safe nested path", "a/b/c/d/file.txt", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: tc.path, + Mode: 0644, + Size: 4, + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte("test")) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err = ExtractTarGz(&buf, destDir, 1024*1024) + + if tc.wantErr { + require.Error(t, err, "expected error for path: %s", tc.path) + assert.ErrorIs(t, err, ErrInvalidArchivePath) + } else { + require.NoError(t, err, "unexpected error for path: %s", tc.path) + } + }) + } +} + +func TestExtractTarGz_Attack_SymlinkChain(t *testing.T) { + // Attack: Create a chain of symlinks trying to escape + // link1 -> subdir, subdir/link2 -> ../.. (escape attempt) + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // Create a directory first + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "subdir/", + Mode: 0755, + Typeflag: tar.TypeDir, + })) + + // Create a symlink that tries to escape via relative path + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: "subdir/escape", + Mode: 0777, + Typeflag: tar.TypeSymlink, + Linkname: "../../etc/passwd", + })) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err := ExtractTarGz(&buf, destDir, 1024*1024) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidArchivePath) +} + +func TestExtractTarGz_Attack_HardlinkToOutside(t *testing.T) { + // Attack: Hard link pointing to a file outside destDir + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + hdr := &tar.Header{ + Name: "evil_hardlink", + Mode: 0644, + Typeflag: tar.TypeLink, + Linkname: "../../../etc/passwd", + } + require.NoError(t, tw.WriteHeader(hdr)) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err := ExtractTarGz(&buf, destDir, 1024*1024) + + // Hard link with path traversal in target should fail + require.Error(t, err) +} + +func TestExtractTarGz_Attack_DeviceFiles(t *testing.T) { + // Attack: Try to create device files (should be skipped, not error) + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // Try to create a character device (like /dev/null) + hdr := &tar.Header{ + Name: "fake_device", + Mode: 0666, + Typeflag: tar.TypeChar, + Devmajor: 1, + Devminor: 3, + } + require.NoError(t, tw.WriteHeader(hdr)) + + // Also add a regular file to verify extraction continues + hdr = &tar.Header{ + Name: "normal.txt", + Mode: 0644, + Size: 5, + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte("hello")) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + _, err = ExtractTarGz(&buf, destDir, 1024*1024) + + // Should succeed - device files are skipped + require.NoError(t, err) + + // Verify normal file was created + content, err := os.ReadFile(filepath.Join(destDir, "normal.txt")) + require.NoError(t, err) + assert.Equal(t, "hello", string(content)) + + // Verify device file was NOT created + _, err = os.Stat(filepath.Join(destDir, "fake_device")) + assert.True(t, os.IsNotExist(err), "device file should not be created") +} + +func TestExtractTarGz_Attack_ZeroSizeClaimLargeContent(t *testing.T) { + // Attack: Header claims 0 size but contains large content + // (malformed tar trying to bypass size checks) + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // Create header with misleading size + hdr := &tar.Header{ + Name: "misleading.txt", + Mode: 0644, + Size: 10, // Claim small size + } + require.NoError(t, tw.WriteHeader(hdr)) + // Write exactly 10 bytes as claimed + _, err := tw.Write([]byte("0123456789")) + require.NoError(t, err) + + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + destDir := t.TempDir() + // Set limit below the actual content + _, err = ExtractTarGz(&buf, destDir, 5) + + // Should fail because even the claimed size exceeds limit + require.Error(t, err) + assert.ErrorIs(t, err, ErrArchiveTooLarge) +} + diff --git a/lib/volumes/manager.go b/lib/volumes/manager.go index b82297d6..2292e98c 100644 --- a/lib/volumes/manager.go +++ b/lib/volumes/manager.go @@ -3,10 +3,13 @@ package volumes import ( "context" "fmt" + "io" + "os" "sync" "time" "github.com/nrednav/cuid2" + "github.com/onkernel/hypeman/lib/images" "github.com/onkernel/hypeman/lib/paths" ) @@ -14,6 +17,7 @@ import ( type Manager interface { ListVolumes(ctx context.Context) ([]Volume, error) CreateVolume(ctx context.Context, req CreateVolumeRequest) (*Volume, error) + CreateVolumeFromArchive(ctx context.Context, req CreateVolumeFromArchiveRequest, archive io.Reader) (*Volume, error) GetVolume(ctx context.Context, id string) (*Volume, error) GetVolumeByName(ctx context.Context, name string) (*Volume, error) DeleteVolume(ctx context.Context, id string) error @@ -144,6 +148,84 @@ func (m *manager) CreateVolume(ctx context.Context, req CreateVolumeRequest) (*V return m.metadataToVolume(meta), nil } +// CreateVolumeFromArchive creates a new volume pre-populated with content from a tar.gz archive. +// The archive is safely extracted with size limits to prevent tar bombs. +func (m *manager) CreateVolumeFromArchive(ctx context.Context, req CreateVolumeFromArchiveRequest, archive io.Reader) (*Volume, error) { + // Generate or use provided ID + id := cuid2.Generate() + if req.Id != nil && *req.Id != "" { + id = *req.Id + } + + // Check volume doesn't already exist + if _, err := loadMetadata(m.paths, id); err == nil { + return nil, ErrAlreadyExists + } + + maxBytes := int64(req.SizeGb) * 1024 * 1024 * 1024 + + // Check total volume storage limit + if m.maxTotalVolumeStorage > 0 { + currentStorage, err := m.calculateTotalVolumeStorage(ctx) + if err != nil { + // Log but don't fail - continue with creation + } else { + if currentStorage+maxBytes > m.maxTotalVolumeStorage { + return nil, fmt.Errorf("total volume storage would be %d bytes, exceeds limit of %d bytes", currentStorage+maxBytes, m.maxTotalVolumeStorage) + } + } + } + + // Create temp directory for extraction + tempDir, err := os.MkdirTemp("", "volume-archive-*") + if err != nil { + return nil, fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tempDir) + + // Extract archive with size limit + _, err = ExtractTarGz(archive, tempDir, maxBytes) + if err != nil { + return nil, fmt.Errorf("extract archive: %w", err) + } + + // Create volume directory + if err := ensureVolumeDir(m.paths, id); err != nil { + return nil, err + } + + // Create ext4 disk from extracted content + diskPath := m.paths.VolumeData(id) + diskSize, err := images.ExportRootfs(tempDir, diskPath, images.FormatExt4) + if err != nil { + deleteVolumeData(m.paths, id) + return nil, fmt.Errorf("create disk from content: %w", err) + } + + // Calculate actual size in GB (round up) + actualSizeGb := int((diskSize + 1024*1024*1024 - 1) / (1024 * 1024 * 1024)) + if actualSizeGb < 1 { + actualSizeGb = 1 + } + + // Create metadata + now := time.Now() + meta := &storedMetadata{ + Id: id, + Name: req.Name, + SizeGb: actualSizeGb, + CreatedAt: now.Format(time.RFC3339), + } + + // Save metadata + if err := saveMetadata(m.paths, meta); err != nil { + deleteVolumeData(m.paths, id) + return nil, err + } + + return m.metadataToVolume(meta), nil +} + // GetVolume returns a volume by ID func (m *manager) GetVolume(ctx context.Context, id string) (*Volume, error) { lock := m.getVolumeLock(id) diff --git a/lib/volumes/types.go b/lib/volumes/types.go index d0810b60..55a035bc 100644 --- a/lib/volumes/types.go +++ b/lib/volumes/types.go @@ -32,3 +32,11 @@ type AttachVolumeRequest struct { Readonly bool } +// CreateVolumeFromArchiveRequest is the domain request for creating a volume +// pre-populated with content from a tar.gz archive +type CreateVolumeFromArchiveRequest struct { + Name string + SizeGb int // Maximum size in GB (extraction fails if content exceeds this) + Id *string // Optional custom ID +} + diff --git a/openapi.yaml b/openapi.yaml index bd6ac20e..c7b57d5d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -914,6 +914,10 @@ paths: $ref: "#/components/schemas/Error" post: summary: Create volume + description: | + Creates a new volume. Supports two modes: + - JSON body: Creates an empty volume of the specified size + - Multipart form: Creates a volume pre-populated with content from a tar.gz archive operationId: createVolume security: - bearerAuth: [] @@ -923,6 +927,30 @@ paths: application/json: schema: $ref: "#/components/schemas/CreateVolumeRequest" + multipart/form-data: + schema: + type: object + required: + - name + - size_gb + - content + properties: + name: + type: string + description: Volume name + example: my-data-volume + size_gb: + type: integer + description: Maximum size in GB (extraction fails if content exceeds this) + example: 10 + id: + type: string + description: Optional custom volume ID (auto-generated if not provided) + example: vol-data-1 + content: + type: string + format: binary + description: tar.gz archive file containing the volume content responses: 201: description: Volume created @@ -931,7 +959,7 @@ paths: schema: $ref: "#/components/schemas/Volume" 400: - description: Bad request + description: Bad request (invalid data or archive too large) content: application/json: schema: @@ -942,6 +970,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 409: + description: Conflict - volume with this ID already exists + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 500: description: Internal server error content: