2013-12-13 00:38:34 -05:00
|
|
|
package googlecompute
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
2013-12-13 00:52:20 -05:00
|
|
|
"log"
|
2013-12-13 00:38:34 -05:00
|
|
|
"net/http"
|
2015-04-17 15:12:39 -04:00
|
|
|
"runtime"
|
2013-12-13 00:38:34 -05:00
|
|
|
|
2014-12-09 11:42:33 -05:00
|
|
|
"github.com/mitchellh/packer/packer"
|
2016-05-05 16:10:55 -04:00
|
|
|
"github.com/mitchellh/packer/version"
|
2014-12-21 14:22:24 -05:00
|
|
|
|
2015-04-17 15:12:39 -04:00
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"golang.org/x/oauth2/google"
|
|
|
|
"golang.org/x/oauth2/jwt"
|
|
|
|
"google.golang.org/api/compute/v1"
|
2015-12-24 10:19:29 -05:00
|
|
|
"strings"
|
2013-12-13 00:38:34 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
// driverGCE is a Driver implementation that actually talks to GCE.
|
|
|
|
// Create an instance using NewDriverGCE.
|
|
|
|
type driverGCE struct {
|
|
|
|
projectId string
|
|
|
|
service *compute.Service
|
|
|
|
ui packer.Ui
|
|
|
|
}
|
|
|
|
|
2014-11-17 13:06:22 -05:00
|
|
|
var DriverScopes = []string{"https://www.googleapis.com/auth/compute", "https://www.googleapis.com/auth/devstorage.full_control"}
|
2013-12-13 00:38:34 -05:00
|
|
|
|
2016-07-07 17:50:46 -04:00
|
|
|
func NewDriverGCE(ui packer.Ui, p string, a *AccountFile) (Driver, error) {
|
2014-11-17 13:06:22 -05:00
|
|
|
var err error
|
|
|
|
|
2015-04-17 15:12:39 -04:00
|
|
|
var client *http.Client
|
|
|
|
|
2014-11-17 13:06:22 -05:00
|
|
|
// Auth with AccountFile first if provided
|
|
|
|
if a.PrivateKey != "" {
|
|
|
|
log.Printf("[INFO] Requesting Google token via AccountFile...")
|
|
|
|
log.Printf("[INFO] -- Email: %s", a.ClientEmail)
|
|
|
|
log.Printf("[INFO] -- Scopes: %s", DriverScopes)
|
|
|
|
log.Printf("[INFO] -- Private Key Length: %d", len(a.PrivateKey))
|
|
|
|
|
2015-04-17 15:12:39 -04:00
|
|
|
conf := jwt.Config{
|
|
|
|
Email: a.ClientEmail,
|
|
|
|
PrivateKey: []byte(a.PrivateKey),
|
|
|
|
Scopes: DriverScopes,
|
|
|
|
TokenURL: "https://accounts.google.com/o/oauth2/token",
|
|
|
|
}
|
|
|
|
|
|
|
|
// Initiate an http.Client. The following GET request will be
|
|
|
|
// authorized and authenticated on the behalf of
|
|
|
|
// your service account.
|
|
|
|
client = conf.Client(oauth2.NoContext)
|
2014-11-17 13:06:22 -05:00
|
|
|
} else {
|
|
|
|
log.Printf("[INFO] Requesting Google token via GCE Service Role...")
|
2015-04-17 15:12:39 -04:00
|
|
|
client = &http.Client{
|
|
|
|
Transport: &oauth2.Transport{
|
|
|
|
// Fetch from Google Compute Engine's metadata server to retrieve
|
|
|
|
// an access token for the provided account.
|
|
|
|
// If no account is specified, "default" is used.
|
|
|
|
Source: google.ComputeTokenSource(""),
|
|
|
|
},
|
|
|
|
}
|
2013-12-13 00:38:34 -05:00
|
|
|
}
|
|
|
|
|
2014-11-17 13:06:22 -05:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2013-12-13 00:38:34 -05:00
|
|
|
}
|
|
|
|
|
2015-04-17 15:12:39 -04:00
|
|
|
log.Printf("[INFO] Instantiating GCE client...")
|
|
|
|
service, err := compute.New(client)
|
|
|
|
// Set UserAgent
|
2016-05-05 16:10:55 -04:00
|
|
|
versionString := version.FormattedVersion()
|
2015-04-17 15:12:39 -04:00
|
|
|
service.UserAgent = fmt.Sprintf(
|
|
|
|
"(%s %s) Packer/%s", runtime.GOOS, runtime.GOARCH, versionString)
|
|
|
|
|
2013-12-13 00:38:34 -05:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return &driverGCE{
|
2014-09-05 12:47:20 -04:00
|
|
|
projectId: p,
|
2013-12-13 00:38:34 -05:00
|
|
|
service: service,
|
|
|
|
ui: ui,
|
|
|
|
}, nil
|
|
|
|
}
|
|
|
|
|
2014-12-09 11:42:33 -05:00
|
|
|
func (d *driverGCE) ImageExists(name string) bool {
|
|
|
|
_, err := d.service.Images.Get(d.projectId, name).Do()
|
|
|
|
// The API may return an error for reasons other than the image not
|
|
|
|
// existing, but this heuristic is sufficient for now.
|
|
|
|
return err == nil
|
|
|
|
}
|
|
|
|
|
2016-05-24 20:13:36 -04:00
|
|
|
func (d *driverGCE) CreateImage(name, description, family, zone, disk string) (<-chan Image, <-chan error) {
|
2013-12-13 22:03:10 -05:00
|
|
|
image := &compute.Image{
|
|
|
|
Description: description,
|
|
|
|
Name: name,
|
2016-05-06 07:43:39 -04:00
|
|
|
Family: family,
|
2014-11-24 11:36:14 -05:00
|
|
|
SourceDisk: fmt.Sprintf("%s%s/zones/%s/disks/%s", d.service.BasePath, d.projectId, zone, disk),
|
|
|
|
SourceType: "RAW",
|
2013-12-13 22:03:10 -05:00
|
|
|
}
|
|
|
|
|
2016-05-24 20:13:36 -04:00
|
|
|
imageCh := make(chan Image, 1)
|
2013-12-13 22:03:10 -05:00
|
|
|
errCh := make(chan error, 1)
|
|
|
|
op, err := d.service.Images.Insert(d.projectId, image).Do()
|
|
|
|
if err != nil {
|
|
|
|
errCh <- err
|
|
|
|
} else {
|
2016-05-24 20:13:36 -04:00
|
|
|
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)
|
|
|
|
}()
|
2013-12-13 22:03:10 -05:00
|
|
|
}
|
|
|
|
|
2016-05-24 20:13:36 -04:00
|
|
|
return imageCh, errCh
|
2013-12-13 22:03:10 -05:00
|
|
|
}
|
|
|
|
|
2013-12-13 22:07:10 -05:00
|
|
|
func (d *driverGCE) DeleteImage(name string) <-chan error {
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
op, err := d.service.Images.Delete(d.projectId, name).Do()
|
|
|
|
if err != nil {
|
|
|
|
errCh <- err
|
|
|
|
} else {
|
|
|
|
go waitForState(errCh, "DONE", d.refreshGlobalOp(op))
|
|
|
|
}
|
|
|
|
|
|
|
|
return errCh
|
|
|
|
}
|
|
|
|
|
2013-12-13 01:34:47 -05:00
|
|
|
func (d *driverGCE) DeleteInstance(zone, name string) (<-chan error, error) {
|
|
|
|
op, err := d.service.Instances.Delete(d.projectId, zone, name).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
go waitForState(errCh, "DONE", d.refreshZoneOp(zone, op))
|
|
|
|
return errCh, nil
|
|
|
|
}
|
|
|
|
|
2014-11-24 11:36:14 -05:00
|
|
|
func (d *driverGCE) DeleteDisk(zone, name string) (<-chan error, error) {
|
|
|
|
op, err := d.service.Disks.Delete(d.projectId, zone, name).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
go waitForState(errCh, "DONE", d.refreshZoneOp(zone, op))
|
|
|
|
return errCh, nil
|
|
|
|
}
|
|
|
|
|
2013-12-13 16:01:28 -05:00
|
|
|
func (d *driverGCE) GetNatIP(zone, name string) (string, error) {
|
|
|
|
instance, err := d.service.Instances.Get(d.projectId, zone, name).Do()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, ni := range instance.NetworkInterfaces {
|
|
|
|
if ni.AccessConfigs == nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
for _, ac := range ni.AccessConfigs {
|
|
|
|
if ac.NatIP != "" {
|
|
|
|
return ac.NatIP, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return "", nil
|
|
|
|
}
|
|
|
|
|
2015-05-29 17:50:11 -04:00
|
|
|
func (d *driverGCE) GetInternalIP(zone, name string) (string, error) {
|
|
|
|
instance, err := d.service.Instances.Get(d.projectId, zone, name).Do()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, ni := range instance.NetworkInterfaces {
|
|
|
|
if ni.NetworkIP == "" {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
return ni.NetworkIP, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return "", nil
|
|
|
|
}
|
|
|
|
|
2016-05-24 20:13:36 -04:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2013-12-13 00:38:34 -05:00
|
|
|
func (d *driverGCE) RunInstance(c *InstanceConfig) (<-chan error, error) {
|
|
|
|
// Get the zone
|
|
|
|
d.ui.Message(fmt.Sprintf("Loading zone: %s", c.Zone))
|
|
|
|
zone, err := d.service.Zones.Get(d.projectId, c.Zone).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the image
|
2014-08-20 13:20:28 -04:00
|
|
|
d.ui.Message(fmt.Sprintf("Loading image: %s in project %s", c.Image.Name, c.Image.ProjectId))
|
2016-05-24 20:13:36 -04:00
|
|
|
image, err := d.getImage(c.Image.Name, c.Image.ProjectId)
|
2013-12-13 00:38:34 -05:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the machine type
|
|
|
|
d.ui.Message(fmt.Sprintf("Loading machine type: %s", c.MachineType))
|
|
|
|
machineType, err := d.service.MachineTypes.Get(
|
|
|
|
d.projectId, zone.Name, c.MachineType).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
// TODO(mitchellh): deprecation warnings
|
|
|
|
|
|
|
|
// Get the network
|
|
|
|
d.ui.Message(fmt.Sprintf("Loading network: %s", c.Network))
|
|
|
|
network, err := d.service.Networks.Get(d.projectId, c.Network).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
2016-02-10 23:31:46 -05:00
|
|
|
// Subnetwork
|
|
|
|
// Validate Subnetwork config now that we have some info about the network
|
|
|
|
if !network.AutoCreateSubnetworks && len(network.Subnetworks) > 0 {
|
|
|
|
// Network appears to be in "custom" mode, so a subnetwork is required
|
|
|
|
if c.Subnetwork == "" {
|
|
|
|
return nil, fmt.Errorf("a subnetwork must be specified")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Get the subnetwork
|
|
|
|
subnetworkSelfLink := ""
|
|
|
|
if c.Subnetwork != "" {
|
|
|
|
d.ui.Message(fmt.Sprintf("Loading subnetwork: %s for region: %s", c.Subnetwork, c.Region))
|
|
|
|
subnetwork, err := d.service.Subnetworks.Get(d.projectId, c.Region, c.Subnetwork).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
subnetworkSelfLink = subnetwork.SelfLink
|
|
|
|
}
|
|
|
|
|
2016-08-02 16:43:04 -04:00
|
|
|
var accessconfig *compute.AccessConfig
|
|
|
|
// Use external IP if OmitExternalIP isn't set
|
|
|
|
if !c.OmitExternalIP {
|
|
|
|
accessconfig = &compute.AccessConfig{
|
|
|
|
Name: "AccessConfig created by Packer",
|
|
|
|
Type: "ONE_TO_ONE_NAT",
|
|
|
|
}
|
2015-12-24 10:19:29 -05:00
|
|
|
|
2016-08-02 16:43:04 -04:00
|
|
|
// If given a static IP, use it
|
|
|
|
if c.Address != "" {
|
|
|
|
region_url := strings.Split(zone.Region, "/")
|
|
|
|
region := region_url[len(region_url)-1]
|
|
|
|
address, err := d.service.Addresses.Get(d.projectId, region, c.Address).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
accessconfig.NatIP = address.Address
|
2015-12-24 10:19:29 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2013-12-13 00:38:34 -05:00
|
|
|
// Build up the metadata
|
|
|
|
metadata := make([]*compute.MetadataItems, len(c.Metadata))
|
|
|
|
for k, v := range c.Metadata {
|
2016-02-10 23:31:46 -05:00
|
|
|
vCopy := v
|
2013-12-13 00:38:34 -05:00
|
|
|
metadata = append(metadata, &compute.MetadataItems{
|
|
|
|
Key: k,
|
2016-02-10 23:31:46 -05:00
|
|
|
Value: &vCopy,
|
2013-12-13 00:38:34 -05:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
// Create the instance information
|
|
|
|
instance := compute.Instance{
|
|
|
|
Description: c.Description,
|
2014-04-03 18:18:58 -04:00
|
|
|
Disks: []*compute.AttachedDisk{
|
|
|
|
&compute.AttachedDisk{
|
2014-04-26 14:12:43 -04:00
|
|
|
Type: "PERSISTENT",
|
|
|
|
Mode: "READ_WRITE",
|
|
|
|
Kind: "compute#attachedDisk",
|
|
|
|
Boot: true,
|
2014-11-24 11:36:14 -05:00
|
|
|
AutoDelete: false,
|
2014-04-03 18:18:58 -04:00
|
|
|
InitializeParams: &compute.AttachedDiskInitializeParams{
|
2014-04-26 14:12:43 -04:00
|
|
|
SourceImage: image.SelfLink,
|
2014-08-07 15:34:08 -04:00
|
|
|
DiskSizeGb: c.DiskSizeGb,
|
2015-10-13 20:18:26 -04:00
|
|
|
DiskType: fmt.Sprintf("zones/%s/diskTypes/%s", zone.Name, c.DiskType),
|
2014-04-03 18:18:58 -04:00
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2013-12-13 00:38:34 -05:00
|
|
|
MachineType: machineType.SelfLink,
|
|
|
|
Metadata: &compute.Metadata{
|
|
|
|
Items: metadata,
|
|
|
|
},
|
|
|
|
Name: c.Name,
|
|
|
|
NetworkInterfaces: []*compute.NetworkInterface{
|
|
|
|
&compute.NetworkInterface{
|
2016-08-02 16:43:04 -04:00
|
|
|
AccessConfigs: []*compute.AccessConfig{accessconfig},
|
|
|
|
Network: network.SelfLink,
|
|
|
|
Subnetwork: subnetworkSelfLink,
|
2013-12-13 00:38:34 -05:00
|
|
|
},
|
|
|
|
},
|
2015-12-04 15:13:35 -05:00
|
|
|
Scheduling: &compute.Scheduling{
|
|
|
|
Preemptible: c.Preemptible,
|
|
|
|
},
|
2013-12-13 00:38:34 -05:00
|
|
|
ServiceAccounts: []*compute.ServiceAccount{
|
|
|
|
&compute.ServiceAccount{
|
2016-05-24 20:13:36 -04:00
|
|
|
Email: c.ServiceAccountEmail,
|
2013-12-13 00:38:34 -05:00
|
|
|
Scopes: []string{
|
|
|
|
"https://www.googleapis.com/auth/userinfo.email",
|
|
|
|
"https://www.googleapis.com/auth/compute",
|
|
|
|
"https://www.googleapis.com/auth/devstorage.full_control",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
Tags: &compute.Tags{
|
|
|
|
Items: c.Tags,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
d.ui.Message("Requesting instance creation...")
|
|
|
|
op, err := d.service.Instances.Insert(d.projectId, zone.Name, &instance).Do()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
errCh := make(chan error, 1)
|
2013-12-13 01:23:00 -05:00
|
|
|
go waitForState(errCh, "DONE", d.refreshZoneOp(zone.Name, op))
|
2013-12-13 00:38:34 -05:00
|
|
|
return errCh, nil
|
|
|
|
}
|
|
|
|
|
2013-12-13 16:01:28 -05:00
|
|
|
func (d *driverGCE) WaitForInstance(state, zone, name string) <-chan error {
|
|
|
|
errCh := make(chan error, 1)
|
|
|
|
go waitForState(errCh, state, d.refreshInstanceState(zone, name))
|
|
|
|
return errCh
|
|
|
|
}
|
|
|
|
|
2016-05-24 20:13:36 -04:00
|
|
|
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"}
|
2013-12-13 00:38:34 -05:00
|
|
|
for _, project := range projects {
|
2016-05-24 20:13:36 -04:00
|
|
|
image, err = d.service.Images.Get(project, name).Do()
|
2013-12-13 00:38:34 -05:00
|
|
|
if err == nil && image != nil && image.SelfLink != "" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
image = nil
|
|
|
|
}
|
|
|
|
|
2016-05-24 20:13:36 -04:00
|
|
|
err = fmt.Errorf("Image %s could not be found in any of these projects: %s", name, projects)
|
2013-12-13 00:38:34 -05:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2013-12-13 16:01:28 -05:00
|
|
|
func (d *driverGCE) refreshInstanceState(zone, name string) stateRefreshFunc {
|
|
|
|
return func() (string, error) {
|
|
|
|
instance, err := d.service.Instances.Get(d.projectId, zone, name).Do()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
return instance.Status, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2013-12-13 22:03:10 -05:00
|
|
|
func (d *driverGCE) refreshGlobalOp(op *compute.Operation) stateRefreshFunc {
|
|
|
|
return func() (string, error) {
|
|
|
|
newOp, err := d.service.GlobalOperations.Get(d.projectId, op.Name).Do()
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the op is done, check for errors
|
|
|
|
err = nil
|
|
|
|
if newOp.Status == "DONE" {
|
|
|
|
if newOp.Error != nil {
|
|
|
|
for _, e := range newOp.Error.Errors {
|
|
|
|
err = packer.MultiErrorAppend(err, fmt.Errorf(e.Message))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return newOp.Status, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2013-12-13 01:23:00 -05:00
|
|
|
func (d *driverGCE) refreshZoneOp(zone string, op *compute.Operation) stateRefreshFunc {
|
2013-12-13 00:38:34 -05:00
|
|
|
return func() (string, error) {
|
2013-12-13 01:23:00 -05:00
|
|
|
newOp, err := d.service.ZoneOperations.Get(d.projectId, zone, op.Name).Do()
|
2013-12-13 00:38:34 -05:00
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
// If the op is done, check for errors
|
|
|
|
err = nil
|
|
|
|
if newOp.Status == "DONE" {
|
|
|
|
if newOp.Error != nil {
|
|
|
|
for _, e := range newOp.Error.Errors {
|
|
|
|
err = packer.MultiErrorAppend(err, fmt.Errorf(e.Message))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return newOp.Status, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// stateRefreshFunc is used to refresh the state of a thing and is
|
|
|
|
// used in conjunction with waitForState.
|
|
|
|
type stateRefreshFunc func() (string, error)
|
|
|
|
|
|
|
|
// waitForState will spin in a loop forever waiting for state to
|
|
|
|
// reach a certain target.
|
2016-05-24 20:13:36 -04:00
|
|
|
func waitForState(errCh chan<- error, target string, refresh stateRefreshFunc) error {
|
|
|
|
err := Retry(2, 2, 0, func() (bool, error) {
|
2013-12-13 00:38:34 -05:00
|
|
|
state, err := refresh()
|
|
|
|
if err != nil {
|
2016-05-24 20:13:36 -04:00
|
|
|
return false, err
|
|
|
|
} else if state == target {
|
|
|
|
return true, nil
|
2013-12-13 00:38:34 -05:00
|
|
|
}
|
2016-05-24 20:13:36 -04:00
|
|
|
return false, nil
|
|
|
|
})
|
|
|
|
errCh <- err
|
|
|
|
return err
|
2013-12-13 00:38:34 -05:00
|
|
|
}
|