From 54057b7b491a3e335bce3f021a8861cb451b7f87 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 11 Jun 2013 20:00:30 -0700 Subject: [PATCH] builder/virtualbox: download ISO --- builder/virtualbox/builder.go | 52 ++++++++ builder/virtualbox/builder_test.go | 75 ++++++++++- builder/virtualbox/step_download_iso.go | 157 ++++++++++++++++++++++++ 3 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 builder/virtualbox/step_download_iso.go diff --git a/builder/virtualbox/builder.go b/builder/virtualbox/builder.go index ec882ef0c..4b50b2ab5 100644 --- a/builder/virtualbox/builder.go +++ b/builder/virtualbox/builder.go @@ -1,12 +1,16 @@ package virtualbox import ( + "errors" "fmt" "github.com/mitchellh/mapstructure" "github.com/mitchellh/multistep" "github.com/mitchellh/packer/packer" "log" + "net/url" + "os" "os/exec" + "strings" ) const BuilderId = "mitchellh.virtualbox" @@ -19,6 +23,8 @@ type Builder struct { type config struct { GuestOSType string `mapstructure:"guest_os_type"` + ISOMD5 string `mapstructure:"iso_md5"` + ISOUrl string `mapstructure:"iso_url"` OutputDir string `mapstructure:"output_directory"` VMName string `mapstructure:"vm_name"` } @@ -43,6 +49,51 @@ func (b *Builder) Prepare(raw interface{}) error { errs := make([]error, 0) + if b.config.ISOMD5 == "" { + errs = append(errs, errors.New("Due to large file sizes, an iso_md5 is required")) + } else { + b.config.ISOMD5 = strings.ToLower(b.config.ISOMD5) + } + + if b.config.ISOUrl == "" { + errs = append(errs, errors.New("An iso_url must be specified.")) + } else { + url, err := url.Parse(b.config.ISOUrl) + if err != nil { + errs = append(errs, fmt.Errorf("iso_url is not a valid URL: %s", err)) + } else { + if url.Scheme == "" { + url.Scheme = "file" + } + + if url.Scheme == "file" { + if _, err := os.Stat(b.config.ISOUrl); err != nil { + errs = append(errs, fmt.Errorf("iso_url points to bad file: %s", err)) + } + } else { + supportedSchemes := []string{"file", "http", "https"} + scheme := strings.ToLower(url.Scheme) + + found := false + for _, supported := range supportedSchemes { + if scheme == supported { + found = true + break + } + } + + if !found { + errs = append(errs, fmt.Errorf("Unsupported URL scheme in iso_url: %s", scheme)) + } + } + } + + if len(errs) == 0 { + // Put the URL back together since we may have modified it + b.config.ISOUrl = url.String() + } + } + b.driver, err = b.newDriver() if err != nil { errs = append(errs, fmt.Errorf("Failed creating VirtualBox driver: %s", err)) @@ -57,6 +108,7 @@ func (b *Builder) Prepare(raw interface{}) error { func (b *Builder) Run(ui packer.Ui, hook packer.Hook, cache packer.Cache) packer.Artifact { steps := []multistep.Step{ + new(stepDownloadISO), new(stepPrepareOutputDir), new(stepSuppressMessages), new(stepCreateVM), diff --git a/builder/virtualbox/builder_test.go b/builder/virtualbox/builder_test.go index 143490e09..4049cfe7f 100644 --- a/builder/virtualbox/builder_test.go +++ b/builder/virtualbox/builder_test.go @@ -2,11 +2,16 @@ package virtualbox import ( "github.com/mitchellh/packer/packer" + "io/ioutil" + "os" "testing" ) func testConfig() map[string]interface{} { - return map[string]interface{}{} + return map[string]interface{}{ + "iso_md5": "foo", + "iso_url": "http://www.google.com/", + } } func TestBuilder_ImplementsBuilder(t *testing.T) { @@ -37,3 +42,71 @@ func TestBuilderPrepare_Defaults(t *testing.T) { t.Errorf("bad vm name: %s", b.config.VMName) } } + +func TestBuilderPrepare_ISOMD5(t *testing.T) { + var b Builder + config := testConfig() + + // Test bad + config["iso_md5"] = "" + err := b.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + // Test good + config["iso_md5"] = "FOo" + err = b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.ISOMD5 != "foo" { + t.Fatalf("should've lowercased: %s", b.config.ISOMD5) + } +} + +func TestBuilderPrepare_ISOUrl(t *testing.T) { + var b Builder + config := testConfig() + + config["iso_url"] = "" + err := b.Prepare(config) + if err == nil { + t.Fatal("should have error") + } + + config["iso_url"] = "i/am/a/file/that/doesnt/exist" + err = b.Prepare(config) + if err == nil { + t.Error("should have error") + } + + config["iso_url"] = "file:i/am/a/file/that/doesnt/exist" + err = b.Prepare(config) + if err == nil { + t.Error("should have error") + } + + config["iso_url"] = "http://www.packer.io" + err = b.Prepare(config) + if err != nil { + t.Errorf("should not have error: %s", err) + } + + tf, err := ioutil.TempFile("", "packer") + if err != nil { + t.Fatalf("error tempfile: %s", err) + } + defer os.Remove(tf.Name()) + + config["iso_url"] = tf.Name() + err = b.Prepare(config) + if err != nil { + t.Fatalf("should not have error: %s", err) + } + + if b.config.ISOUrl != "file://"+tf.Name() { + t.Fatalf("iso_url should be modified: %s", b.config.ISOUrl) + } +} diff --git a/builder/virtualbox/step_download_iso.go b/builder/virtualbox/step_download_iso.go new file mode 100644 index 000000000..f7491eab9 --- /dev/null +++ b/builder/virtualbox/step_download_iso.go @@ -0,0 +1,157 @@ +package virtualbox + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// This step downloads the ISO specified. +// +// Uses: +// cache packer.Cache +// config *config +// ui packer.Ui +// +// Produces: +// iso_path string +type stepDownloadISO struct{} + +func (s stepDownloadISO) Run(state map[string]interface{}) multistep.StepAction { + cache := state["cache"].(packer.Cache) + config := state["config"].(*config) + ui := state["ui"].(packer.Ui) + + log.Printf("Acquiring lock to download the ISO.") + cachePath := cache.Lock(config.ISOUrl) + defer cache.Unlock(config.ISOUrl) + + err := s.checkMD5(cachePath, config.ISOMD5) + haveFile := err == nil + if err != nil { + if !os.IsNotExist(err) { + ui.Say(fmt.Sprintf("Error validating MD5 of ISO: %s", err)) + return multistep.ActionHalt + } + } + + if !haveFile { + url, err := url.Parse(config.ISOUrl) + if err != nil { + ui.Error(fmt.Sprintf("Error parsing iso_url: %s", err)) + return multistep.ActionHalt + } + + // Start the download in a goroutine so that we cancel it and such. + var progress uint + downloadComplete := make(chan bool, 1) + go func() { + ui.Say("Copying or downloading ISO. Progress will be shown periodically.") + cachePath, err = s.downloadUrl(cachePath, url, &progress) + downloadComplete <- true + }() + + progressTimer := time.NewTicker(15 * time.Second) + defer progressTimer.Stop() + + DownloadWaitLoop: + for { + select { + case <-downloadComplete: + log.Println("Download of ISO completed.") + break DownloadWaitLoop + case <-progressTimer.C: + ui.Say(fmt.Sprintf("Download progress: %d%%", progress)) + case <-time.After(1 * time.Second): + if _, ok := state[multistep.StateCancelled]; ok { + ui.Say("Interrupt received. Cancelling download...") + return multistep.ActionHalt + } + } + } + + if err != nil { + ui.Error(fmt.Sprintf("Error downloading ISO: %s", err)) + return multistep.ActionHalt + } + + if err = s.checkMD5(cachePath, config.ISOMD5); err != nil { + ui.Say(fmt.Sprintf("Error validating MD5 of ISO: %s", err)) + return multistep.ActionHalt + } + } + + log.Printf("Path to ISO on disk: %s", cachePath) + state["iso_path"] = cachePath + + return multistep.ActionContinue +} + +func (stepDownloadISO) Cleanup(map[string]interface{}) {} + +func (stepDownloadISO) checkMD5(path string, expected string) error { + f, err := os.Open(path) + if err != nil { + return err + } + + hash := md5.New() + io.Copy(hash, f) + result := strings.ToLower(hex.EncodeToString(hash.Sum(nil))) + if result != expected { + return fmt.Errorf("result != expected: %s != %s", result, expected) + } + + return nil +} + +func (stepDownloadISO) downloadUrl(path string, url *url.URL, progress *uint) (string, error) { + if url.Scheme == "file" { + // If it is just a file URL, then we already have the ISO + return url.Path, nil + } + + // Otherwise, it is an HTTP URL, and we must download it. + f, err := os.Create(path) + if err != nil { + return "", err + } + defer f.Close() + + log.Printf("Beginning download of ISO: %s", url.String()) + resp, err := http.Get(url.String()) + if err != nil { + return "", err + } + + var buffer [4096]byte + var totalRead int64 + for { + n, err := resp.Body.Read(buffer[:]) + if err != nil && err != io.EOF { + return "", err + } + + totalRead += int64(n) + *progress = uint((float64(totalRead) / float64(resp.ContentLength)) * 100) + + if _, werr := f.Write(buffer[:n]); werr != nil { + return "", werr + } + + if err == io.EOF { + break + } + } + + return path, nil +}