From ac9267402cdfa123ae2fddc8638877acd5795603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20J=2E=20Gajda?= Date: Mon, 15 Dec 2025 08:30:38 +0100 Subject: [PATCH 1/3] feat(storage/import): add support for additional content types and --content-type flag - Support IANA-registered content types: application/gzip, application/x-xz, application/x-tar, application/x-bzip2, application/x-7z-compressed, application/zip, and application/octet-stream - Add --content-type flag to allow explicit content type specification - Auto-detect content type from file extension with fallback to octet-stream - Add getContentType() function for extensible content type mapping - Add comprehensive unit tests for content type detection Fixes #581 --- internal/commands/storage/import.go | 41 +++++++++++++++++++----- internal/commands/storage/import_test.go | 27 ++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/internal/commands/storage/import.go b/internal/commands/storage/import.go index 28dc4024c..379c4681c 100644 --- a/internal/commands/storage/import.go +++ b/internal/commands/storage/import.go @@ -60,6 +60,7 @@ type importCommand struct { existingStorageUUIDOrName string noWait config.OptionalBoolean wait config.OptionalBoolean + contentType string createParams createParams @@ -71,6 +72,7 @@ func (s *importCommand) InitCommand() { flagSet := &pflag.FlagSet{} flagSet.StringVar(&s.sourceLocation, "source-location", "", "Location of the source of the import. Can be a file or a URL.") flagSet.StringVar(&s.existingStorageUUIDOrName, "storage", "", "Import to an existing storage. Storage must be large enough and must be undetached or the server where the storage is attached must be in shutdown state.") + flagSet.StringVar(&s.contentType, "content-type", "", "Content type of the file being imported. If not specified, it will be automatically detected based on file extension. Supported types: application/gzip, application/x-xz, application/x-tar, application/x-bzip2, application/x-7z-compressed, application/zip, application/octet-stream") config.AddToggleFlag(flagSet, &s.noWait, "no-wait", false, "When importing from remote url, do not wait until the import finishes or storage is in online state. If set, command will exit after import process has been initialized.") config.AddToggleFlag(flagSet, &s.wait, "wait", false, "Wait for storage to be in online state before returning.") applyCreateFlags(flagSet, &s.createParams, defaultCreateParams) @@ -206,7 +208,7 @@ func (s *importCommand) ExecuteWithoutArguments(exec commands.Executor) (output. if err != nil { return commands.HandleError(exec, msg, fmt.Errorf("cannot open local file: %w", err)) } - go importLocalFile(exec, storageToImportTo.UUID, sourceFile, statusChan) + go importLocalFile(exec, storageToImportTo.UUID, sourceFile, s.contentType, statusChan) } // import has been triggered, read updates from the process @@ -351,19 +353,42 @@ func pollStorageImportStatus(exec commands.Executor, uuid string, statusChan cha } } -func importLocalFile(exec commands.Executor, uuid string, file *os.File, statusChan chan<- storageImportStatus) { +func getContentType(filename string) string { + // Map file extensions to their IANA-registered content types + // Based on UpCloud Storage Import API documentation + contentTypes := map[string]string{ + ".gz": "application/gzip", + ".xz": "application/x-xz", + ".iso": "application/octet-stream", + ".img": "application/octet-stream", + ".raw": "application/octet-stream", + ".qcow2": "application/octet-stream", + ".tar": "application/x-tar", + ".bz2": "application/x-bzip2", + ".7z": "application/x-7z-compressed", + ".zip": "application/zip", + } + + ext := filepath.Ext(filename) + if contentType, exists := contentTypes[ext]; exists { + return contentType + } + + // Default to octet-stream for unknown types + return "application/octet-stream" +} + +func importLocalFile(exec commands.Executor, uuid string, file *os.File, userContentType string, statusChan chan<- storageImportStatus) { // make sure we close the channel when exiting import defer close(statusChan) chDone := make(chan storageImportStatus) reader := &readerCounter{source: file} // figure out content type - contentType := "application/octet-stream" - switch filepath.Ext(file.Name()) { - case ".gz": - contentType = "application/gzip" - case ".xz": - contentType = "application/x-xz" + // use user-provided content type if specified, otherwise auto-detect + contentType := userContentType + if contentType == "" { + contentType = getContentType(file.Name()) } go func() { diff --git a/internal/commands/storage/import_test.go b/internal/commands/storage/import_test.go index 9efd0323b..8d0746de3 100644 --- a/internal/commands/storage/import_test.go +++ b/internal/commands/storage/import_test.go @@ -164,6 +164,33 @@ func TestImportCommand(t *testing.T) { } } +func TestGetContentType(t *testing.T) { + tests := []struct { + filename string + expected string + }{ + {"image.iso", "application/octet-stream"}, + {"image.img", "application/octet-stream"}, + {"image.raw", "application/octet-stream"}, + {"image.qcow2", "application/octet-stream"}, + {"archive.gz", "application/gzip"}, + {"archive.xz", "application/x-xz"}, + {"archive.tar", "application/x-tar"}, + {"archive.bz2", "application/x-bzip2"}, + {"archive.7z", "application/x-7z-compressed"}, + {"archive.zip", "application/zip"}, + {"unknown.bin", "application/octet-stream"}, + {"noextension", "application/octet-stream"}, + } + + for _, test := range tests { + t.Run(test.filename, func(t *testing.T) { + result := getContentType(test.filename) + assert.Equal(t, test.expected, result) + }) + } +} + func TestParseSource(t *testing.T) { for _, test := range []struct { name string From 5f652e282f217334f9655503b4a47b071a4f6b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20J=2E=20Gajda?= Date: Mon, 15 Dec 2025 08:47:12 +0100 Subject: [PATCH 2/3] refactor(storage/import): extract content type mapping to package level - Move supportedContentTypes map to package level for reusability - Add getSupportedExtensionsText() to list all supported file extensions - Add getSupportedContentTypesText() to list all supported content types - Update --source-location help to show supported extensions dynamically - Update --content-type help to show supported types dynamically - Simplify getContentType() to use the package-level map - Help text now automatically reflects all supported formats --- internal/commands/storage/import.go | 88 +++++++++++++++++++----- internal/commands/storage/import_test.go | 4 +- 2 files changed, 72 insertions(+), 20 deletions(-) diff --git a/internal/commands/storage/import.go b/internal/commands/storage/import.go index 379c4681c..88598c561 100644 --- a/internal/commands/storage/import.go +++ b/internal/commands/storage/import.go @@ -25,6 +25,73 @@ import ( "github.com/spf13/pflag" ) +// supportedContentTypes maps file extensions to IANA-registered content types +// based on UpCloud Storage Import API documentation +var supportedContentTypes = map[string]string{ + ".gz": "application/gzip", + ".xz": "application/x-xz", + ".iso": "application/octet-stream", + ".img": "application/octet-stream", + ".raw": "application/octet-stream", + ".qcow2": "application/octet-stream", + ".tar": "application/x-tar", + ".bz2": "application/x-bzip2", + ".7z": "application/x-7z-compressed", + ".zip": "application/zip", +} + +// getSupportedExtensionsText returns a formatted string of supported file extensions +func getSupportedExtensionsText() string { + var extensions []string + for ext := range supportedContentTypes { + extensions = append(extensions, ext) + } + // Sort for consistent output + for i := 0; i < len(extensions); i++ { + for j := i + 1; j < len(extensions); j++ { + if extensions[i] > extensions[j] { + extensions[i], extensions[j] = extensions[j], extensions[i] + } + } + } + var result string + for i, ext := range extensions { + if i > 0 { + result += ", " + } + result += ext + } + return result +} + +// getSupportedContentTypesText returns a formatted string of supported content types +func getSupportedContentTypesText() string { + seen := make(map[string]bool) + var types []string + for _, ct := range supportedContentTypes { + if !seen[ct] { + types = append(types, ct) + seen[ct] = true + } + } + // Sort for consistent output + for i := 0; i < len(types); i++ { + for j := i + 1; j < len(types); j++ { + if types[i] > types[j] { + types[i], types[j] = types[j], types[i] + } + } + } + var result string + for i, ct := range types { + if i > 0 { + result += ", " + } + result += ct + } + return result +} + // ImportCommand creates the "storage import" command func ImportCommand() commands.Command { return &importCommand{ @@ -70,9 +137,9 @@ type importCommand struct { // InitCommand implements Command.InitCommand func (s *importCommand) InitCommand() { flagSet := &pflag.FlagSet{} - flagSet.StringVar(&s.sourceLocation, "source-location", "", "Location of the source of the import. Can be a file or a URL.") + flagSet.StringVar(&s.sourceLocation, "source-location", "", fmt.Sprintf("Location of the source of the import. Can be a file or a URL. Supported file extensions: %s", getSupportedExtensionsText())) flagSet.StringVar(&s.existingStorageUUIDOrName, "storage", "", "Import to an existing storage. Storage must be large enough and must be undetached or the server where the storage is attached must be in shutdown state.") - flagSet.StringVar(&s.contentType, "content-type", "", "Content type of the file being imported. If not specified, it will be automatically detected based on file extension. Supported types: application/gzip, application/x-xz, application/x-tar, application/x-bzip2, application/x-7z-compressed, application/zip, application/octet-stream") + flagSet.StringVar(&s.contentType, "content-type", "", fmt.Sprintf("Content type of the file being imported. If not specified, it will be automatically detected based on file extension. Supported types: %s", getSupportedContentTypesText())) config.AddToggleFlag(flagSet, &s.noWait, "no-wait", false, "When importing from remote url, do not wait until the import finishes or storage is in online state. If set, command will exit after import process has been initialized.") config.AddToggleFlag(flagSet, &s.wait, "wait", false, "Wait for storage to be in online state before returning.") applyCreateFlags(flagSet, &s.createParams, defaultCreateParams) @@ -354,23 +421,8 @@ func pollStorageImportStatus(exec commands.Executor, uuid string, statusChan cha } func getContentType(filename string) string { - // Map file extensions to their IANA-registered content types - // Based on UpCloud Storage Import API documentation - contentTypes := map[string]string{ - ".gz": "application/gzip", - ".xz": "application/x-xz", - ".iso": "application/octet-stream", - ".img": "application/octet-stream", - ".raw": "application/octet-stream", - ".qcow2": "application/octet-stream", - ".tar": "application/x-tar", - ".bz2": "application/x-bzip2", - ".7z": "application/x-7z-compressed", - ".zip": "application/zip", - } - ext := filepath.Ext(filename) - if contentType, exists := contentTypes[ext]; exists { + if contentType, exists := supportedContentTypes[ext]; exists { return contentType } diff --git a/internal/commands/storage/import_test.go b/internal/commands/storage/import_test.go index 8d0746de3..cf0b14103 100644 --- a/internal/commands/storage/import_test.go +++ b/internal/commands/storage/import_test.go @@ -166,8 +166,8 @@ func TestImportCommand(t *testing.T) { func TestGetContentType(t *testing.T) { tests := []struct { - filename string - expected string + filename string + expected string }{ {"image.iso", "application/octet-stream"}, {"image.img", "application/octet-stream"}, From 4113edab2d81781df705c4b0473483dd034515a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20J=2E=20Gajda?= Date: Thu, 1 Jan 2026 02:57:17 +0100 Subject: [PATCH 3/3] fix: replace string concatenation with strings.Builder for efficiency The golangci-lint modernize linter detected inefficient string concatenation in loops. Using strings.Builder is more efficient as it avoids creating multiple intermediate string objects. --- internal/commands/storage/import.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/commands/storage/import.go b/internal/commands/storage/import.go index 88598c561..06ccc2862 100644 --- a/internal/commands/storage/import.go +++ b/internal/commands/storage/import.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "sync/atomic" "time" @@ -54,14 +55,14 @@ func getSupportedExtensionsText() string { } } } - var result string + var result strings.Builder for i, ext := range extensions { if i > 0 { - result += ", " + result.WriteString(", ") } - result += ext + result.WriteString(ext) } - return result + return result.String() } // getSupportedContentTypesText returns a formatted string of supported content types @@ -82,14 +83,14 @@ func getSupportedContentTypesText() string { } } } - var result string + var result strings.Builder for i, ct := range types { if i > 0 { - result += ", " + result.WriteString(", ") } - result += ct + result.WriteString(ct) } - return result + return result.String() } // ImportCommand creates the "storage import" command