diff --git a/cmd/start.go b/cmd/start.go index 1907ce9e..02a83164 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -22,6 +22,7 @@ import ( "github.com/abiosoft/colima/environment/container/incus" "github.com/abiosoft/colima/environment/container/kubernetes" "github.com/abiosoft/colima/util" + "github.com/abiosoft/colima/util/downloader" "github.com/abiosoft/colima/util/osutil" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -106,6 +107,15 @@ Run 'colima template' to set the default configurations or 'colima start --edit' } } + // validate and set downloader if flag is specified (takes precedence over env var) + if cmd.Flag("downloader").Changed { + normalized, err := downloader.ValidateDownloader(startCmdArgs.Flags.Downloader) + if err != nil { + return err + } + downloader.SetDownloader(normalized) + } + return nil }, } @@ -141,8 +151,9 @@ var startCmdArgs struct { DNSHosts []string Foreground bool SaveConfig bool - LegacyCPU int // for backward compatibility + LegacyCPU int // for backward compatibility Template bool + Downloader string // downloader to use (native, curl) } } @@ -255,6 +266,9 @@ func init() { // dns startCmd.Flags().IPSliceVarP(&startCmdArgs.Network.DNSResolvers, "dns", "n", nil, "DNS resolvers for the VM") startCmd.Flags().StringSliceVar(&startCmdArgs.Flags.DNSHosts, "dns-host", nil, "custom DNS names to provide to resolver") + + // download options + startCmd.Flags().StringVar(&startCmdArgs.Flags.Downloader, "downloader", downloader.DownloaderNative, "downloader to use (native, curl)") } func dnsHostsFromFlag(hosts []string) map[string]string { diff --git a/util/downloader/curl.go b/util/downloader/curl.go new file mode 100644 index 00000000..47b638a2 --- /dev/null +++ b/util/downloader/curl.go @@ -0,0 +1,93 @@ +package downloader + +import ( + "fmt" + "os" + "os/exec" + "path" + "strings" + + "github.com/abiosoft/colima/util/osutil" + "github.com/abiosoft/colima/util/terminal" +) + +const ( + // DownloaderNative uses Go's native HTTP client + DownloaderNative = "native" + // DownloaderCurl uses the curl command (honors .curlrc) + DownloaderCurl = "curl" + + envDownloader = "COLIMA_DOWNLOADER" +) + +// ValidateDownloader validates the downloader value (case-insensitive). +// Returns the normalized value or an error if invalid. +func ValidateDownloader(v string) (string, error) { + switch strings.ToLower(v) { + case DownloaderNative: + return DownloaderNative, nil + case DownloaderCurl: + return DownloaderCurl, nil + default: + return "", fmt.Errorf("invalid downloader %q: must be one of %s, %s", v, DownloaderNative, DownloaderCurl) + } +} + +// Downloader returns the configured downloader type. +// Flag takes precedence over environment variable when explicitly set. +func Downloader() string { + if downloaderOverride != nil { + return *downloaderOverride + } + if v := osutil.EnvVar(envDownloader).Val(); v != "" { + // normalize env var value + if normalized, err := ValidateDownloader(v); err == nil { + return normalized + } + } + return DownloaderNative +} + +// UseCurl returns true if curl should be used for downloads. +func UseCurl() bool { + return Downloader() == DownloaderCurl +} + +// downloaderOverride is set by the --downloader flag (nil means not set) +var downloaderOverride *string + +// SetDownloader sets the downloader override (called from start command when flag is explicitly set). +// The value should be validated before calling this function. +func SetDownloader(v string) { + downloaderOverride = &v +} + +// curlDownloader handles downloads using the curl command +type curlDownloader struct{} + +// downloadFile downloads a file using curl +func (c curlDownloader) downloadFile(r Request, destPath string) error { + // check if curl is available + if _, err := exec.LookPath("curl"); err != nil { + return fmt.Errorf("curl not found in PATH: %w", err) + } + + args := []string{ + "-fSL", // fail on HTTP errors, show errors, follow redirects + "-C", "-", // resume if possible (auto-detect offset) + "--progress-bar", // show progress bar + "-o", destPath, // output file + r.URL, + } + + cmd := exec.Command("curl", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("curl download failed for '%s': %w", path.Base(r.URL), err) + } + + terminal.ClearLine() + return nil +} diff --git a/util/downloader/download.go b/util/downloader/download.go index c358d9ab..d57166ce 100644 --- a/util/downloader/download.go +++ b/util/downloader/download.go @@ -82,6 +82,40 @@ func (d downloader) downloadFile(r Request) (err error) { return fmt.Errorf("error preparing cache dir: %w", err) } + // use curl if enabled (supports .curlrc for proxy auth, SSPI, etc.) + if UseCurl() { + if err := d.downloadWithCurl(r, cacheDownloadingFilename); err != nil { + return err + } + } else { + if err := d.downloadWithHTTP(r, cacheDownloadingFilename); err != nil { + return err + } + } + + // validate download if SHA is present + if r.SHA != nil { + if err := r.SHA.validateDownload(r.URL, cacheDownloadingFilename); err != nil { + // move file to allow subsequent re-download + _ = os.Rename(cacheDownloadingFilename, cacheDownloadingFilename+".invalid") + return fmt.Errorf("error validating SHA sum for '%s': %w", path.Base(r.URL), err) + } + } + + // move completed download to final location + if err := os.Rename(cacheDownloadingFilename, CacheFilename(r.URL)); err != nil { + return fmt.Errorf("error finalizing download: %w", err) + } + + return nil +} + +func (d downloader) downloadWithCurl(r Request, destPath string) error { + curl := curlDownloader{} + return curl.downloadFile(r, destPath) +} + +func (d downloader) downloadWithHTTP(r Request, destPath string) error { // check for existing partial download and resume info var resumeInfo ResumeInfo resumeInfoPath := d.resumeInfoPath(r.URL) @@ -91,7 +125,7 @@ func (d downloader) downloadFile(r Request) (err error) { // get existing file size for resume var existingSize int64 - if stat, err := os.Stat(cacheDownloadingFilename); err == nil { + if stat, err := os.Stat(destPath); err == nil { existingSize = stat.Size() } @@ -111,7 +145,7 @@ func (d downloader) downloadFile(r Request) (err error) { // download the file result, err := client.Download(ctx, DownloadOptions{ URL: finalURL, - DestPath: cacheDownloadingFilename, + DestPath: destPath, ExpectedETag: resumeInfo.ETag, ResumeFromByte: existingSize, ShowProgress: true, @@ -127,20 +161,6 @@ func (d downloader) downloadFile(r Request) (err error) { // clean up resume info on successful download _ = os.Remove(resumeInfoPath) - // validate download if SHA is present - if r.SHA != nil { - if err := r.SHA.validateDownload(r.URL, cacheDownloadingFilename); err != nil { - // move file to allow subsequent re-download - _ = os.Rename(cacheDownloadingFilename, cacheDownloadingFilename+".invalid") - return fmt.Errorf("error validating SHA sum for '%s': %w", path.Base(r.URL), err) - } - } - - // move completed download to final location - if err := os.Rename(cacheDownloadingFilename, CacheFilename(r.URL)); err != nil { - return fmt.Errorf("error finalizing download: %w", err) - } - return nil }