Skip to content

Commit

Permalink
Add Google Cloud provider
Browse files Browse the repository at this point in the history
For authentication:
1) Looks for GCLOUD_OAUTH_TOKEN (usually set in ~/.hostctl)
2) Tries detecting OAuth credentials managed by "gcloud auth"
3) Opens browser for web-based OAuth (will save to ~/.hostctl)

Since Google Cloud doesn't allow "." in instance names, provides an interface
to override the default naming convention for the "scale" command.
  • Loading branch information
mgood committed Sep 1, 2015
1 parent ffa3b97 commit 7692e70
Show file tree
Hide file tree
Showing 4 changed files with 426 additions and 7 deletions.
27 changes: 27 additions & 0 deletions google/aliases.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package google

type ImageAlias struct {
Project string
Name string
}

var PublicAliases = map[string]ImageAlias{
"centos-6": ImageAlias{"centos-cloud", "centos-6"},
"centos-7": ImageAlias{"centos-cloud", "centos-7"},
"container-vm": ImageAlias{"google-containers", "container-vm"},
"coreos": ImageAlias{"coreos-cloud", "coreos-stable"},
"debian-7": ImageAlias{"debian-cloud", "debian-7-wheezy"},
"debian-7-backports": ImageAlias{"debian-cloud", "backports-debian-7-wheezy"},
"debian-8": ImageAlias{"debian-cloud", "debian-8-jessie"},
"opensuse-13": ImageAlias{"opensuse-cloud", "opensuse-13"},
"rhel-6": ImageAlias{"rhel-cloud", "rhel-6"},
"rhel-7": ImageAlias{"rhel-cloud", "rhel-7"},
"sles-11": ImageAlias{"suse-cloud", "sles-11"},
"sles-12": ImageAlias{"suse-cloud", "sles-12"},
"ubuntu-12-04": ImageAlias{"ubuntu-os-cloud", "ubuntu-1204-precise"},
"ubuntu-14-04": ImageAlias{"ubuntu-os-cloud", "ubuntu-1404-trusty"},
"ubuntu-14-10": ImageAlias{"ubuntu-os-cloud", "ubuntu-1410-utopic"},
"ubuntu-15-04": ImageAlias{"ubuntu-os-cloud", "ubuntu-1504-vivid"},
"windows-2008-r2": ImageAlias{"windows-cloud", "windows-server-2008-r2"},
"windows-2012-r2": ImageAlias{"windows-cloud", "windows-server-2012-r2"},
}
366 changes: 366 additions & 0 deletions google/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
package google

import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"google.golang.org/api/compute/v1"
"google.golang.org/api/googleapi"

"github.com/MattAitchison/env"
"github.com/gliderlabs/hostctl/providers"
"github.com/mitchellh/go-homedir"
"golang.org/x/net/context"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)

var envSet = env.NewEnvSet("google")

func init() {
readEnv()
providers.Register(new(googleCloudProvider), "google")
}

// default config using client credentials from the "gcloud" tool
var defaultOAuthConfig = oauth2.Config{
ClientID: "32555940559.apps.googleusercontent.com",
ClientSecret: "ZmssLNjJy2998hD4CTg2ejr2",
Endpoint: google.Endpoint,
Scopes: []string{
compute.DevstorageFullControlScope,
compute.ComputeScope,
},
}

func readEnv() {
envSet.Clear()
envSet.String("GCLOUD_PROJECT", "", "Google project identifier")
envSet.String("GCLOUD_OAUTH_TOKEN", "", "OAuth token (saved automatically to ~/.hostctl after authentication)")
}

type googleCloudProvider struct {
service *compute.Service
project string
region string
}

func (p *googleCloudProvider) HostNamePattern() string {
return "{{.Namespace}}{{.Name}}-{{.Index}}"
}

func (p *googleCloudProvider) Setup() error {
readEnv()

p.region = os.Getenv("HOSTCTL_REGION")
p.project = envSet.Var("GCLOUD_PROJECT").Value.Get().(string)

if p.region == "" {
return errors.New("HOSTCTL_REGION required for Google provider")
}

if p.project == "" {
return errors.New("GCLOUD_PROJECT required for Google provider")
}

httpClient, err := newOAuthClient(context.Background())
if err != nil {
return err
}

service, err := compute.New(httpClient)
if err != nil {
return err
}

p.service = service
return nil
}

func (p *googleCloudProvider) Env() *env.EnvSet {
readEnv()
return envSet
}

func (p *googleCloudProvider) Create(host providers.Host) error {
image, err := p.resolveAlias(host.Image)
if err != nil {
return err
}

instance := &compute.Instance{
Name: host.Name,
MachineType: fmt.Sprintf("zones/%s/machineTypes/%s", host.Region, host.Flavor),
Disks: []*compute.AttachedDisk{{
// AutoDelete: true,
Boot: true,
InitializeParams: &compute.AttachedDiskInitializeParams{
SourceImage: image,
},
}},
NetworkInterfaces: []*compute.NetworkInterface{{
Network: "global/networks/default",
AccessConfigs: []*compute.AccessConfig{{
Type: "ONE_TO_ONE_NAT",
}},
}},
}

// TODO ssh key for host.Keyname

if host.Userdata != "" {
instance.Metadata = &compute.Metadata{
Items: []*compute.MetadataItems{
{Key: "user-data", Value: &host.Userdata},
},
}
}

op, err := p.service.Instances.Insert(p.project, p.region, instance).Do()

if err != nil {
return err
}

return p.waitForZoneOp(op)
}

func (p *googleCloudProvider) Destroy(name string) error {
op, err := p.service.Instances.Delete(p.project, p.region, name).Do()
if err != nil {
if err, ok := err.(*googleapi.Error); ok && err.Code == http.StatusNotFound {
return nil
}
return err
}
return p.waitForZoneOp(op)
}

func (p *googleCloudProvider) List(pattern string) (hosts []providers.Host) {
list, err := p.service.Instances.List(p.project, p.region).Do()
if err != nil {
return nil
}

for _, instance := range list.Items {
// TODO convert glob pattern into regex for server-side filtering?
if ok, _ := filepath.Match(pattern, instance.Name); ok {
// TODO filter hosts without a public IP?
hosts = append(hosts, providers.Host{
Name: instance.Name,
})
}
}

return hosts
}

func (p *googleCloudProvider) Get(name string) *providers.Host {
instance, err := p.service.Instances.Get(p.project, p.region, name).Do()
if err != nil {
return nil
}

for _, iface := range instance.NetworkInterfaces {
for _, access := range iface.AccessConfigs {
return &providers.Host{
Name: instance.Name,
IP: access.NatIP,
}
}
}

return nil
}

func (p *googleCloudProvider) resolveAlias(image string) (string, error) {
// assume any image with a '/' is already full-qualified
if strings.ContainsRune(image, '/') {
return image, nil
}

alias, ok := PublicAliases[image]
// if there is no public alias, assume this is a valid image name within the project
if !ok {
return imagePath(p.project, image), nil
}

publicImageList, err := p.service.Images.List(alias.Project).Filter(fmt.Sprintf("name eq ^%s(-.+)*-v.+", alias.Name)).Do()
if err != nil {
return "", err
}

imageVersion := func(image *compute.Image) string {
parts := strings.Split(image.Name, "v")
return parts[len(parts)-1]
}

var publicImage *compute.Image
for _, image := range publicImageList.Items {
if image.Deprecated == nil && (publicImage == nil || imageVersion(image) > imageVersion(publicImage)) {
publicImage = image
}
}

if publicImage == nil {
return "", fmt.Errorf("could not find image for alias %s", image)
}

userImageList, err := p.service.Images.List(p.project).Filter(fmt.Sprintf("name eq ^%s$", image)).Do()
if err != nil {
return "", err
}

var userImage *compute.Image
for _, image := range userImageList.Items {
if image.Deprecated == nil {
userImage = image
break
}
}

if userImage == nil {
return publicImage.SelfLink, nil
}

return "", fmt.Errorf(`Image name "%s" is ambiguous, please use one of the fully-qualified names:
Your image:
%s
Public image:
%s
`, image, imagePath(p.project, userImage.Name), imagePath(alias.Project, publicImage.Name))
}

func imagePath(project, name string) string {
return fmt.Sprintf("projects/%s/global/images/%s", project, name)
}

func (p *googleCloudProvider) waitForZoneOp(op *compute.Operation) (err error) {
opName := op.Name

for op.Status != "DONE" {
time.Sleep(1 * time.Second)

op, err = p.service.ZoneOperations.Get(p.project, p.region, opName).Do()
if err != nil {
return err
}
}

return nil
}

func newOAuthClient(ctx context.Context) (*http.Client, error) {
var token *oauth2.Token

tokenJSON := envSet.Var("GCLOUD_OAUTH_TOKEN").Value.Get().(string)

if tokenJSON != "" {
token = new(oauth2.Token)
err := json.Unmarshal([]byte(tokenJSON), token)
if err != nil {
return nil, err
}
} else {
config, sdkErr := google.NewSDKConfig("")
if sdkErr == nil {
return config.Client(ctx), nil
}
fmt.Printf(`Unable to read "gcloud" authentication:
%s
Attempting OAuth online authentication.
For alternative authentication, please use:
gcloud auth login
`, sdkErr)

token = tokenFromWeb(ctx, &defaultOAuthConfig)
saveToken(token)
}

return defaultOAuthConfig.Client(ctx, token), nil
}

func saveToken(token *oauth2.Token) {
path := "~/.hostctl"
fmt.Println("Success! Saving OAuth token to", path)

path, _ = homedir.Expand(path)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666)
if err != nil {
return
}
defer f.Close()

// if the file is not empty, make sure it ends with a newline
f.Seek(-1, os.SEEK_END)
buf := make([]byte, 1)
n, err := f.Read(buf)
if n == 0 {
if err != io.EOF {
return
}
} else if buf[0] != '\n' {
f.WriteString("\n")
}

tokenJSON, _ := json.Marshal(token)
fmt.Fprintf(f, "export GCLOUD_OAUTH_TOKEN='%s'\n", tokenJSON)
}

func tokenFromWeb(ctx context.Context, config *oauth2.Config) *oauth2.Token {
ch := make(chan string)
randState := fmt.Sprintf("st%d", time.Now().UnixNano())
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/favicon.ico" {
http.Error(rw, "", 404)
return
}
if req.FormValue("state") != randState {
http.Error(rw, "", 500)
return
}
if code := req.FormValue("code"); code != "" {
fmt.Fprintf(rw, "<h1>Success</h1>Authorized.")
rw.(http.Flusher).Flush()
ch <- code
return
}
http.Error(rw, "", 500)
}))
defer ts.Close()

config.RedirectURL = ts.URL
authURL := config.AuthCodeURL(randState)
go openURL(authURL)
fmt.Printf("Opening authentication at:\n %s\n", authURL)
code := <-ch

token, err := config.Exchange(ctx, code)
if err != nil {
log.Fatalf("Token exchange error: %v", err)
}
return token
}

func openURL(url string) {
try := []string{"xdg-open", "google-chrome", "open"}
for _, bin := range try {
err := exec.Command(bin, url).Run()
if err == nil {
return
}
}
}
Loading

0 comments on commit 7692e70

Please sign in to comment.