From 4954667781b46937e083f5573f3dea7f24cf0acd Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Tue, 2 Dec 2025 16:51:06 -0500 Subject: [PATCH 1/4] Create volume with initial content --- cmd/api/api/volumes.go | 92 ++++++++++++++ cmd/api/main.go | 9 ++ lib/instances/volumes_test.go | 151 ++++++++++++++++++++++ lib/volumes/README.md | 26 +++- lib/volumes/archive.go | 180 ++++++++++++++++++++++++++ lib/volumes/archive_test.go | 232 ++++++++++++++++++++++++++++++++++ lib/volumes/manager.go | 82 ++++++++++++ lib/volumes/types.go | 8 ++ 8 files changed, 776 insertions(+), 4 deletions(-) create mode 100644 lib/volumes/archive.go create mode 100644 lib/volumes/archive_test.go diff --git a/cmd/api/api/volumes.go b/cmd/api/api/volumes.go index 63d9f134..d676f350 100644 --- a/cmd/api/api/volumes.go +++ b/cmd/api/api/volumes.go @@ -2,7 +2,10 @@ package api import ( "context" + "encoding/json" "errors" + "net/http" + "strconv" "github.com/onkernel/hypeman/lib/logger" "github.com/onkernel/hypeman/lib/oapi" @@ -157,3 +160,92 @@ func volumeToOAPI(vol volumes.Volume) oapi.Volume { return oapiVol } +// CreateVolumeFromArchiveHandler handles POST /volumes/from-archive +// This is a custom endpoint (outside OpenAPI spec) that accepts multipart form data +// with a tar.gz file to create a pre-populated volume. +// +// Form fields: +// - name: volume name (required) +// - size_gb: maximum size in GB (required) +// - id: optional custom volume ID +// - content: tar.gz file (required) +func (s *ApiService) CreateVolumeFromArchiveHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := logger.FromContext(ctx) + + // Parse multipart form (32MB max in memory, rest goes to temp files) + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid_form", "failed to parse multipart form: "+err.Error()) + return + } + + // Get required fields + name := r.FormValue("name") + if name == "" { + writeJSONError(w, http.StatusBadRequest, "missing_field", "name is required") + return + } + + sizeGbStr := r.FormValue("size_gb") + if sizeGbStr == "" { + writeJSONError(w, http.StatusBadRequest, "missing_field", "size_gb is required") + return + } + sizeGb, err := strconv.Atoi(sizeGbStr) + if err != nil || sizeGb <= 0 { + writeJSONError(w, http.StatusBadRequest, "invalid_field", "size_gb must be a positive integer") + return + } + + // Get optional ID + var id *string + if idVal := r.FormValue("id"); idVal != "" { + id = &idVal + } + + // Get the archive file + file, _, err := r.FormFile("content") + if err != nil { + writeJSONError(w, http.StatusBadRequest, "missing_file", "content file is required") + return + } + defer file.Close() + + // Create the volume + domainReq := volumes.CreateVolumeFromArchiveRequest{ + Name: name, + SizeGb: sizeGb, + Id: id, + } + + vol, err := s.VolumeManager.CreateVolumeFromArchive(ctx, domainReq, file) + if err != nil { + if errors.Is(err, volumes.ErrArchiveTooLarge) { + writeJSONError(w, http.StatusBadRequest, "archive_too_large", err.Error()) + return + } + if errors.Is(err, volumes.ErrAlreadyExists) { + writeJSONError(w, http.StatusConflict, "already_exists", "volume with this ID already exists") + return + } + log.Error("failed to create volume from archive", "error", err, "name", name) + writeJSONError(w, http.StatusInternalServerError, "internal_error", "failed to create volume") + return + } + + // Return success response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(volumeToOAPI(*vol)) +} + +// writeJSONError writes a JSON error response +func writeJSONError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "code": code, + "message": message, + }) +} + diff --git a/cmd/api/main.go b/cmd/api/main.go index d2ad80f2..5744cafb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -103,6 +103,15 @@ func run() error { mw.JwtAuth(app.Config.JwtSecret), ).Get("/instances/{id}/exec", app.ApiService.ExecHandler) + // Custom volume from archive endpoint (outside OpenAPI spec, uses multipart form) + r.With( + middleware.RequestID, + middleware.RealIP, + middleware.Logger, + middleware.Recoverer, + mw.JwtAuth(app.Config.JwtSecret), + ).Post("/volumes/from-archive", app.ApiService.CreateVolumeFromArchiveHandler) + // Authenticated API endpoints r.Group(func(r chi.Router) { // Common middleware 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/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..0afe5653 --- /dev/null +++ b/lib/volumes/archive.go @@ -0,0 +1,180 @@ +package volumes + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +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") +) + +// @sjmiller609 todo: do we have a dependency we can use for safe extraction? +// ExtractTarGz extracts a tar.gz archive to destDir, aborting if the extracted +// content exceeds maxBytes. Returns the total extracted bytes on success. +// +// Safety measures against adversarial archives: +// - Tracks cumulative extracted size, aborts immediately if limit exceeded +// - Validates paths to prevent directory traversal attacks +// - Uses io.LimitReader as secondary protection when copying files +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 and sanitize path + targetPath, err := sanitizePath(destDir, header.Name) + if err != nil { + return extractedBytes, 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 + f, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 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: + // Validate symlink target doesn't escape destDir + linkTarget := header.Linkname + if filepath.IsAbs(linkTarget) { + return extractedBytes, fmt.Errorf("%w: absolute symlink target", ErrInvalidArchivePath) + } + + // Resolve the symlink relative to its location + resolvedTarget := filepath.Join(filepath.Dir(targetPath), linkTarget) + resolvedTarget = filepath.Clean(resolvedTarget) + + // Ensure resolved path is within destDir + if !strings.HasPrefix(resolvedTarget, filepath.Clean(destDir)+string(os.PathSeparator)) && + resolvedTarget != filepath.Clean(destDir) { + return extractedBytes, fmt.Errorf("%w: symlink escapes destination", ErrInvalidArchivePath) + } + + // 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(linkTarget, targetPath); err != nil { + return extractedBytes, fmt.Errorf("create symlink %s: %w", header.Name, err) + } + + case tar.TypeLink: + // Hard links - validate target is within destDir + linkTarget, err := sanitizePath(destDir, header.Linkname) + if err != nil { + return extractedBytes, 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 +} + +// sanitizePath validates and returns a safe path within destDir +func sanitizePath(destDir, name string) (string, error) { + // Clean the path + name = filepath.Clean(name) + + // Reject absolute paths + if filepath.IsAbs(name) { + return "", fmt.Errorf("%w: absolute path %s", ErrInvalidArchivePath, name) + } + + // Reject paths with .. + if strings.Contains(name, "..") { + return "", fmt.Errorf("%w: path traversal in %s", ErrInvalidArchivePath, name) + } + + // Build target path + targetPath := filepath.Join(destDir, name) + + // Double-check the result is within destDir + if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(destDir)+string(os.PathSeparator)) && + filepath.Clean(targetPath) != filepath.Clean(destDir) { + return "", fmt.Errorf("%w: path escapes destination: %s", ErrInvalidArchivePath, name) + } + + return targetPath, nil +} + diff --git a/lib/volumes/archive_test.go b/lib/volumes/archive_test.go new file mode 100644 index 00000000..c96a888e --- /dev/null +++ b/lib/volumes/archive_test.go @@ -0,0 +1,232 @@ +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) +} + 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 +} + From 04d329916c32ff4f30e1b5120131ed2a381654ed Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Tue, 2 Dec 2025 16:57:42 -0500 Subject: [PATCH 2/4] Update path sanitization --- lib/volumes/archive.go | 103 ++++++++++++++++++++++------------------- 1 file changed, 56 insertions(+), 47 deletions(-) diff --git a/lib/volumes/archive.go b/lib/volumes/archive.go index 0afe5653..c0263a59 100644 --- a/lib/volumes/archive.go +++ b/lib/volumes/archive.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "strings" + + securejoin "github.com/cyphar/filepath-securejoin" ) var ( @@ -18,13 +20,33 @@ var ( ErrInvalidArchivePath = errors.New("invalid archive path") ) -// @sjmiller609 todo: do we have a dependency we can use for safe extraction? +// 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. // // Safety measures against adversarial archives: +// - Rejects archives containing path traversal attempts or absolute paths // - Tracks cumulative extracted size, aborts immediately if limit exceeded -// - Validates paths to prevent directory traversal attacks +// - Uses securejoin for safe path joining (defense in depth) // - Uses io.LimitReader as secondary protection when copying files func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { // Create destination directory @@ -53,12 +75,17 @@ func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { return extractedBytes, fmt.Errorf("read tar header: %w", err) } - // Validate and sanitize path - targetPath, err := sanitizePath(destDir, header.Name) - if err != nil { + // Validate path - reject archives with malicious entries + if err := validateArchivePath(header.Name); err != nil { return extractedBytes, err } + // Use securejoin for safe path joining (defense in depth) + 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) @@ -101,20 +128,29 @@ func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { } case tar.TypeSymlink: - // Validate symlink target doesn't escape destDir - linkTarget := header.Linkname - if filepath.IsAbs(linkTarget) { - return extractedBytes, fmt.Errorf("%w: absolute symlink target", ErrInvalidArchivePath) + // 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) } - // Resolve the symlink relative to its location - resolvedTarget := filepath.Join(filepath.Dir(targetPath), linkTarget) - resolvedTarget = filepath.Clean(resolvedTarget) + // 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) + } - // Ensure resolved path is within destDir - if !strings.HasPrefix(resolvedTarget, filepath.Clean(destDir)+string(os.PathSeparator)) && - resolvedTarget != filepath.Clean(destDir) { - return extractedBytes, fmt.Errorf("%w: symlink escapes destination", ErrInvalidArchivePath) + // 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 @@ -122,15 +158,15 @@ func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { return extractedBytes, fmt.Errorf("create parent dir for symlink: %w", err) } - if err := os.Symlink(linkTarget, targetPath); err != nil { + 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 - linkTarget, err := sanitizePath(destDir, header.Linkname) + // Hard links - validate target is within destDir using securejoin + linkTarget, err := securejoin.SecureJoin(destDir, header.Linkname) if err != nil { - return extractedBytes, err + return extractedBytes, fmt.Errorf("%w: hardlink target unsafe: %v", ErrInvalidArchivePath, err) } // Ensure parent directory exists @@ -151,30 +187,3 @@ func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { return extractedBytes, nil } -// sanitizePath validates and returns a safe path within destDir -func sanitizePath(destDir, name string) (string, error) { - // Clean the path - name = filepath.Clean(name) - - // Reject absolute paths - if filepath.IsAbs(name) { - return "", fmt.Errorf("%w: absolute path %s", ErrInvalidArchivePath, name) - } - - // Reject paths with .. - if strings.Contains(name, "..") { - return "", fmt.Errorf("%w: path traversal in %s", ErrInvalidArchivePath, name) - } - - // Build target path - targetPath := filepath.Join(destDir, name) - - // Double-check the result is within destDir - if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(destDir)+string(os.PathSeparator)) && - filepath.Clean(targetPath) != filepath.Clean(destDir) { - return "", fmt.Errorf("%w: path escapes destination: %s", ErrInvalidArchivePath, name) - } - - return targetPath, nil -} - From 0d8cd5c2fdd4e585aff04da024157904bf556e39 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 3 Dec 2025 11:32:02 -0500 Subject: [PATCH 3/4] Add from import volume to openapi spec and review upload security --- cmd/api/api/volumes.go | 188 ++++++++++------- cmd/api/main.go | 9 - go.mod | 2 +- go.sum | 10 +- lib/oapi/oapi.go | 401 ++++++++++++++++++++++++++++++------ lib/volumes/archive.go | 24 ++- lib/volumes/archive_test.go | 182 ++++++++++++++++ openapi.yaml | 68 ++++++ 8 files changed, 719 insertions(+), 165 deletions(-) diff --git a/cmd/api/api/volumes.go b/cmd/api/api/volumes.go index d676f350..88e6307a 100644 --- a/cmd/api/api/volumes.go +++ b/cmd/api/api/volumes.go @@ -2,9 +2,8 @@ package api import ( "context" - "encoding/json" "errors" - "net/http" + "io" "strconv" "github.com/onkernel/hypeman/lib/logger" @@ -160,92 +159,125 @@ func volumeToOAPI(vol volumes.Volume) oapi.Volume { return oapiVol } -// CreateVolumeFromArchiveHandler handles POST /volumes/from-archive -// This is a custom endpoint (outside OpenAPI spec) that accepts multipart form data -// with a tar.gz file to create a pre-populated volume. -// -// Form fields: -// - name: volume name (required) -// - size_gb: maximum size in GB (required) -// - id: optional custom volume ID -// - content: tar.gz file (required) -func (s *ApiService) CreateVolumeFromArchiveHandler(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() +// CreateVolumeFromArchive creates a volume pre-populated with content from a tar.gz archive +func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.CreateVolumeFromArchiveRequestObject) (oapi.CreateVolumeFromArchiveResponseObject, error) { log := logger.FromContext(ctx) - // Parse multipart form (32MB max in memory, rest goes to temp files) - if err := r.ParseMultipartForm(32 << 20); err != nil { - writeJSONError(w, http.StatusBadRequest, "invalid_form", "failed to parse multipart form: "+err.Error()) - return - } - - // Get required fields - name := r.FormValue("name") - if name == "" { - writeJSONError(w, http.StatusBadRequest, "missing_field", "name is required") - return - } + // Read the multipart form data from the request body + var name string + var sizeGb int + var id *string + var archiveReader io.Reader - sizeGbStr := r.FormValue("size_gb") - if sizeGbStr == "" { - writeJSONError(w, http.StatusBadRequest, "missing_field", "size_gb is required") - return - } - sizeGb, err := strconv.Atoi(sizeGbStr) - if err != nil || sizeGb <= 0 { - writeJSONError(w, http.StatusBadRequest, "invalid_field", "size_gb must be a positive integer") - return - } + for { + part, err := request.Body.NextPart() + if err == io.EOF { + break + } + if err != nil { + return oapi.CreateVolumeFromArchive400JSONResponse{ + Code: "invalid_form", + Message: "failed to parse multipart form: " + err.Error(), + }, nil + } - // Get optional ID - var id *string - if idVal := r.FormValue("id"); idVal != "" { - id = &idVal - } + switch part.FormName() { + case "name": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateVolumeFromArchive400JSONResponse{ + 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.CreateVolumeFromArchive400JSONResponse{ + Code: "invalid_field", + Message: "failed to read size_gb field", + }, nil + } + sizeGb, err = strconv.Atoi(string(data)) + if err != nil || sizeGb <= 0 { + return oapi.CreateVolumeFromArchive400JSONResponse{ + Code: "invalid_field", + Message: "size_gb must be a positive integer", + }, nil + } + case "id": + data, err := io.ReadAll(part) + if err != nil { + return oapi.CreateVolumeFromArchive400JSONResponse{ + 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.CreateVolumeFromArchive400JSONResponse{ + Code: "missing_field", + Message: "name is required", + }, nil + } + if sizeGb <= 0 { + return oapi.CreateVolumeFromArchive400JSONResponse{ + Code: "missing_field", + Message: "size_gb is required", + }, nil + } - // Get the archive file - file, _, err := r.FormFile("content") - if err != nil { - writeJSONError(w, http.StatusBadRequest, "missing_file", "content file is required") - return - } - defer file.Close() + // Create the volume + domainReq := volumes.CreateVolumeFromArchiveRequest{ + Name: name, + SizeGb: sizeGb, + Id: id, + } - // Create the volume - 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.CreateVolumeFromArchive400JSONResponse{ + Code: "archive_too_large", + Message: err.Error(), + }, nil + } + if errors.Is(err, volumes.ErrAlreadyExists) { + return oapi.CreateVolumeFromArchive409JSONResponse{ + 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.CreateVolumeFromArchive500JSONResponse{ + Code: "internal_error", + Message: "failed to create volume", + }, nil + } - vol, err := s.VolumeManager.CreateVolumeFromArchive(ctx, domainReq, file) - if err != nil { - if errors.Is(err, volumes.ErrArchiveTooLarge) { - writeJSONError(w, http.StatusBadRequest, "archive_too_large", err.Error()) - return - } - if errors.Is(err, volumes.ErrAlreadyExists) { - writeJSONError(w, http.StatusConflict, "already_exists", "volume with this ID already exists") - return + return oapi.CreateVolumeFromArchive201JSONResponse(volumeToOAPI(*vol)), nil } - log.Error("failed to create volume from archive", "error", err, "name", name) - writeJSONError(w, http.StatusInternalServerError, "internal_error", "failed to create volume") - return } - // Return success response - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(volumeToOAPI(*vol)) -} + // If we get here without processing content, it means content was not provided + if archiveReader == nil { + return oapi.CreateVolumeFromArchive400JSONResponse{ + Code: "missing_file", + Message: "content file is required", + }, nil + } -// writeJSONError writes a JSON error response -func writeJSONError(w http.ResponseWriter, status int, code, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{ - "code": code, - "message": message, - }) + // Should not reach here, but return error just in case + return oapi.CreateVolumeFromArchive500JSONResponse{ + Code: "internal_error", + Message: "unexpected error processing request", + }, nil } diff --git a/cmd/api/main.go b/cmd/api/main.go index 5744cafb..d2ad80f2 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -103,15 +103,6 @@ func run() error { mw.JwtAuth(app.Config.JwtSecret), ).Get("/instances/{id}/exec", app.ApiService.ExecHandler) - // Custom volume from archive endpoint (outside OpenAPI spec, uses multipart form) - r.With( - middleware.RequestID, - middleware.RealIP, - middleware.Logger, - middleware.Recoverer, - mw.JwtAuth(app.Config.JwtSecret), - ).Post("/volumes/from-archive", app.ApiService.CreateVolumeFromArchiveHandler) - // Authenticated API endpoints r.Group(func(r chi.Router) { // Common middleware 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/oapi/oapi.go b/lib/oapi/oapi.go index 9629cf2a..bfa315ed 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"` } +// CreateVolumeFromArchiveMultipartBody defines parameters for CreateVolumeFromArchive. +type CreateVolumeFromArchiveMultipartBody 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 +// CreateVolumeFromArchiveMultipartRequestBody defines body for CreateVolumeFromArchive for multipart/form-data ContentType. +type CreateVolumeFromArchiveMultipartRequestBody CreateVolumeFromArchiveMultipartBody + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -453,6 +473,9 @@ type ClientInterface interface { CreateVolume(ctx context.Context, body CreateVolumeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // CreateVolumeFromArchiveWithBody request with any body + CreateVolumeFromArchiveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteVolume request DeleteVolume(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -700,6 +723,18 @@ func (c *Client) CreateVolume(ctx context.Context, body CreateVolumeJSONRequestB return c.Client.Do(req) } +func (c *Client) CreateVolumeFromArchiveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateVolumeFromArchiveRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) DeleteVolume(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDeleteVolumeRequest(c.Server, id) if err != nil { @@ -1323,6 +1358,35 @@ func NewCreateVolumeRequestWithBody(server string, contentType string, body io.R return req, nil } +// NewCreateVolumeFromArchiveRequestWithBody generates requests for CreateVolumeFromArchive with any type of body +func NewCreateVolumeFromArchiveRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/volumes/from-archive") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewDeleteVolumeRequest generates requests for DeleteVolume func NewDeleteVolumeRequest(server string, id string) (*http.Request, error) { var err error @@ -1490,6 +1554,9 @@ type ClientWithResponsesInterface interface { CreateVolumeWithResponse(ctx context.Context, body CreateVolumeJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateVolumeResponse, error) + // CreateVolumeFromArchiveWithBodyWithResponse request with any body + CreateVolumeFromArchiveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVolumeFromArchiveResponse, error) + // DeleteVolumeWithResponse request DeleteVolumeWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteVolumeResponse, error) @@ -1883,6 +1950,32 @@ func (r CreateVolumeResponse) StatusCode() int { return 0 } +type CreateVolumeFromArchiveResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *Volume + JSON400 *Error + JSON401 *Error + JSON409 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r CreateVolumeFromArchiveResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateVolumeFromArchiveResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type DeleteVolumeResponse struct { Body []byte HTTPResponse *http.Response @@ -2107,6 +2200,15 @@ func (c *ClientWithResponses) CreateVolumeWithResponse(ctx context.Context, body return ParseCreateVolumeResponse(rsp) } +// CreateVolumeFromArchiveWithBodyWithResponse request with arbitrary body returning *CreateVolumeFromArchiveResponse +func (c *ClientWithResponses) CreateVolumeFromArchiveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVolumeFromArchiveResponse, error) { + rsp, err := c.CreateVolumeFromArchiveWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateVolumeFromArchiveResponse(rsp) +} + // DeleteVolumeWithResponse request returning *DeleteVolumeResponse func (c *ClientWithResponses) DeleteVolumeWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteVolumeResponse, error) { rsp, err := c.DeleteVolume(ctx, id, reqEditors...) @@ -2779,6 +2881,60 @@ func ParseCreateVolumeResponse(rsp *http.Response) (*CreateVolumeResponse, error return response, nil } +// ParseCreateVolumeFromArchiveResponse parses an HTTP response from a CreateVolumeFromArchiveWithResponse call +func ParseCreateVolumeFromArchiveResponse(rsp *http.Response) (*CreateVolumeFromArchiveResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateVolumeFromArchiveResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest Volume + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + 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 { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseDeleteVolumeResponse parses an HTTP response from a DeleteVolumeWithResponse call func ParseDeleteVolumeResponse(rsp *http.Response) (*DeleteVolumeResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -2909,6 +3065,9 @@ type ServerInterface interface { // Create volume // (POST /volumes) CreateVolume(w http.ResponseWriter, r *http.Request) + // Create volume from tar.gz archive + // (POST /volumes/from-archive) + CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) // Delete volume // (DELETE /volumes/{id}) DeleteVolume(w http.ResponseWriter, r *http.Request, id string) @@ -3017,6 +3176,12 @@ func (_ Unimplemented) CreateVolume(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Create volume from tar.gz archive +// (POST /volumes/from-archive) +func (_ Unimplemented) CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Delete volume // (DELETE /volumes/{id}) func (_ Unimplemented) DeleteVolume(w http.ResponseWriter, r *http.Request, id string) { @@ -3488,6 +3653,26 @@ func (siw *ServerInterfaceWrapper) CreateVolume(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } +// CreateVolumeFromArchive operation middleware +func (siw *ServerInterfaceWrapper) CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateVolumeFromArchive(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // DeleteVolume operation middleware func (siw *ServerInterfaceWrapper) DeleteVolume(w http.ResponseWriter, r *http.Request) { @@ -3711,6 +3896,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/volumes", wrapper.CreateVolume) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/volumes/from-archive", wrapper.CreateVolumeFromArchive) + }) r.Group(func(r chi.Router) { r.Delete(options.BaseURL+"/volumes/{id}", wrapper.DeleteVolume) }) @@ -4334,6 +4522,59 @@ func (response CreateVolume500JSONResponse) VisitCreateVolumeResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type CreateVolumeFromArchiveRequestObject struct { + Body *multipart.Reader +} + +type CreateVolumeFromArchiveResponseObject interface { + VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error +} + +type CreateVolumeFromArchive201JSONResponse Volume + +func (response CreateVolumeFromArchive201JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + + return json.NewEncoder(w).Encode(response) +} + +type CreateVolumeFromArchive400JSONResponse Error + +func (response CreateVolumeFromArchive400JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type CreateVolumeFromArchive401JSONResponse Error + +func (response CreateVolumeFromArchive401JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type CreateVolumeFromArchive409JSONResponse Error + +func (response CreateVolumeFromArchive409JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type CreateVolumeFromArchive500JSONResponse Error + +func (response CreateVolumeFromArchive500JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type DeleteVolumeRequestObject struct { Id string `json:"id"` } @@ -4462,6 +4703,9 @@ type StrictServerInterface interface { // Create volume // (POST /volumes) CreateVolume(ctx context.Context, request CreateVolumeRequestObject) (CreateVolumeResponseObject, error) + // Create volume from tar.gz archive + // (POST /volumes/from-archive) + CreateVolumeFromArchive(ctx context.Context, request CreateVolumeFromArchiveRequestObject) (CreateVolumeFromArchiveResponseObject, error) // Delete volume // (DELETE /volumes/{id}) DeleteVolume(ctx context.Context, request DeleteVolumeRequestObject) (DeleteVolumeResponseObject, error) @@ -4932,6 +5176,37 @@ func (sh *strictHandler) CreateVolume(w http.ResponseWriter, r *http.Request) { } } +// CreateVolumeFromArchive operation middleware +func (sh *strictHandler) CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) { + var request CreateVolumeFromArchiveRequestObject + + if reader, err := r.MultipartReader(); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err)) + return + } else { + request.Body = reader + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateVolumeFromArchive(ctx, request.(CreateVolumeFromArchiveRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateVolumeFromArchive") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateVolumeFromArchiveResponseObject); ok { + if err := validResponse.VisitCreateVolumeFromArchiveResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // DeleteVolume operation middleware func (sh *strictHandler) DeleteVolume(w http.ResponseWriter, r *http.Request, id string) { var request DeleteVolumeRequestObject @@ -4987,67 +5262,71 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+xciW4bOZN+FYL7DyAvWqftbKzBYuGxcxiIE8OeZICNsw7VXZI4YZMdki1bCfzuCx7d", - "6kuHE1sT/wkQIFIfZN31VRXlrzgUcSI4cK3w8CtW4RRiYj8eak3C6TvB0hjO4XMKSpvLiRQJSE3BPhSL", - "lOurhOip+RaBCiVNNBUcD/EZ0VN0PQUJaGZXQWoqUhahESD7HkQ4wHBD4oQBHuJuzHU3IprgAOt5Yi4p", - "LSmf4NsASyCR4GzuthmTlGk8HBOmIKhse2qWRkQh80rbvpOvNxKCAeH41q74OaUSIjx8X2TjQ/6wGP0N", - "oTabH0kgGk5iMlkuCU5iqMvgzdEJouY9JGEMEngIqAWdSSdAkQg/gexQ0WV0JImcd/mE8pshIxqU3imJ", - "ZvWzdXlV2LO0rWCMK014uJw34DPzH4kiavgi7Kx0u6assgye8RmVgsfANZoRScmIgSqy9xW/fnP87OrZ", - "63d4aHaO0tC+GuCzN+d/4iHe7fV6Zt0a/VOhE5ZOrhT9AiXLwLsv/sBVQg5z+lEMsZBzNBYS+TVQa5rG", - "hLeN1RgKzb2YaMToJ0CXZr1LHKBL3H9xicvKGditakKwat/IItaomrCEcliq62CJ6b0ss2MeQi0mrkGG", - "RAFioDVIFaCITqhWASI8QhFRU1DIOM3vKCScC42UJlIjIRHwCF1TPUXEPlcWQjxvXwv5iQkStfs4wDG5", - "eQV8YuLCk90AJ8TsZsj6v/ek/aXXPvjQ8h/aH/4zu7TzP/9q5A+0WbvO4mt3A4WCj+kklcRct0rVU0DU", - "mzUOauZsJBKVDEbLtBZJ/pqCnoJEWiBig2G+pLlktvCvo4zCgkTcgg1xp2bEYgaSkXmDEfd7DVb8l6Ta", - "atS/hyKqPiHz8hoTNqs5G97v1Y2412zFDUQ10PSHsSjvU5tQkhPSH5z6j4NN/WoWJqkqkTSokvM6jUcg", - "kRijGZU6JQwdnb0thZxBvjDlGiYg7co2S6m6nbkkqAqG4PWf2wPRKDSx1NifprGxOaohtmv9S8IYD/F/", - "dBeptuvzbNet7FKtCZCFKEekJPPmUJ4Fl+UhfU3aplFDYEp8bAxTpUWMaARc0zEFiVok1aI9AQ6SaIgQ", - "HSMTGRIpZjSCqKy2mWBtk8VtGNgwVjlykWeuFFXsUk4zy+zzajKqL3lhzJByNKETMprrcsbp9+r6bxZ0", - "tn6TqJ9JKWRduKGIGlg8TBJGQ2shbZVASMc0RGBWQOYF1IpJOKUccp8pS3VEoivp1Rk0ZVxNKGsw3ULO", - "c5v5J1HLhMk4ZZomDNw9tbOp2VrOj+1KdYsNMOUc5BVk4rnDSjEo1Zg2K9ks4yV/xEb9CEbpZGJEUhTd", - "KVWK8gnKtIvGFFg0dFl4LXay2lwQttQOPA8bWsMrk4fbDGbAikbgPMoQGwsJKLcTp7QSV5TPCKPRFeVJ", - "2mgSS0X5PJU2rblFERmJVNto5hRW3MRCXuvrY5HyqFFYNXG8BMJcPVCWhNJEpz4Bp7GRrfhk5LnYTnxa", - "qw6/SJMaTjLAVVFA3BDsjk6P0ViK2EAHTSgHiWLQxFcfOUXvscXZOMBtY1MRgVhwJMbj3w0FuavUo1zK", - "mLHTCgzIHcTmCoiuiG4grZhHlCZxglrnz492d3cPqil7sN/u9dv9/T/7vWHP/PtfHGCXag2SJBraPhnV", - "Awad+MxQ3v0clGAziFBMOB2D0sg/WdxZTclg/8mQjML+YDeC8d7+k06n07QNcC3niaC8Yatn+b3NVNF1", - "0Li9WLOjpt+nhwcobDbh5Ss+O/zzpSl5UyW7TISEddWI8mHhe/51ccN+cF9HlDcWRHnMrVBqQ4yPCCZ9", - "OzdCVKExoaxSiCcpY/760HDCIcwNUthgs0Su69L8a2OajH6BCDUWxppMTKHhLO77KuAAf04hhatEKOp2", - "r7Un/B0DEkYpZRGyb6CWYS6DOPZSGeAMlrJfgJIWNjjYUdv4OMfrZmfzjN8z5Zoy27aYl3bc333y9L96", - "B/1Bwbkp10/28Eak5GG3gtktz/5ukMfkBHjkMqgxA/cpFHxmvMJ+sfSZOOMMpxTAs3s1ZZjqiPLJVUQb", - "rPMvdxNFVEKobV2+3odwlyTJelNsRnV5TMvZL0TkxtyS1ZL19HL/oXz3bqH8Yboz9V4LUVeKk0RNRQOr", - "Wa1MUPYMghuqtPLlOFXFejzn3HfwqmVyU2enhAZ9z2ZFyblZj6YBGhyWa52U088plKqho7cnxwNf0pa3", - "0V/2yMHTmxuiD57Qa3XwJR7Jyd+75JH0h1Z2dL63LSPGd+jKNJlWXmxT5ctwiL65ERNgmjToXik64RCh", - "kzNEokiCUsV8kC1fVnr/YNDpP3na6fd6nX5vk+wYk3DF3qeHR5tv3hs45Dcko2EYDWH8HdnZq811Cgm7", - "JnOFLrM2yyVG11PgyKupkp19K2aj+qDe7/q29lZFC2sbWHdpWG0UPWxndEnov7Bd07vH/f2lcX+tVk0u", - "g3X1dpbILuzD9i2RJEuZEMmdeBisyV1reSg097bR0KuGkUJwepj2HTVIu9TDy/S2MQS5yNRcZim7bREd", - "DC95G7lWYDRE705PkV8djVKN8rY+RKh1xEQaoZfzBOSMKiERJ5rOYMescJ5yTvnErGCjbmjusDmS7vrq", - "l89Iqtzu5t3Eflv9xsU01ZG45vYdNU01Mt8syYYFDyhWL+HMeYheC/uOpzQwAbSCTNzjhEejef3xKopp", - "hYSjkUnKSgsJ0c4lL4BmL2kcYC8xHGDHPg5wxpX56Kizn+zGBU0vnMBZVR1qktzOGkz6FVXaOEiYSmmw", - "XOFh1II40fOspsmMfudbrfyEj0VT2+++oXDv4K5djSY897YK4P4du9XFuJJtsjai1KLXd473qcrm+oYV", - "E08nabV1tHLI71P++hm/8zeUgGznqDDDC6beuJbUltVeRO4sgOBs/t8m7+zgJjy4Gpackpt8BwsYiEKV", - "GZfjIxvv+ynXTgedZ31nOs6WsGR0yvilGWNsfu4hg8l1Zaw6CJElyasm1/GGvsJ1XAtwbdd2sUew7qxF", - "Y7CpD7C81hvJPjmuFhuuAF1IppD3K011pZfyFKx0B3fsxNz7RuMva7pxDj2FQlg3fBQ1u66mrkaMggRL", - "nBUoqevHxDAIU0n1/MJkB6eNERAJ8jB1crFpw25tLy94nWqd4NtbOyBySi3z+cIU3TREh2cn1otjwsnE", - "uNS7U8ToGMJ5yACldphTwwD2ZMKbo5P2iBickZWutpVBtRW/eTom3KyPAzwDqdy+vc6gY8+XiAQ4SSge", - "4t1Ov2NKOSMRy2J3mk81JmAjpTFHm9JOIku79nMPIz+VCK6cbAa9nhsDce1DLFlMArt/K9eadKl2XSL2", - "O1gRVvKHEYMrjR2hDnqqNI6JnBve7VUUTiH8ZG91LfxUSxkyeOLEPfKdHG2EMdzwpg6fa5xmOMeTfxvg", - "vV7/3iTsRroN277lJNVTIekXiMym+/eo1qWbnnANkhOGFMgZSD+gKzohHr4vu9/7D7cfinq34lrIKhGq", - "QdeF823YRQlQ+g8Rze+NxYYTdLfliGQy4m3N0gb3RoE3sAYh22bbKOuGu6KIqDkPd5x1bUHRf5AIZdP9", - "f8qi93p7W7DoykD5EXnSWcqYPSTnpyGLEVYxnna/GhR+65IbA1epl73t2F7PvC0hksSgQSpLQUVH56/a", - "wEMRGfToROd7B+auT9euSMnQf9mjgoLgqhDtQ83b9hqwlN3VsfLLTDYwE6fdzDCCpWjhO/TvSozFAeLf", - "Bs/9LOC3wXM3Dfht93BxjvhhjKW3rdCcnW36ZXxrje8F+GS/EJoNTR7rr0F7+VNbAXxZq/UumC+n8Bfs", - "2wT2FcW1Evkt2t4PCP4qvzLYCP/dn4oX9tYkcN8m8A2znwr3PRaT9j0/g8DcTxLoQqPFGNf9SqNN8Fdh", - "Dr0qBee2cXKM7KhhGf6yfZT7Rl/Z5lsHYNnGjzIN2rml/T2LB2OFXLMUj/1w9tDbbuzbOsx61CZmkVZN", - "dPVA1GViUoRd1QG7BBIvjkaZ2lIJBsi8hYhCF5bA9gVwjZ7NDHedS34OOpVc2X4wI0qj14hRDgq1jNik", - "YAwiNJqjj4aqjyg3553AvMKR8L/0YPNLbt6gPAWFlKWF8gnicO0XpGP0cSwYE9d2YvGxY6eeS33nleH1", - "H/KfYPmZAceLFkhawbnThWCPs9t9P6cg54uN/VH7xVb53KXfa5zE1WadXqaNIiVjbc8yUU0JQyLV7vh+", - "EyFO8s2kLGvyrw8jGm50F4wttR19ZYeqyrUOxsXEM4ZaFxfPdn4FjA1zkhVZ7unWw70AG8KGP2dgJ1+N", - "yP3cPfDTp63sQMY/bIZ7vYOH3/pI8DGjoUbthR0ZKig3kJhHo7nV7eKky2NyEG/QC85smPZ8NfpIdm+p", - "j/hDNj+9jyzs4yf3klBICaF2Z+Qe1/ChADcL7t6yx+oWx9WCrOR5d3ranFj8mcjuV/fhZF2tvPgjID8M", - "svPHUtZtkzH4KHzV8xSBO5iyfT8V+cmhRzp2sb/L9yzY1FGs+pvzQ/FP3Pw81n3/Dd6mPxW0UXt3q76V", - "Hfr6YXxr29nQ00CY/ZleSR6Pxc2dpWWcaFFpAhcO/C8dc/mz/1sZcvnQcocRV8bBr2nABgOugrBWjbfy", - "AP9ww61viH33p9zMypZGvl9jrR9+rDXLdLiIYhsOsjaDLxuiiocYYuXQdrsjrHc/TsYtnDh/hEeZZnkS", - "WzY7+7FMsLe9wLrtmdm7R1yhvYAsYRfmZXYBs2KTwbwSIWEoghkwkdifPLhncYBTyfwPCoZd9wdTpkLp", - "4dPe0x6+/XD7/wEAAP//oKJwbNdUAAA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/volumes/archive.go b/lib/volumes/archive.go index c0263a59..c51a4201 100644 --- a/lib/volumes/archive.go +++ b/lib/volumes/archive.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "syscall" securejoin "github.com/cyphar/filepath-securejoin" ) @@ -43,11 +44,16 @@ func validateArchivePath(name string) error { // ExtractTarGz extracts a tar.gz archive to destDir, aborting if the extracted // content exceeds maxBytes. Returns the total extracted bytes on success. // -// Safety measures against adversarial archives: -// - Rejects archives containing path traversal attempts or absolute paths -// - Tracks cumulative extracted size, aborts immediately if limit exceeded -// - Uses securejoin for safe path joining (defense in depth) -// - Uses io.LimitReader as secondary protection when copying files +// 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 { @@ -80,7 +86,7 @@ func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { return extractedBytes, err } - // Use securejoin for safe path joining (defense in depth) + // 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) @@ -103,8 +109,10 @@ func ExtractTarGz(r io.Reader, destDir string, maxBytes int64) (int64, error) { return extractedBytes, fmt.Errorf("create parent dir: %w", err) } - // Create file - f, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + // 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) } diff --git a/lib/volumes/archive_test.go b/lib/volumes/archive_test.go index c96a888e..810b8a85 100644 --- a/lib/volumes/archive_test.go +++ b/lib/volumes/archive_test.go @@ -230,3 +230,185 @@ func TestExtractTarGz_PreventsTarBomb(t *testing.T) { 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/openapi.yaml b/openapi.yaml index bd6ac20e..432dd52a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -949,6 +949,74 @@ paths: schema: $ref: "#/components/schemas/Error" + /volumes/from-archive: + post: + summary: Create volume from tar.gz archive + description: | + Creates a new volume pre-populated with content from an uploaded tar.gz archive. + The archive is securely extracted with size limits to prevent tar bombs. + operationId: createVolumeFromArchive + security: + - bearerAuth: [] + requestBody: + required: true + content: + 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 + content: + application/json: + schema: + $ref: "#/components/schemas/Volume" + 400: + description: Bad request (invalid form data or archive too large) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 401: + description: Unauthorized + content: + 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: + application/json: + schema: + $ref: "#/components/schemas/Error" + /volumes/{id}: get: summary: Get volume details From 37507a0cb8b61a78c3a3ad7c7e3a4d9a027f7339 Mon Sep 17 00:00:00 2001 From: Steven Miller Date: Wed, 3 Dec 2025 11:36:46 -0500 Subject: [PATCH 4/4] Combined endpoints --- cmd/api/api/volumes.go | 287 ++++++++++++++++------------- lib/oapi/oapi.go | 410 +++++++++-------------------------------- openapi.yaml | 44 +---- 3 files changed, 252 insertions(+), 489 deletions(-) diff --git a/cmd/api/api/volumes.go b/cmd/api/api/volumes.go index 88e6307a..8b4afd88 100644 --- a/cmd/api/api/volumes.go +++ b/cmd/api/api/volumes.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "mime/multipart" "strconv" "github.com/onkernel/hypeman/lib/logger" @@ -33,149 +34,64 @@ 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, - } - - 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", - }, nil - } - return oapi.CreateVolume201JSONResponse(volumeToOAPI(*vol)), nil -} - -// 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{ - Code: "internal_error", - Message: "failed to get volume", - }, nil + // Handle JSON request (empty volume) + if request.JSONBody != nil { + domainReq := volumes.CreateVolumeRequest{ + Name: request.JSONBody.Name, + SizeGb: request.JSONBody.SizeGb, + Id: request.JSONBody.Id, } - } - return oapi.GetVolume200JSONResponse(volumeToOAPI(*vol)), nil -} -// 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) - - // 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): - return oapi.DeleteVolume404JSONResponse{ - Code: "not_found", - Message: "volume not found", - }, nil - case errors.Is(err, volumes.ErrInUse): - return oapi.DeleteVolume409JSONResponse{ - Code: "conflict", - Message: "volume is in use by an instance", - }, nil - default: - log.Error("failed to delete volume", "error", err, "id", request.Id) - return oapi.DeleteVolume500JSONResponse{ + 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 delete volume", + Message: "failed to create volume", }, nil } - } - return oapi.DeleteVolume204Response{}, nil -} - -func volumeToOAPI(vol volumes.Volume) oapi.Volume { - oapiVol := oapi.Volume{ - Id: vol.Id, - Name: vol.Name, - SizeGb: vol.SizeGb, - CreatedAt: vol.CreatedAt, + return oapi.CreateVolume201JSONResponse(volumeToOAPI(*vol)), nil } - // Convert attachments - if len(vol.Attachments) > 0 { - attachments := make([]oapi.VolumeAttachmentInfo, len(vol.Attachments)) - for i, att := range vol.Attachments { - attachments[i] = oapi.VolumeAttachmentInfo{ - InstanceId: att.InstanceID, - MountPath: att.MountPath, - Readonly: att.Readonly, - } - } - oapiVol.Attachments = &attachments + // Handle multipart request (volume with archive content) + if request.MultipartBody != nil { + return s.createVolumeFromMultipart(ctx, request.MultipartBody) } - return oapiVol + return oapi.CreateVolume400JSONResponse{ + Code: "invalid_request", + Message: "request body is required", + }, nil } -// CreateVolumeFromArchive creates a volume pre-populated with content from a tar.gz archive -func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.CreateVolumeFromArchiveRequestObject) (oapi.CreateVolumeFromArchiveResponseObject, error) { +// 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) - // Read the multipart form data from the request body var name string var sizeGb int var id *string var archiveReader io.Reader for { - part, err := request.Body.NextPart() + part, err := multipartReader.NextPart() if err == io.EOF { break } if err != nil { - return oapi.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "invalid_form", Message: "failed to parse multipart form: " + err.Error(), }, nil @@ -185,7 +101,7 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C case "name": data, err := io.ReadAll(part) if err != nil { - return oapi.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "invalid_field", Message: "failed to read name field", }, nil @@ -194,14 +110,14 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C case "size_gb": data, err := io.ReadAll(part) if err != nil { - return oapi.CreateVolumeFromArchive400JSONResponse{ + 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.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "invalid_field", Message: "size_gb must be a positive integer", }, nil @@ -209,7 +125,7 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C case "id": data, err := io.ReadAll(part) if err != nil { - return oapi.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "invalid_field", Message: "failed to read id field", }, nil @@ -222,19 +138,19 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C archiveReader = part // Process the archive immediately while we have the reader if name == "" { - return oapi.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "missing_field", Message: "name is required", }, nil } if sizeGb <= 0 { - return oapi.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "missing_field", Message: "size_gb is required", }, nil } - // Create the volume + // Create the volume from archive domainReq := volumes.CreateVolumeFromArchiveRequest{ Name: name, SizeGb: sizeGb, @@ -244,40 +160,147 @@ func (s *ApiService) CreateVolumeFromArchive(ctx context.Context, request oapi.C vol, err := s.VolumeManager.CreateVolumeFromArchive(ctx, domainReq, archiveReader) if err != nil { if errors.Is(err, volumes.ErrArchiveTooLarge) { - return oapi.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "archive_too_large", Message: err.Error(), }, nil } if errors.Is(err, volumes.ErrAlreadyExists) { - return oapi.CreateVolumeFromArchive409JSONResponse{ + 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.CreateVolumeFromArchive500JSONResponse{ + return oapi.CreateVolume500JSONResponse{ Code: "internal_error", Message: "failed to create volume", }, nil } - return oapi.CreateVolumeFromArchive201JSONResponse(volumeToOAPI(*vol)), 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.CreateVolumeFromArchive400JSONResponse{ + return oapi.CreateVolume400JSONResponse{ Code: "missing_file", - Message: "content file is required", + Message: "content file is required for multipart requests", }, nil } - // Should not reach here, but return error just in case - return oapi.CreateVolumeFromArchive500JSONResponse{ + // Should not reach here + return oapi.CreateVolume500JSONResponse{ Code: "internal_error", Message: "unexpected error processing request", }, nil } +// 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{ + Code: "internal_error", + Message: "failed to get volume", + }, nil + } + } + return oapi.GetVolume200JSONResponse(volumeToOAPI(*vol)), nil +} + +// 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) + + // 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): + return oapi.DeleteVolume404JSONResponse{ + Code: "not_found", + Message: "volume not found", + }, nil + case errors.Is(err, volumes.ErrInUse): + return oapi.DeleteVolume409JSONResponse{ + Code: "conflict", + Message: "volume is in use by an instance", + }, nil + default: + log.Error("failed to delete volume", "error", err, "id", request.Id) + return oapi.DeleteVolume500JSONResponse{ + Code: "internal_error", + Message: "failed to delete volume", + }, nil + } + } + return oapi.DeleteVolume204Response{}, nil +} + +func volumeToOAPI(vol volumes.Volume) oapi.Volume { + oapiVol := oapi.Volume{ + Id: vol.Id, + Name: vol.Name, + SizeGb: vol.SizeGb, + CreatedAt: vol.CreatedAt, + } + + // Convert attachments + if len(vol.Attachments) > 0 { + attachments := make([]oapi.VolumeAttachmentInfo, len(vol.Attachments)) + for i, att := range vol.Attachments { + attachments[i] = oapi.VolumeAttachmentInfo{ + InstanceId: att.InstanceID, + MountPath: att.MountPath, + Readonly: att.Readonly, + } + } + oapiVol.Attachments = &attachments + } + + return oapiVol +} + + diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index bfa315ed..0b069ed5 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -314,8 +314,8 @@ type GetInstanceLogsParams struct { Follow *bool `form:"follow,omitempty" json:"follow,omitempty"` } -// CreateVolumeFromArchiveMultipartBody defines parameters for CreateVolumeFromArchive. -type CreateVolumeFromArchiveMultipartBody struct { +// CreateVolumeMultipartBody defines parameters for CreateVolume. +type CreateVolumeMultipartBody struct { // Content tar.gz archive file containing the volume content Content openapi_types.File `json:"content"` @@ -341,8 +341,8 @@ type AttachVolumeJSONRequestBody = AttachVolumeRequest // CreateVolumeJSONRequestBody defines body for CreateVolume for application/json ContentType. type CreateVolumeJSONRequestBody = CreateVolumeRequest -// CreateVolumeFromArchiveMultipartRequestBody defines body for CreateVolumeFromArchive for multipart/form-data ContentType. -type CreateVolumeFromArchiveMultipartRequestBody CreateVolumeFromArchiveMultipartBody +// 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 @@ -473,9 +473,6 @@ type ClientInterface interface { CreateVolume(ctx context.Context, body CreateVolumeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // CreateVolumeFromArchiveWithBody request with any body - CreateVolumeFromArchiveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - // DeleteVolume request DeleteVolume(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -723,18 +720,6 @@ func (c *Client) CreateVolume(ctx context.Context, body CreateVolumeJSONRequestB return c.Client.Do(req) } -func (c *Client) CreateVolumeFromArchiveWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewCreateVolumeFromArchiveRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - func (c *Client) DeleteVolume(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDeleteVolumeRequest(c.Server, id) if err != nil { @@ -1358,35 +1343,6 @@ func NewCreateVolumeRequestWithBody(server string, contentType string, body io.R return req, nil } -// NewCreateVolumeFromArchiveRequestWithBody generates requests for CreateVolumeFromArchive with any type of body -func NewCreateVolumeFromArchiveRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/volumes/from-archive") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - // NewDeleteVolumeRequest generates requests for DeleteVolume func NewDeleteVolumeRequest(server string, id string) (*http.Request, error) { var err error @@ -1554,9 +1510,6 @@ type ClientWithResponsesInterface interface { CreateVolumeWithResponse(ctx context.Context, body CreateVolumeJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateVolumeResponse, error) - // CreateVolumeFromArchiveWithBodyWithResponse request with any body - CreateVolumeFromArchiveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVolumeFromArchiveResponse, error) - // DeleteVolumeWithResponse request DeleteVolumeWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteVolumeResponse, error) @@ -1931,6 +1884,7 @@ type CreateVolumeResponse struct { JSON201 *Volume JSON400 *Error JSON401 *Error + JSON409 *Error JSON500 *Error } @@ -1950,32 +1904,6 @@ func (r CreateVolumeResponse) StatusCode() int { return 0 } -type CreateVolumeFromArchiveResponse struct { - Body []byte - HTTPResponse *http.Response - JSON201 *Volume - JSON400 *Error - JSON401 *Error - JSON409 *Error - JSON500 *Error -} - -// Status returns HTTPResponse.Status -func (r CreateVolumeFromArchiveResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r CreateVolumeFromArchiveResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - type DeleteVolumeResponse struct { Body []byte HTTPResponse *http.Response @@ -2200,15 +2128,6 @@ func (c *ClientWithResponses) CreateVolumeWithResponse(ctx context.Context, body return ParseCreateVolumeResponse(rsp) } -// CreateVolumeFromArchiveWithBodyWithResponse request with arbitrary body returning *CreateVolumeFromArchiveResponse -func (c *ClientWithResponses) CreateVolumeFromArchiveWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateVolumeFromArchiveResponse, error) { - rsp, err := c.CreateVolumeFromArchiveWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseCreateVolumeFromArchiveResponse(rsp) -} - // DeleteVolumeWithResponse request returning *DeleteVolumeResponse func (c *ClientWithResponses) DeleteVolumeWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteVolumeResponse, error) { rsp, err := c.DeleteVolume(ctx, id, reqEditors...) @@ -2847,53 +2766,6 @@ func ParseCreateVolumeResponse(rsp *http.Response) (*CreateVolumeResponse, error HTTPResponse: rsp, } - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest Volume - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON201 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest Error - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON400 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: - var dest Error - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON401 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: - var dest Error - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON500 = &dest - - } - - return response, nil -} - -// ParseCreateVolumeFromArchiveResponse parses an HTTP response from a CreateVolumeFromArchiveWithResponse call -func ParseCreateVolumeFromArchiveResponse(rsp *http.Response) (*CreateVolumeFromArchiveResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &CreateVolumeFromArchiveResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: var dest Volume @@ -3065,9 +2937,6 @@ type ServerInterface interface { // Create volume // (POST /volumes) CreateVolume(w http.ResponseWriter, r *http.Request) - // Create volume from tar.gz archive - // (POST /volumes/from-archive) - CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) // Delete volume // (DELETE /volumes/{id}) DeleteVolume(w http.ResponseWriter, r *http.Request, id string) @@ -3176,12 +3045,6 @@ func (_ Unimplemented) CreateVolume(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -// Create volume from tar.gz archive -// (POST /volumes/from-archive) -func (_ Unimplemented) CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - // Delete volume // (DELETE /volumes/{id}) func (_ Unimplemented) DeleteVolume(w http.ResponseWriter, r *http.Request, id string) { @@ -3653,26 +3516,6 @@ func (siw *ServerInterfaceWrapper) CreateVolume(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// CreateVolumeFromArchive operation middleware -func (siw *ServerInterfaceWrapper) CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) { - - ctx := r.Context() - - ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) - - r = r.WithContext(ctx) - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateVolumeFromArchive(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - // DeleteVolume operation middleware func (siw *ServerInterfaceWrapper) DeleteVolume(w http.ResponseWriter, r *http.Request) { @@ -3896,9 +3739,6 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/volumes", wrapper.CreateVolume) }) - r.Group(func(r chi.Router) { - r.Post(options.BaseURL+"/volumes/from-archive", wrapper.CreateVolumeFromArchive) - }) r.Group(func(r chi.Router) { r.Delete(options.BaseURL+"/volumes/{id}", wrapper.DeleteVolume) }) @@ -4479,7 +4319,8 @@ func (response ListVolumes500JSONResponse) VisitListVolumesResponse(w http.Respo } type CreateVolumeRequestObject struct { - Body *CreateVolumeJSONRequestBody + JSONBody *CreateVolumeJSONRequestBody + MultipartBody *multipart.Reader } type CreateVolumeResponseObject interface { @@ -4513,62 +4354,18 @@ func (response CreateVolume401JSONResponse) VisitCreateVolumeResponse(w http.Res return json.NewEncoder(w).Encode(response) } -type CreateVolume500JSONResponse Error - -func (response CreateVolume500JSONResponse) VisitCreateVolumeResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(500) - - return json.NewEncoder(w).Encode(response) -} - -type CreateVolumeFromArchiveRequestObject struct { - Body *multipart.Reader -} - -type CreateVolumeFromArchiveResponseObject interface { - VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error -} - -type CreateVolumeFromArchive201JSONResponse Volume - -func (response CreateVolumeFromArchive201JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(201) - - return json.NewEncoder(w).Encode(response) -} - -type CreateVolumeFromArchive400JSONResponse Error - -func (response CreateVolumeFromArchive400JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(400) - - return json.NewEncoder(w).Encode(response) -} - -type CreateVolumeFromArchive401JSONResponse Error - -func (response CreateVolumeFromArchive401JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(401) - - return json.NewEncoder(w).Encode(response) -} - -type CreateVolumeFromArchive409JSONResponse Error +type CreateVolume409JSONResponse Error -func (response CreateVolumeFromArchive409JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) 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 CreateVolumeFromArchive500JSONResponse Error +type CreateVolume500JSONResponse Error -func (response CreateVolumeFromArchive500JSONResponse) VisitCreateVolumeFromArchiveResponse(w http.ResponseWriter) error { +func (response CreateVolume500JSONResponse) VisitCreateVolumeResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) @@ -4703,9 +4500,6 @@ type StrictServerInterface interface { // Create volume // (POST /volumes) CreateVolume(ctx context.Context, request CreateVolumeRequestObject) (CreateVolumeResponseObject, error) - // Create volume from tar.gz archive - // (POST /volumes/from-archive) - CreateVolumeFromArchive(ctx context.Context, request CreateVolumeFromArchiveRequestObject) (CreateVolumeFromArchiveResponseObject, error) // Delete volume // (DELETE /volumes/{id}) DeleteVolume(ctx context.Context, request DeleteVolumeRequestObject) (DeleteVolumeResponseObject, error) @@ -5149,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)) @@ -5176,37 +4981,6 @@ func (sh *strictHandler) CreateVolume(w http.ResponseWriter, r *http.Request) { } } -// CreateVolumeFromArchive operation middleware -func (sh *strictHandler) CreateVolumeFromArchive(w http.ResponseWriter, r *http.Request) { - var request CreateVolumeFromArchiveRequestObject - - if reader, err := r.MultipartReader(); err != nil { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err)) - return - } else { - request.Body = reader - } - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.CreateVolumeFromArchive(ctx, request.(CreateVolumeFromArchiveRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "CreateVolumeFromArchive") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(CreateVolumeFromArchiveResponseObject); ok { - if err := validResponse.VisitCreateVolumeFromArchiveResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} - // DeleteVolume operation middleware func (sh *strictHandler) DeleteVolume(w http.ResponseWriter, r *http.Request, id string) { var request DeleteVolumeRequestObject @@ -5262,71 +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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", + "So+fDp4Ogut31/8XAAD//1tiJLd5WAAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/openapi.yaml b/openapi.yaml index 432dd52a..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,44 +927,6 @@ paths: application/json: schema: $ref: "#/components/schemas/CreateVolumeRequest" - responses: - 201: - description: Volume created - content: - application/json: - schema: - $ref: "#/components/schemas/Volume" - 400: - description: Bad request - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - 401: - description: Unauthorized - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - 500: - description: Internal server error - content: - application/json: - schema: - $ref: "#/components/schemas/Error" - - /volumes/from-archive: - post: - summary: Create volume from tar.gz archive - description: | - Creates a new volume pre-populated with content from an uploaded tar.gz archive. - The archive is securely extracted with size limits to prevent tar bombs. - operationId: createVolumeFromArchive - security: - - bearerAuth: [] - requestBody: - required: true - content: multipart/form-data: schema: type: object @@ -993,7 +959,7 @@ paths: schema: $ref: "#/components/schemas/Volume" 400: - description: Bad request (invalid form data or archive too large) + description: Bad request (invalid data or archive too large) content: application/json: schema: