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.
This commit is contained in:
Scott Crunkleton 2016-05-24 17:13:36 -07:00
parent 37fe764727
commit 7190fbeed8
18 changed files with 474 additions and 104 deletions

View File

@ -7,7 +7,7 @@ import (
// Artifact represents a GCE image as the result of a Packer build. // Artifact represents a GCE image as the result of a Packer build.
type Artifact struct { type Artifact struct {
imageName string image Image
driver Driver driver Driver
} }
@ -18,8 +18,8 @@ func (*Artifact) BuilderId() string {
// Destroy destroys the GCE image represented by the artifact. // Destroy destroys the GCE image represented by the artifact.
func (a *Artifact) Destroy() error { func (a *Artifact) Destroy() error {
log.Printf("Destroying image: %s", a.imageName) log.Printf("Destroying image: %s", a.image.Name)
errCh := a.driver.DeleteImage(a.imageName) errCh := a.driver.DeleteImage(a.image.Name)
return <-errCh return <-errCh
} }
@ -30,12 +30,12 @@ func (*Artifact) Files() []string {
// Id returns the GCE image name. // Id returns the GCE image name.
func (a *Artifact) Id() string { func (a *Artifact) Id() string {
return a.imageName return a.image.Name
} }
// String returns the string representation of the artifact. // String returns the string representation of the artifact.
func (a *Artifact) String() string { 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{} { func (a *Artifact) State(name string) interface{} {

View File

@ -67,6 +67,7 @@ func (b *Builder) Run(ui packer.Ui, hook packer.Hook, cache packer.Cache) (packe
SSHConfig: sshConfig, SSHConfig: sshConfig,
}, },
new(common.StepProvision), new(common.StepProvision),
new(StepWaitInstanceStartup),
new(StepTeardownInstance), new(StepTeardownInstance),
new(StepCreateImage), new(StepCreateImage),
} }
@ -86,13 +87,13 @@ func (b *Builder) Run(ui packer.Ui, hook packer.Hook, cache packer.Cache) (packe
if rawErr, ok := state.GetOk("error"); ok { if rawErr, ok := state.GetOk("error"); ok {
return nil, rawErr.(error) return nil, rawErr.(error)
} }
if _, ok := state.GetOk("image_name"); !ok { if _, ok := state.GetOk("image"); !ok {
log.Println("Failed to find image_name in state. Bug?") log.Println("Failed to find image in state. Bug?")
return nil, nil return nil, nil
} }
artifact := &Artifact{ artifact := &Artifact{
imageName: state.Get("image_name").(string), image: state.Get("image").(Image),
driver: driver, driver: driver,
} }
return artifact, nil return artifact, nil

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -26,6 +26,7 @@ type Config struct {
AccountFile string `mapstructure:"account_file"` AccountFile string `mapstructure:"account_file"`
ProjectId string `mapstructure:"project_id"` ProjectId string `mapstructure:"project_id"`
Address string `mapstructure:"address"`
DiskName string `mapstructure:"disk_name"` DiskName string `mapstructure:"disk_name"`
DiskSizeGb int64 `mapstructure:"disk_size"` DiskSizeGb int64 `mapstructure:"disk_size"`
DiskType string `mapstructure:"disk_type"` DiskType string `mapstructure:"disk_type"`
@ -36,15 +37,15 @@ type Config struct {
MachineType string `mapstructure:"machine_type"` MachineType string `mapstructure:"machine_type"`
Metadata map[string]string `mapstructure:"metadata"` Metadata map[string]string `mapstructure:"metadata"`
Network string `mapstructure:"network"` Network string `mapstructure:"network"`
Subnetwork string `mapstructure:"subnetwork"`
Address string `mapstructure:"address"`
Preemptible bool `mapstructure:"preemptible"` Preemptible bool `mapstructure:"preemptible"`
RawStateTimeout string `mapstructure:"state_timeout"`
Region string `mapstructure:"region"`
SourceImage string `mapstructure:"source_image"` SourceImage string `mapstructure:"source_image"`
SourceImageProjectId string `mapstructure:"source_image_project_id"` 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"` Tags []string `mapstructure:"tags"`
UseInternalIP bool `mapstructure:"use_internal_ip"` UseInternalIP bool `mapstructure:"use_internal_ip"`
Region string `mapstructure:"region"`
Zone string `mapstructure:"zone"` Zone string `mapstructure:"zone"`
account accountFile account accountFile

View File

@ -10,7 +10,7 @@ type Driver interface {
// CreateImage creates an image from the given disk in Google Compute // CreateImage creates an image from the given disk in Google Compute
// Engine. // 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 deletes the image with the given name.
DeleteImage(name string) <-chan error DeleteImage(name string) <-chan error
@ -21,11 +21,14 @@ type Driver interface {
// DeleteDisk deletes the disk with the given name. // DeleteDisk deletes the disk with the given name.
DeleteDisk(zone, name string) (<-chan error, error) DeleteDisk(zone, name string) (<-chan error, 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 gets the NAT IP address for the instance.
GetNatIP(zone, name string) (string, error) GetNatIP(zone, name string) (string, error)
// GetInternalIP gets the GCE-internal IP address for the instance. // GetSerialPortOutput gets the Serial Port contents for the instance.
GetInternalIP(zone, name string) (string, error) GetSerialPortOutput(zone, name string) (string, error)
// RunInstance takes the given config and launches an instance. // RunInstance takes the given config and launches an instance.
RunInstance(*InstanceConfig) (<-chan error, error) RunInstance(*InstanceConfig) (<-chan error, error)
@ -37,9 +40,11 @@ type Driver interface {
type Image struct { type Image struct {
Name string Name string
ProjectId string ProjectId string
SizeGb int64
} }
type InstanceConfig struct { type InstanceConfig struct {
Address string
Description string Description string
DiskSizeGb int64 DiskSizeGb int64
DiskType string DiskType string
@ -48,10 +53,10 @@ type InstanceConfig struct {
Metadata map[string]string Metadata map[string]string
Name string Name string
Network string Network string
Subnetwork string
Address string
Preemptible bool Preemptible bool
Tags []string
Region string Region string
ServiceAccountEmail string
Subnetwork string
Tags []string
Zone string Zone string
} }

View File

@ -5,7 +5,6 @@ import (
"log" "log"
"net/http" "net/http"
"runtime" "runtime"
"time"
"github.com/mitchellh/packer/packer" "github.com/mitchellh/packer/packer"
"github.com/mitchellh/packer/version" "github.com/mitchellh/packer/version"
@ -91,7 +90,7 @@ func (d *driverGCE) ImageExists(name string) bool {
return err == nil 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{ image := &compute.Image{
Description: description, Description: description,
Name: name, Name: name,
@ -100,15 +99,32 @@ func (d *driverGCE) CreateImage(name, description, family, zone, disk string) <-
SourceType: "RAW", SourceType: "RAW",
} }
imageCh := make(chan Image, 1)
errCh := make(chan error, 1) errCh := make(chan error, 1)
op, err := d.service.Images.Insert(d.projectId, image).Do() op, err := d.service.Images.Insert(d.projectId, image).Do()
if err != nil { if err != nil {
errCh <- err errCh <- err
} else { } 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 { func (d *driverGCE) DeleteImage(name string) <-chan error {
@ -181,6 +197,15 @@ func (d *driverGCE) GetInternalIP(zone, name string) (string, error) {
return "", nil 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) { func (d *driverGCE) RunInstance(c *InstanceConfig) (<-chan error, error) {
// Get the zone // Get the zone
d.ui.Message(fmt.Sprintf("Loading zone: %s", c.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 // Get the image
d.ui.Message(fmt.Sprintf("Loading image: %s in project %s", c.Image.Name, c.Image.ProjectId)) 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 { if err != nil {
return nil, err return nil, err
} }
@ -294,7 +319,7 @@ func (d *driverGCE) RunInstance(c *InstanceConfig) (<-chan error, error) {
}, },
ServiceAccounts: []*compute.ServiceAccount{ ServiceAccounts: []*compute.ServiceAccount{
&compute.ServiceAccount{ &compute.ServiceAccount{
Email: "default", Email: c.ServiceAccountEmail,
Scopes: []string{ Scopes: []string{
"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/compute", "https://www.googleapis.com/auth/compute",
@ -324,17 +349,17 @@ func (d *driverGCE) WaitForInstance(state, zone, name string) <-chan error {
return errCh return errCh
} }
func (d *driverGCE) getImage(img Image) (image *compute.Image, err error) { func (d *driverGCE) getImage(name, projectId string) (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"} 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 { 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 != "" { if err == nil && image != nil && image.SelfLink != "" {
return return
} }
image = nil 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 return
} }
@ -396,18 +421,16 @@ type stateRefreshFunc func() (string, error)
// waitForState will spin in a loop forever waiting for state to // waitForState will spin in a loop forever waiting for state to
// reach a certain target. // reach a certain target.
func waitForState(errCh chan<- error, target string, refresh stateRefreshFunc) { func waitForState(errCh chan<- error, target string, refresh stateRefreshFunc) error {
for { err := Retry(2, 2, 0, func() (bool, error) {
state, err := refresh() state, err := refresh()
if err != nil { if err != nil {
return false, err
} else if state == target {
return true, nil
}
return false, nil
})
errCh <- err errCh <- err
return return err
}
if state == target {
errCh <- nil
return
}
time.Sleep(2 * time.Second)
}
} }

View File

@ -11,7 +11,10 @@ type DriverMock struct {
CreateImageFamily string CreateImageFamily string
CreateImageZone string CreateImageZone string
CreateImageDisk string CreateImageDisk string
CreateImageProjectId string
CreateImageSizeGb int64
CreateImageErrCh <-chan error CreateImageErrCh <-chan error
CreateImageResultCh <-chan Image
DeleteImageName string DeleteImageName string
DeleteImageErrCh <-chan error DeleteImageErrCh <-chan error
@ -36,6 +39,11 @@ type DriverMock struct {
GetInternalIPResult string GetInternalIPResult string
GetInternalIPErr error GetInternalIPErr error
GetSerialPortOutputZone string
GetSerialPortOutputName string
GetSerialPortOutputResult string
GetSerialPortOutputErr error
RunInstanceConfig *InstanceConfig RunInstanceConfig *InstanceConfig
RunInstanceErrCh <-chan error RunInstanceErrCh <-chan error
RunInstanceErr error RunInstanceErr error
@ -51,21 +59,39 @@ func (d *DriverMock) ImageExists(name string) bool {
return d.ImageExistsResult 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.CreateImageName = name
d.CreateImageDesc = description d.CreateImageDesc = description
d.CreateImageFamily = family d.CreateImageFamily = family
d.CreateImageZone = zone d.CreateImageZone = zone
d.CreateImageDisk = disk 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 { if resultCh == nil {
ch := make(chan error) ch := make(chan Image, 1)
ch <- Image{
Name: name,
ProjectId: d.CreateImageProjectId,
SizeGb: d.CreateImageSizeGb,
}
close(ch) close(ch)
resultCh = 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 { 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 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) { func (d *DriverMock) RunInstance(c *InstanceConfig) (<-chan error, error) {
d.RunInstanceConfig = c d.RunInstanceConfig = c

View File

@ -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)

View File

@ -23,7 +23,8 @@ func (s *StepCreateImage) Run(state multistep.StateBag) multistep.StepAction {
ui := state.Get("ui").(packer.Ui) ui := state.Get("ui").(packer.Ui)
ui.Say("Creating image...") 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 var err error
select { select {
case err = <-errCh: case err = <-errCh:
@ -38,7 +39,7 @@ func (s *StepCreateImage) Run(state multistep.StateBag) multistep.StepAction {
return multistep.ActionHalt return multistep.ActionHalt
} }
state.Put("image_name", config.ImageName) state.Put("image", <-imageCh)
return multistep.ActionContinue return multistep.ActionContinue
} }

View File

@ -18,13 +18,35 @@ func TestStepCreateImage(t *testing.T) {
config := state.Get("config").(*Config) config := state.Get("config").(*Config)
driver := state.Get("driver").(*DriverMock) driver := state.Get("driver").(*DriverMock)
driver.CreateImageProjectId = "createimage-project"
driver.CreateImageSizeGb = 100
// run the step // run the step
if action := step.Run(state); action != multistep.ActionContinue { if action := step.Run(state); action != multistep.ActionContinue {
t.Fatalf("bad action: %#v", action) t.Fatalf("bad action: %#v", action)
} }
// Verify state 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 proper args passed to driver.CreateImage.
if driver.CreateImageName != config.ImageName { if driver.CreateImageName != config.ImageName {
t.Fatalf("bad: %#v", driver.CreateImageName) t.Fatalf("bad: %#v", driver.CreateImageName)
} }
@ -40,16 +62,6 @@ func TestStepCreateImage(t *testing.T) {
if driver.CreateImageDisk != config.DiskName { if driver.CreateImageDisk != config.DiskName {
t.Fatalf("bad: %#v", driver.CreateImageDisk) 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) { func TestStepCreateImage_errorOnChannel(t *testing.T) {

View File

@ -3,6 +3,7 @@ package googlecompute
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"time" "time"
"github.com/mitchellh/multistep" "github.com/mitchellh/multistep"
@ -22,15 +23,16 @@ func (config *Config) getImage() Image {
return Image{Name: config.SourceImage, ProjectId: project} 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) instanceMetadata := make(map[string]string)
var err error
// Copy metadata from config // Copy metadata from config.
for k, v := range config.Metadata { for k, v := range config.Metadata {
instanceMetadata[k] = v instanceMetadata[k] = v
} }
// Merge any existing ssh keys with our public key // Merge any existing ssh keys with our public key.
sshMetaKey := "sshKeys" sshMetaKey := "sshKeys"
sshKeys := fmt.Sprintf("%s:%s", config.Comm.SSHUsername, sshPublicKey) sshKeys := fmt.Sprintf("%s:%s", config.Comm.SSHUsername, sshPublicKey)
if confSshKeys, exists := instanceMetadata[sshMetaKey]; exists { if confSshKeys, exists := instanceMetadata[sshMetaKey]; exists {
@ -38,7 +40,17 @@ func (config *Config) getInstanceMetadata(sshPublicKey string) map[string]string
} }
instanceMetadata[sshMetaKey] = sshKeys instanceMetadata[sshMetaKey] = sshKeys
return instanceMetadata // 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, err
} }
// Run executes the Packer build step that creates a GCE instance. // Run executes the Packer build step that creates a GCE instance.
@ -51,20 +63,25 @@ func (s *StepCreateInstance) Run(state multistep.StateBag) multistep.StepAction
ui.Say("Creating instance...") ui.Say("Creating instance...")
name := config.InstanceName name := config.InstanceName
errCh, err := driver.RunInstance(&InstanceConfig{ 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", Description: "New instance created by Packer",
DiskSizeGb: config.DiskSizeGb, DiskSizeGb: config.DiskSizeGb,
DiskType: config.DiskType, DiskType: config.DiskType,
Image: config.getImage(), Image: config.getImage(),
MachineType: config.MachineType, MachineType: config.MachineType,
Metadata: config.getInstanceMetadata(sshPublicKey), Metadata: metadata,
Name: name, Name: name,
Network: config.Network, Network: config.Network,
Subnetwork: config.Subnetwork,
Address: config.Address,
Preemptible: config.Preemptible, Preemptible: config.Preemptible,
Tags: config.Tags,
Region: config.Region, Region: config.Region,
ServiceAccountEmail: config.account.ClientEmail,
Subnetwork: config.Subnetwork,
Tags: config.Tags,
Zone: config.Zone, Zone: config.Zone,
}) })

View File

@ -20,6 +20,8 @@ type StepCreateSSHKey struct {
} }
// Run executes the Packer build step that generates SSH key pairs. // 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 { func (s *StepCreateSSHKey) Run(state multistep.StateBag) multistep.StepAction {
ui := state.Get("ui").(packer.Ui) ui := state.Get("ui").(packer.Ui)

View File

@ -17,6 +17,7 @@ type StepInstanceInfo struct {
} }
// Run executes the Packer build step that gathers GCE instance info. // 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 { func (s *StepInstanceInfo) Run(state multistep.StateBag) multistep.StepAction {
config := state.Get("config").(*Config) config := state.Get("config").(*Config)
driver := state.Get("driver").(Driver) driver := state.Get("driver").(Driver)

View File

@ -27,6 +27,8 @@ func (s *StepTeardownInstance) Run(state multistep.StateBag) multistep.StepActio
} }
ui.Say("Deleting instance...") ui.Say("Deleting instance...")
instanceLog, _ := driver.GetSerialPortOutput(config.Zone, name)
state.Put("instance_log", instanceLog)
errCh, err := driver.DeleteInstance(config.Zone, name) errCh, err := driver.DeleteInstance(config.Zone, name)
if err == nil { if err == nil {
select { select {
@ -43,7 +45,6 @@ func (s *StepTeardownInstance) Run(state multistep.StateBag) multistep.StepActio
"Error: %s", name, err)) "Error: %s", name, err))
return multistep.ActionHalt return multistep.ActionHalt
} }
ui.Message("Instance has been deleted!") ui.Message("Instance has been deleted!")
state.Put("instance_name", "") state.Put("instance_name", "")

View File

@ -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) {}

View File

@ -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)
}
}

View File

@ -1,8 +1,9 @@
--- ---
description: | description: |
The `googlecompute` Packer builder is able to create images for use with Google 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 Compute Engine (GCE) based on existing images. Building GCE images from scratch
allow the creation of 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 layout: docs
page_title: Google Compute Builder page_title: Google Compute Builder
... ...
@ -14,9 +15,9 @@ Type: `googlecompute`
The `googlecompute` Packer builder is able to create The `googlecompute` Packer builder is able to create
[images](https://developers.google.com/compute/docs/images) for use with [Google [images](https://developers.google.com/compute/docs/images) for use with [Google
Compute Engine](https://cloud.google.com/products/compute-engine)(GCE) based on 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 existing images. Building GCE images from scratch is not possible from Packer at
scratch. this time. For building images from scratch, please see
[Building GCE Images from Scratch](https://cloud.google.com/compute/docs/tutorials/building-images).
## Authentication ## Authentication
Authenticating with Google Cloud services requires at most one JSON file, called Authenticating with Google Cloud services requires at most one JSON file, called
@ -76,10 +77,10 @@ straightforwarded, it is documented here.
## Basic Example ## Basic Example
Below is a fully functioning example. It doesn't do anything useful, since no 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 provisioners or startup-script metadata are defined, but it will effectively
image. The account_file is obtained in the previous section. If it parses as repackage an existing GCE image. The account_file is obtained in the previous
JSON it is assumed to be the file itself, otherwise it is assumed to be section. If it parses as JSON it is assumed to be the file itself, otherwise it
the path to the file containing the JSON. is assumed to be the path to the file containing the JSON.
``` {.javascript} ``` {.javascript}
{ {
@ -150,6 +151,9 @@ builder.
- `region` (string) - The region in which to launch the instance. Defaults to - `region` (string) - The region in which to launch the instance. Defaults to
to the region hosting the specified `zone`. 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. - `state_timeout` (string) - The time to wait for instance state changes.
Defaults to `"5m"`. Defaults to `"5m"`.
@ -164,6 +168,27 @@ builder.
- `use_internal_ip` (boolean) - If true, use the instance's internal IP - `use_internal_ip` (boolean) - If true, use the instance's internal IP
instead of its external IP during building. 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 ## Gotchas
Centos and recent Debian images have root ssh access disabled by default. Set `ssh_username` to Centos and recent Debian images have root ssh access disabled by default. Set `ssh_username` to