From 7190fbeed8a13f07ca8f008576a0c5d994158c6d Mon Sep 17 00:00:00 2001 From: Scott Crunkleton Date: Tue, 24 May 2016 17:13:36 -0700 Subject: [PATCH] Adding support for googlecompute startup scripts. - Startup scripts can be provided through the instance creation metadata field 'startup-script'. - Script log can be copied to a GCS location by setting the metadata field 'startup-script-log-dest'. Added Retry method to googlecompute package. Added GetSerialPortOutput to googlecompute Drivers. Added StepWaitInstanceStartup (and associated test) which waits for an instance startup-script to finish. Changed the instance service account to use the same service account as the one provided in the Packer config template. It was the project default service account. Tested googlecompute package with 'go test' and also performed builds with a startup script and without a startup script. --- builder/googlecompute/artifact.go | 10 +-- builder/googlecompute/builder.go | 9 +-- builder/googlecompute/common.go | 44 +++++++++++++ builder/googlecompute/common_test.go | 64 ++++++++++++++++++ builder/googlecompute/config.go | 9 +-- builder/googlecompute/driver.go | 41 +++++++----- builder/googlecompute/driver_gce.go | 65 +++++++++++++------ builder/googlecompute/driver_mock.go | 52 ++++++++++++--- builder/googlecompute/startup.go | 53 +++++++++++++++ builder/googlecompute/step_create_image.go | 5 +- .../googlecompute/step_create_image_test.go | 34 ++++++---- builder/googlecompute/step_create_instance.go | 55 ++++++++++------ builder/googlecompute/step_create_ssh_key.go | 2 + builder/googlecompute/step_instance_info.go | 1 + .../googlecompute/step_teardown_instance.go | 3 +- .../step_wait_instance_startup.go | 50 ++++++++++++++ .../step_wait_instance_startup_test.go | 38 +++++++++++ .../docs/builders/googlecompute.html.md | 43 +++++++++--- 18 files changed, 474 insertions(+), 104 deletions(-) create mode 100644 builder/googlecompute/common.go create mode 100644 builder/googlecompute/common_test.go create mode 100644 builder/googlecompute/startup.go create mode 100644 builder/googlecompute/step_wait_instance_startup.go create mode 100644 builder/googlecompute/step_wait_instance_startup_test.go diff --git a/builder/googlecompute/artifact.go b/builder/googlecompute/artifact.go index cb2ad88bb..128db103b 100644 --- a/builder/googlecompute/artifact.go +++ b/builder/googlecompute/artifact.go @@ -7,7 +7,7 @@ import ( // Artifact represents a GCE image as the result of a Packer build. type Artifact struct { - imageName string + image Image driver Driver } @@ -18,8 +18,8 @@ func (*Artifact) BuilderId() string { // Destroy destroys the GCE image represented by the artifact. func (a *Artifact) Destroy() error { - log.Printf("Destroying image: %s", a.imageName) - errCh := a.driver.DeleteImage(a.imageName) + log.Printf("Destroying image: %s", a.image.Name) + errCh := a.driver.DeleteImage(a.image.Name) return <-errCh } @@ -30,12 +30,12 @@ func (*Artifact) Files() []string { // Id returns the GCE image name. func (a *Artifact) Id() string { - return a.imageName + return a.image.Name } // String returns the string representation of the artifact. func (a *Artifact) String() string { - return fmt.Sprintf("A disk image was created: %v", a.imageName) + return fmt.Sprintf("A disk image was created: %v", a.image.Name) } func (a *Artifact) State(name string) interface{} { diff --git a/builder/googlecompute/builder.go b/builder/googlecompute/builder.go index 0f6e4bd2f..987672a90 100644 --- a/builder/googlecompute/builder.go +++ b/builder/googlecompute/builder.go @@ -67,6 +67,7 @@ func (b *Builder) Run(ui packer.Ui, hook packer.Hook, cache packer.Cache) (packe SSHConfig: sshConfig, }, new(common.StepProvision), + new(StepWaitInstanceStartup), new(StepTeardownInstance), new(StepCreateImage), } @@ -86,14 +87,14 @@ func (b *Builder) Run(ui packer.Ui, hook packer.Hook, cache packer.Cache) (packe if rawErr, ok := state.GetOk("error"); ok { return nil, rawErr.(error) } - if _, ok := state.GetOk("image_name"); !ok { - log.Println("Failed to find image_name in state. Bug?") + if _, ok := state.GetOk("image"); !ok { + log.Println("Failed to find image in state. Bug?") return nil, nil } artifact := &Artifact{ - imageName: state.Get("image_name").(string), - driver: driver, + image: state.Get("image").(Image), + driver: driver, } return artifact, nil } diff --git a/builder/googlecompute/common.go b/builder/googlecompute/common.go new file mode 100644 index 000000000..8f5520c16 --- /dev/null +++ b/builder/googlecompute/common.go @@ -0,0 +1,44 @@ +package googlecompute + +import ( + "fmt" + "math" + "time" +) + +var RetryExhaustedError error = fmt.Errorf("Function never succeeded in Retry") + +// Retry retries a function up to numTries times with exponential backoff. +// If numTries == 0, retry indefinitely. If interval == 0, Retry will not delay retrying and there will be +// no exponential backoff. If maxInterval == 0, maxInterval is set to +Infinity. +// Intervals are in seconds. +// Returns an error if initial > max intervals, if retries are exhausted, or if the passed function returns +// an error. +func Retry(initialInterval float64, maxInterval float64, numTries uint, function func() (bool, error)) error { + if maxInterval == 0 { + maxInterval = math.Inf(1) + } else if initialInterval < 0 || initialInterval > maxInterval { + return fmt.Errorf("Invalid retry intervals (negative or initial < max). Initial: %f, Max: %f.", initialInterval, maxInterval) + } + + var err error + done := false + interval := initialInterval + for i := uint(0); !done && (numTries == 0 || i < numTries); i++ { + done, err = function() + if err != nil { + return err + } + + if !done { + // Retry after delay. Calculate next delay. + time.Sleep(time.Duration(interval) * time.Second) + interval = math.Min(interval * 2, maxInterval) + } + } + + if !done { + return RetryExhaustedError + } + return nil +} diff --git a/builder/googlecompute/common_test.go b/builder/googlecompute/common_test.go new file mode 100644 index 000000000..caf9c2436 --- /dev/null +++ b/builder/googlecompute/common_test.go @@ -0,0 +1,64 @@ +package googlecompute + +import ( + "fmt" + "testing" +) + +func TestRetry(t *testing.T) { + numTries := uint(0) + // Test that a passing function only gets called once. + err := Retry(0, 0, 0, func() (bool, error) { + numTries++ + return true, nil + }) + if numTries != 1 { + t.Fatal("Passing function should not have been retried.") + } + if err != nil { + t.Fatalf("Passing function should not have returned a retry error. Error: %s", err) + } + + // Test that a failing function gets retried (once in this example). + numTries = 0 + results := []bool{false, true} + err = Retry(0, 0, 0, func() (bool, error) { + result := results[numTries] + numTries++ + return result, nil + }) + if numTries != 2 { + t.Fatalf("Retried function should have been tried twice. Tried %d times.", numTries) + } + if err != nil { + t.Fatalf("Successful retried function should not have returned a retry error. Error: %s", err) + } + + // Test that a function error gets returned, and the function does not get called again. + numTries = 0 + funcErr := fmt.Errorf("This function had an error!") + err = Retry(0, 0, 0, func() (bool, error) { + numTries++ + return false, funcErr + }) + if numTries != 1 { + t.Fatal("Errant function should not have been retried.") + } + if err != funcErr { + t.Fatalf("Errant function did not return the right error %s. Error: %s", funcErr, err) + } + + // Test when a function exhausts its retries. + numTries = 0 + expectedTries := uint(3) + err = Retry(0, 0, expectedTries, func() (bool, error) { + numTries++ + return false, nil + }) + if numTries != expectedTries { + t.Fatalf("Unsuccessul retry function should have been called %d times. Only called %d times.", expectedTries, numTries) + } + if err != RetryExhaustedError { + t.Fatalf("Unsuccessful retry function should have returned a retry exhausted error. Actual error: %s", err) + } +} \ No newline at end of file diff --git a/builder/googlecompute/config.go b/builder/googlecompute/config.go index e93347087..2072bd112 100644 --- a/builder/googlecompute/config.go +++ b/builder/googlecompute/config.go @@ -26,6 +26,7 @@ type Config struct { AccountFile string `mapstructure:"account_file"` ProjectId string `mapstructure:"project_id"` + Address string `mapstructure:"address"` DiskName string `mapstructure:"disk_name"` DiskSizeGb int64 `mapstructure:"disk_size"` DiskType string `mapstructure:"disk_type"` @@ -36,15 +37,15 @@ type Config struct { MachineType string `mapstructure:"machine_type"` Metadata map[string]string `mapstructure:"metadata"` Network string `mapstructure:"network"` - Subnetwork string `mapstructure:"subnetwork"` - Address string `mapstructure:"address"` Preemptible bool `mapstructure:"preemptible"` + RawStateTimeout string `mapstructure:"state_timeout"` + Region string `mapstructure:"region"` SourceImage string `mapstructure:"source_image"` SourceImageProjectId string `mapstructure:"source_image_project_id"` - RawStateTimeout string `mapstructure:"state_timeout"` + StartupScriptFile string `mapstructure:"startup_script_file"` + Subnetwork string `mapstructure:"subnetwork"` Tags []string `mapstructure:"tags"` UseInternalIP bool `mapstructure:"use_internal_ip"` - Region string `mapstructure:"region"` Zone string `mapstructure:"zone"` account accountFile diff --git a/builder/googlecompute/driver.go b/builder/googlecompute/driver.go index 13cd2809e..f1c6c931e 100644 --- a/builder/googlecompute/driver.go +++ b/builder/googlecompute/driver.go @@ -10,7 +10,7 @@ type Driver interface { // CreateImage creates an image from the given disk in Google Compute // Engine. - CreateImage(name, description, family, zone, disk string) <-chan error + CreateImage(name, description, family, zone, disk string) (<-chan Image, <-chan error) // DeleteImage deletes the image with the given name. DeleteImage(name string) <-chan error @@ -21,12 +21,15 @@ type Driver interface { // DeleteDisk deletes the disk with the given name. DeleteDisk(zone, name string) (<-chan error, error) - // GetNatIP gets the NAT IP address for the instance. - GetNatIP(zone, name string) (string, error) - // GetInternalIP gets the GCE-internal IP address for the instance. GetInternalIP(zone, name string) (string, error) + // GetNatIP gets the NAT IP address for the instance. + GetNatIP(zone, name string) (string, error) + + // GetSerialPortOutput gets the Serial Port contents for the instance. + GetSerialPortOutput(zone, name string) (string, error) + // RunInstance takes the given config and launches an instance. RunInstance(*InstanceConfig) (<-chan error, error) @@ -37,21 +40,23 @@ type Driver interface { type Image struct { Name string ProjectId string + SizeGb int64 } type InstanceConfig struct { - Description string - DiskSizeGb int64 - DiskType string - Image Image - MachineType string - Metadata map[string]string - Name string - Network string - Subnetwork string - Address string - Preemptible bool - Tags []string - Region string - Zone string + Address string + Description string + DiskSizeGb int64 + DiskType string + Image Image + MachineType string + Metadata map[string]string + Name string + Network string + Preemptible bool + Region string + ServiceAccountEmail string + Subnetwork string + Tags []string + Zone string } diff --git a/builder/googlecompute/driver_gce.go b/builder/googlecompute/driver_gce.go index 306ab1756..3b262d59e 100644 --- a/builder/googlecompute/driver_gce.go +++ b/builder/googlecompute/driver_gce.go @@ -5,7 +5,6 @@ import ( "log" "net/http" "runtime" - "time" "github.com/mitchellh/packer/packer" "github.com/mitchellh/packer/version" @@ -91,7 +90,7 @@ func (d *driverGCE) ImageExists(name string) bool { return err == nil } -func (d *driverGCE) CreateImage(name, description, family, zone, disk string) <-chan error { +func (d *driverGCE) CreateImage(name, description, family, zone, disk string) (<-chan Image, <-chan error) { image := &compute.Image{ Description: description, Name: name, @@ -100,15 +99,32 @@ func (d *driverGCE) CreateImage(name, description, family, zone, disk string) <- SourceType: "RAW", } + imageCh := make(chan Image, 1) errCh := make(chan error, 1) op, err := d.service.Images.Insert(d.projectId, image).Do() if err != nil { errCh <- err } else { - go waitForState(errCh, "DONE", d.refreshGlobalOp(op)) + go func() { + err = waitForState(errCh, "DONE", d.refreshGlobalOp(op)) + if err != nil { + close(imageCh) + } + image, err = d.getImage(name, d.projectId) + if err != nil { + close(imageCh) + errCh <- err + } + imageCh <- Image{ + Name: name, + ProjectId: d.projectId, + SizeGb: image.DiskSizeGb, + } + close(imageCh) + }() } - return errCh + return imageCh, errCh } func (d *driverGCE) DeleteImage(name string) <-chan error { @@ -181,6 +197,15 @@ func (d *driverGCE) GetInternalIP(zone, name string) (string, error) { return "", nil } +func (d *driverGCE) GetSerialPortOutput(zone, name string) (string, error) { + output, err := d.service.Instances.GetSerialPortOutput(d.projectId, zone, name).Do() + if err != nil { + return "", err + } + + return output.Contents, nil +} + func (d *driverGCE) RunInstance(c *InstanceConfig) (<-chan error, error) { // Get the zone d.ui.Message(fmt.Sprintf("Loading zone: %s", c.Zone)) @@ -191,7 +216,7 @@ func (d *driverGCE) RunInstance(c *InstanceConfig) (<-chan error, error) { // Get the image d.ui.Message(fmt.Sprintf("Loading image: %s in project %s", c.Image.Name, c.Image.ProjectId)) - image, err := d.getImage(c.Image) + image, err := d.getImage(c.Image.Name, c.Image.ProjectId) if err != nil { return nil, err } @@ -294,7 +319,7 @@ func (d *driverGCE) RunInstance(c *InstanceConfig) (<-chan error, error) { }, ServiceAccounts: []*compute.ServiceAccount{ &compute.ServiceAccount{ - Email: "default", + Email: c.ServiceAccountEmail, Scopes: []string{ "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/compute", @@ -324,17 +349,17 @@ func (d *driverGCE) WaitForInstance(state, zone, name string) <-chan error { return errCh } -func (d *driverGCE) getImage(img Image) (image *compute.Image, err error) { - projects := []string{img.ProjectId, "centos-cloud", "coreos-cloud", "debian-cloud", "google-containers", "opensuse-cloud", "rhel-cloud", "suse-cloud", "ubuntu-os-cloud", "windows-cloud"} +func (d *driverGCE) getImage(name, projectId string) (image *compute.Image, err error) { + projects := []string{projectId, "centos-cloud", "coreos-cloud", "debian-cloud", "google-containers", "opensuse-cloud", "rhel-cloud", "suse-cloud", "ubuntu-os-cloud", "windows-cloud"} for _, project := range projects { - image, err = d.service.Images.Get(project, img.Name).Do() + image, err = d.service.Images.Get(project, name).Do() if err == nil && image != nil && image.SelfLink != "" { return } image = nil } - err = fmt.Errorf("Image %s could not be found in any of these projects: %s", img.Name, projects) + err = fmt.Errorf("Image %s could not be found in any of these projects: %s", name, projects) return } @@ -396,18 +421,16 @@ type stateRefreshFunc func() (string, error) // waitForState will spin in a loop forever waiting for state to // reach a certain target. -func waitForState(errCh chan<- error, target string, refresh stateRefreshFunc) { - for { +func waitForState(errCh chan<- error, target string, refresh stateRefreshFunc) error { + err := Retry(2, 2, 0, func() (bool, error) { state, err := refresh() if err != nil { - errCh <- err - return + return false, err + } else if state == target { + return true, nil } - if state == target { - errCh <- nil - return - } - - time.Sleep(2 * time.Second) - } + return false, nil + }) + errCh <- err + return err } diff --git a/builder/googlecompute/driver_mock.go b/builder/googlecompute/driver_mock.go index 551671a91..90ab726c5 100644 --- a/builder/googlecompute/driver_mock.go +++ b/builder/googlecompute/driver_mock.go @@ -6,12 +6,15 @@ type DriverMock struct { ImageExistsName string ImageExistsResult bool - CreateImageName string - CreateImageDesc string - CreateImageFamily string - CreateImageZone string - CreateImageDisk string - CreateImageErrCh <-chan error + CreateImageName string + CreateImageDesc string + CreateImageFamily string + CreateImageZone string + CreateImageDisk string + CreateImageProjectId string + CreateImageSizeGb int64 + CreateImageErrCh <-chan error + CreateImageResultCh <-chan Image DeleteImageName string DeleteImageErrCh <-chan error @@ -35,6 +38,11 @@ type DriverMock struct { GetInternalIPName string GetInternalIPResult string GetInternalIPErr error + + GetSerialPortOutputZone string + GetSerialPortOutputName string + GetSerialPortOutputResult string + GetSerialPortOutputErr error RunInstanceConfig *InstanceConfig RunInstanceErrCh <-chan error @@ -51,21 +59,39 @@ func (d *DriverMock) ImageExists(name string) bool { return d.ImageExistsResult } -func (d *DriverMock) CreateImage(name, description, family, zone, disk string) <-chan error { +func (d *DriverMock) CreateImage(name, description, family, zone, disk string) (<-chan Image, <-chan error) { d.CreateImageName = name d.CreateImageDesc = description d.CreateImageFamily = family d.CreateImageZone = zone d.CreateImageDisk = disk + if d.CreateImageSizeGb == 0 { + d.CreateImageSizeGb = 10 + } + if d.CreateImageProjectId == "" { + d.CreateImageProjectId = "test" + } - resultCh := d.CreateImageErrCh + resultCh := d.CreateImageResultCh if resultCh == nil { - ch := make(chan error) + ch := make(chan Image, 1) + ch <- Image{ + Name: name, + ProjectId: d.CreateImageProjectId, + SizeGb: d.CreateImageSizeGb, + } close(ch) resultCh = ch } - return resultCh + errCh := d.CreateImageErrCh + if errCh == nil { + ch := make(chan error) + close(ch) + errCh = ch + } + + return resultCh, errCh } func (d *DriverMock) DeleteImage(name string) <-chan error { @@ -121,6 +147,12 @@ func (d *DriverMock) GetInternalIP(zone, name string) (string, error) { return d.GetInternalIPResult, d.GetInternalIPErr } +func (d *DriverMock) GetSerialPortOutput(zone, name string) (string, error) { + d.GetSerialPortOutputZone = zone + d.GetSerialPortOutputName = name + return d.GetSerialPortOutputResult, d.GetSerialPortOutputErr +} + func (d *DriverMock) RunInstance(c *InstanceConfig) (<-chan error, error) { d.RunInstanceConfig = c diff --git a/builder/googlecompute/startup.go b/builder/googlecompute/startup.go new file mode 100644 index 000000000..308e33e18 --- /dev/null +++ b/builder/googlecompute/startup.go @@ -0,0 +1,53 @@ +package googlecompute + +import ( + "encoding/base64" + "fmt" +) + +const StartupScriptStartLog string = "Packer startup script starting." +const StartupScriptDoneLog string = "Packer startup script done." +const StartupScriptKey string = "startup-script" +const StartupWrappedScriptKey string = "packer-wrapped-startup-script" + +// We have to encode StartupScriptDoneLog because we use it as a sentinel value to indicate +// that the user-provided startup script is done. If we pass StartupScriptDoneLog as-is, it +// will be printed early in the instance console log (before the startup script even runs; +// we print out instance creation metadata which contains this wrapper script). +var StartupScriptDoneLogBase64 string = base64.StdEncoding.EncodeToString([]byte(StartupScriptDoneLog)) + +var StartupScript string = fmt.Sprintf(`#!/bin/bash +echo %s +RETVAL=0 + +GetMetadata () { + echo "$(curl -f -H "Metadata-Flavor: Google" http://metadata/computeMetadata/v1/instance/attributes/$1 2> /dev/null)" +} + +STARTUPSCRIPT=$(GetMetadata %s) +STARTUPSCRIPTPATH=/packer-wrapped-startup-script +if [ -f "/var/log/startupscript.log" ]; then + STARTUPSCRIPTLOGPATH=/var/log/startupscript.log +else + STARTUPSCRIPTLOGPATH=/var/log/daemon.log +fi +STARTUPSCRIPTLOGDEST=$(GetMetadata startup-script-log-dest) + +if [[ ! -z $STARTUPSCRIPT ]]; then + echo "Executing user-provided startup script..." + echo "${STARTUPSCRIPT}" > ${STARTUPSCRIPTPATH} + chmod +x ${STARTUPSCRIPTPATH} + ${STARTUPSCRIPTPATH} + RETVAL=$? + + if [[ ! -z $STARTUPSCRIPTLOGDEST ]]; then + echo "Uploading user-provided startup script log to ${STARTUPSCRIPTLOGDEST}..." + gsutil -h "Content-Type:text/plain" cp ${STARTUPSCRIPTLOGPATH} ${STARTUPSCRIPTLOGDEST} + fi + + rm ${STARTUPSCRIPTPATH} +fi + +echo $(echo %s | base64 --decode) +exit $RETVAL +`, StartupScriptStartLog, StartupWrappedScriptKey, StartupScriptDoneLogBase64) diff --git a/builder/googlecompute/step_create_image.go b/builder/googlecompute/step_create_image.go index dbb690dd3..f7e531d43 100644 --- a/builder/googlecompute/step_create_image.go +++ b/builder/googlecompute/step_create_image.go @@ -23,7 +23,8 @@ func (s *StepCreateImage) Run(state multistep.StateBag) multistep.StepAction { ui := state.Get("ui").(packer.Ui) ui.Say("Creating image...") - errCh := driver.CreateImage(config.ImageName, config.ImageDescription, config.ImageFamily, config.Zone, config.DiskName) + + imageCh, errCh := driver.CreateImage(config.ImageName, config.ImageDescription, config.ImageFamily, config.Zone, config.DiskName) var err error select { case err = <-errCh: @@ -38,7 +39,7 @@ func (s *StepCreateImage) Run(state multistep.StateBag) multistep.StepAction { return multistep.ActionHalt } - state.Put("image_name", config.ImageName) + state.Put("image", <-imageCh) return multistep.ActionContinue } diff --git a/builder/googlecompute/step_create_image_test.go b/builder/googlecompute/step_create_image_test.go index 4358f10de..c956ec5e3 100644 --- a/builder/googlecompute/step_create_image_test.go +++ b/builder/googlecompute/step_create_image_test.go @@ -18,13 +18,35 @@ func TestStepCreateImage(t *testing.T) { config := state.Get("config").(*Config) driver := state.Get("driver").(*DriverMock) + driver.CreateImageProjectId = "createimage-project" + driver.CreateImageSizeGb = 100 // run the step if action := step.Run(state); action != multistep.ActionContinue { t.Fatalf("bad action: %#v", action) } + + uncastImage, ok := state.GetOk("image") + if !ok { + t.Fatal("should have image") + } + image, ok := uncastImage.(Image) + if !ok { + t.Fatal("image is not an Image") + } + + // Verify created Image results. + if image.Name != config.ImageName { + t.Fatalf("Created image name, %s, does not match config name, %s.", image.Name, config.ImageName) + } + if driver.CreateImageProjectId != image.ProjectId { + t.Fatalf("Created image project ID, %s, does not match driver project ID, %s.", image.ProjectId, driver.CreateImageProjectId) + } + if driver.CreateImageSizeGb != image.SizeGb { + t.Fatalf("Created image size, %d, does not match the expected test value, %d.", image.SizeGb, driver.CreateImageSizeGb) + } - // Verify state + // Verify proper args passed to driver.CreateImage. if driver.CreateImageName != config.ImageName { t.Fatalf("bad: %#v", driver.CreateImageName) } @@ -40,16 +62,6 @@ func TestStepCreateImage(t *testing.T) { if driver.CreateImageDisk != config.DiskName { t.Fatalf("bad: %#v", driver.CreateImageDisk) } - - nameRaw, ok := state.GetOk("image_name") - if !ok { - t.Fatal("should have name") - } - if name, ok := nameRaw.(string); !ok { - t.Fatal("name is not a string") - } else if name != config.ImageName { - t.Fatalf("bad name: %s", name) - } } func TestStepCreateImage_errorOnChannel(t *testing.T) { diff --git a/builder/googlecompute/step_create_instance.go b/builder/googlecompute/step_create_instance.go index 45320d158..cf997a920 100644 --- a/builder/googlecompute/step_create_instance.go +++ b/builder/googlecompute/step_create_instance.go @@ -3,6 +3,7 @@ package googlecompute import ( "errors" "fmt" + "io/ioutil" "time" "github.com/mitchellh/multistep" @@ -22,23 +23,34 @@ func (config *Config) getImage() Image { return Image{Name: config.SourceImage, ProjectId: project} } -func (config *Config) getInstanceMetadata(sshPublicKey string) map[string]string { +func (config *Config) getInstanceMetadata(sshPublicKey string) (map[string]string, error) { instanceMetadata := make(map[string]string) + var err error - // Copy metadata from config + // Copy metadata from config. for k, v := range config.Metadata { instanceMetadata[k] = v } - // Merge any existing ssh keys with our public key + // Merge any existing ssh keys with our public key. sshMetaKey := "sshKeys" sshKeys := fmt.Sprintf("%s:%s", config.Comm.SSHUsername, sshPublicKey) if confSshKeys, exists := instanceMetadata[sshMetaKey]; exists { sshKeys = fmt.Sprintf("%s\n%s", sshKeys, confSshKeys) } instanceMetadata[sshMetaKey] = sshKeys + + // Wrap any startup script with our own startup script. + if config.StartupScriptFile != "" { + var content []byte + content, err = ioutil.ReadFile(config.StartupScriptFile) + instanceMetadata[StartupWrappedScriptKey] = string(content) + } else if wrappedStartupScript, exists := instanceMetadata[StartupScriptKey]; exists { + instanceMetadata[StartupWrappedScriptKey] = wrappedStartupScript + } + instanceMetadata[StartupScriptKey] = StartupScript - return instanceMetadata + return instanceMetadata, err } // Run executes the Packer build step that creates a GCE instance. @@ -51,21 +63,26 @@ func (s *StepCreateInstance) Run(state multistep.StateBag) multistep.StepAction ui.Say("Creating instance...") name := config.InstanceName - errCh, err := driver.RunInstance(&InstanceConfig{ - Description: "New instance created by Packer", - DiskSizeGb: config.DiskSizeGb, - DiskType: config.DiskType, - Image: config.getImage(), - MachineType: config.MachineType, - Metadata: config.getInstanceMetadata(sshPublicKey), - Name: name, - Network: config.Network, - Subnetwork: config.Subnetwork, - Address: config.Address, - Preemptible: config.Preemptible, - Tags: config.Tags, - Region: config.Region, - Zone: config.Zone, + var errCh <-chan error + var err error + var metadata map[string]string + metadata, err = config.getInstanceMetadata(sshPublicKey) + errCh, err = driver.RunInstance(&InstanceConfig{ + Address: config.Address, + Description: "New instance created by Packer", + DiskSizeGb: config.DiskSizeGb, + DiskType: config.DiskType, + Image: config.getImage(), + MachineType: config.MachineType, + Metadata: metadata, + Name: name, + Network: config.Network, + Preemptible: config.Preemptible, + Region: config.Region, + ServiceAccountEmail: config.account.ClientEmail, + Subnetwork: config.Subnetwork, + Tags: config.Tags, + Zone: config.Zone, }) if err == nil { diff --git a/builder/googlecompute/step_create_ssh_key.go b/builder/googlecompute/step_create_ssh_key.go index 521e6c3d6..4c4894fba 100644 --- a/builder/googlecompute/step_create_ssh_key.go +++ b/builder/googlecompute/step_create_ssh_key.go @@ -20,6 +20,8 @@ type StepCreateSSHKey struct { } // Run executes the Packer build step that generates SSH key pairs. +// The key pairs are added to the multistep state as "ssh_private_key" and +// "ssh_public_key". func (s *StepCreateSSHKey) Run(state multistep.StateBag) multistep.StepAction { ui := state.Get("ui").(packer.Ui) diff --git a/builder/googlecompute/step_instance_info.go b/builder/googlecompute/step_instance_info.go index 92f382f06..511906206 100644 --- a/builder/googlecompute/step_instance_info.go +++ b/builder/googlecompute/step_instance_info.go @@ -17,6 +17,7 @@ type StepInstanceInfo struct { } // Run executes the Packer build step that gathers GCE instance info. +// This adds "instance_ip" to the multistep state. func (s *StepInstanceInfo) Run(state multistep.StateBag) multistep.StepAction { config := state.Get("config").(*Config) driver := state.Get("driver").(Driver) diff --git a/builder/googlecompute/step_teardown_instance.go b/builder/googlecompute/step_teardown_instance.go index b623d24fd..42ad83a5d 100644 --- a/builder/googlecompute/step_teardown_instance.go +++ b/builder/googlecompute/step_teardown_instance.go @@ -27,6 +27,8 @@ func (s *StepTeardownInstance) Run(state multistep.StateBag) multistep.StepActio } ui.Say("Deleting instance...") + instanceLog, _ := driver.GetSerialPortOutput(config.Zone, name) + state.Put("instance_log", instanceLog) errCh, err := driver.DeleteInstance(config.Zone, name) if err == nil { select { @@ -43,7 +45,6 @@ func (s *StepTeardownInstance) Run(state multistep.StateBag) multistep.StepActio "Error: %s", name, err)) return multistep.ActionHalt } - ui.Message("Instance has been deleted!") state.Put("instance_name", "") diff --git a/builder/googlecompute/step_wait_instance_startup.go b/builder/googlecompute/step_wait_instance_startup.go new file mode 100644 index 000000000..3fa162732 --- /dev/null +++ b/builder/googlecompute/step_wait_instance_startup.go @@ -0,0 +1,50 @@ +package googlecompute + +import( + "fmt" + "strings" + + "github.com/mitchellh/multistep" + "github.com/mitchellh/packer/packer" +) + +type StepWaitInstanceStartup int + +// Run reads the instance serial port output and looks for the log entry indicating the startup script finished. +func (s *StepWaitInstanceStartup) Run(state multistep.StateBag) multistep.StepAction { + config := state.Get("config").(*Config) + driver := state.Get("driver").(Driver) + ui := state.Get("ui").(packer.Ui) + instanceName := state.Get("instance_name").(string) + + ui.Say("Waiting for any running startup script to finish...") + + // Keep checking the serial port output to see if the startup script is done. + err := Retry(10, 60, 0, func() (bool, error) { + output, err := driver.GetSerialPortOutput(config.Zone, instanceName) + + if err != nil { + err := fmt.Errorf("Error getting serial port output: %s", err) + return false, err + } + + done := strings.Contains(output, StartupScriptDoneLog) + if !done { + ui.Say("Startup script not finished yet. Waiting...") + } + + return done, nil + }) + + if err != nil { + err := fmt.Errorf("Error waiting for startup script to finish: %s", err) + state.Put("error", err) + ui.Error(err.Error()) + return multistep.ActionHalt + } + ui.Say("Startup script, if any, has finished running.") + return multistep.ActionContinue +} + +// Cleanup. +func (s *StepWaitInstanceStartup) Cleanup(state multistep.StateBag) {} diff --git a/builder/googlecompute/step_wait_instance_startup_test.go b/builder/googlecompute/step_wait_instance_startup_test.go new file mode 100644 index 000000000..da6d896d3 --- /dev/null +++ b/builder/googlecompute/step_wait_instance_startup_test.go @@ -0,0 +1,38 @@ +package googlecompute + +import ( + "github.com/mitchellh/multistep" + "testing" +) + +func TestStepWaitInstanceStartup(t *testing.T) { + state := testState(t) + step := new(StepWaitInstanceStartup) + config := state.Get("config").(*Config) + driver := state.Get("driver").(*DriverMock) + + testZone := "test-zone" + testInstanceName := "test-instance-name" + + config.Zone = testZone + state.Put("instance_name", testInstanceName) + // The done log triggers step completion. + driver.GetSerialPortOutputResult = StartupScriptDoneLog + + // Run the step. + if action := step.Run(state); action != multistep.ActionContinue { + t.Fatalf("StepWaitInstanceStartup did not return a Continue action: %#v", action) + } + + // Check that GetSerialPortOutput was called properly. + if driver.GetSerialPortOutputZone != testZone { + t.Fatalf( + "GetSerialPortOutput wrong zone. Expected: %s, Actual: %s", driver.GetSerialPortOutputZone, + testZone) + } + if driver.GetSerialPortOutputName != testInstanceName { + t.Fatalf( + "GetSerialPortOutput wrong instance name. Expected: %s, Actual: %s", driver.GetSerialPortOutputName, + testInstanceName) + } +} \ No newline at end of file diff --git a/website/source/docs/builders/googlecompute.html.md b/website/source/docs/builders/googlecompute.html.md index c4842b976..c944e9d4c 100644 --- a/website/source/docs/builders/googlecompute.html.md +++ b/website/source/docs/builders/googlecompute.html.md @@ -1,8 +1,9 @@ --- description: | The `googlecompute` Packer builder is able to create images for use with Google - Compute Engine (GCE) based on existing images. Google Compute Engine doesn't - allow the creation of images from scratch. + Compute Engine (GCE) based on existing images. Building GCE images from scratch + is not possible from Packer at this time. For building images from scratch, please see + [Building GCE Images from Scratch](https://cloud.google.com/compute/docs/tutorials/building-images). layout: docs page_title: Google Compute Builder ... @@ -14,9 +15,9 @@ Type: `googlecompute` The `googlecompute` Packer builder is able to create [images](https://developers.google.com/compute/docs/images) for use with [Google Compute Engine](https://cloud.google.com/products/compute-engine)(GCE) based on -existing images. Google Compute Engine doesn't allow the creation of images from -scratch. - +existing images. Building GCE images from scratch is not possible from Packer at +this time. For building images from scratch, please see +[Building GCE Images from Scratch](https://cloud.google.com/compute/docs/tutorials/building-images). ## Authentication Authenticating with Google Cloud services requires at most one JSON file, called @@ -76,10 +77,10 @@ straightforwarded, it is documented here. ## Basic Example Below is a fully functioning example. It doesn't do anything useful, since no -provisioners are defined, but it will effectively repackage an existing GCE -image. The account_file is obtained in the previous section. If it parses as -JSON it is assumed to be the file itself, otherwise it is assumed to be -the path to the file containing the JSON. +provisioners or startup-script metadata are defined, but it will effectively +repackage an existing GCE image. The account_file is obtained in the previous +section. If it parses as JSON it is assumed to be the file itself, otherwise it +is assumed to be the path to the file containing the JSON. ``` {.javascript} { @@ -150,6 +151,9 @@ builder. - `region` (string) - The region in which to launch the instance. Defaults to to the region hosting the specified `zone`. +- `startup_script_file` (string) - The filepath to a startup script to run on + the VM from which the image will be made. + - `state_timeout` (string) - The time to wait for instance state changes. Defaults to `"5m"`. @@ -163,6 +167,27 @@ builder. - `use_internal_ip` (boolean) - If true, use the instance's internal IP instead of its external IP during building. + +## Startup Scripts + +Startup scripts can be a powerful tool for configuring the instance from which the image is made. +The builder will wait for a startup script to terminate. A startup script can be provided via the +`startup_script_file` or 'startup-script' instance creation `metadata` field. Therefore, the build +time will vary depending on the duration of the startup script. If `startup_script_file` is set, +the 'startup-script' `metadata` field will be overwritten. In other words,`startup_script_file` +takes precedence. + +The builder does not check for a pass/fail/error signal from the startup script, at this time. Until +such support is implemented, startup scripts should be robust, as an image will still be built even +when a startup script fails. + +### Windows +Startup scripts do not work on Windows builds, at this time. + +### Logging +Startup script logs can be copied to a Google Cloud Storage (GCS) location specified via the +'startup-script-log-dest' instance creation `metadata` field. The GCS location must be writeable by +the credentials provided in the builder config's `account_file`. ## Gotchas