dashboard: create buildlet client package, move coordinator code into it
Operation Packification, step 2 of tons.
Eventually the buildlet client binary will use this stuff now.
Change-Id: I4cf5f3e6beb9e56bdc795ed513ce6daaf61425e3
Reviewed-on: https://go-review.googlesource.com/2921
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/buildlet/buildletclient.go b/buildlet/buildletclient.go
new file mode 100644
index 0000000..ae0e87b
--- /dev/null
+++ b/buildlet/buildletclient.go
@@ -0,0 +1,72 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build extdep
+
+// Package buildlet contains client tools for working with a buildlet
+// server.
+package buildlet // import "golang.org/x/tools/dashboard/buildlet"
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "strings"
+)
+
+// KeyPair is the TLS public certificate PEM file and its associated
+// private key PEM file that a builder will use for its HTTPS
+// server. The zero value means no HTTPs, which is used by the
+// coordinator for machines running within a firewall.
+type KeyPair struct {
+ CertPEM string
+ KeyPEM string
+}
+
+// NoKeyPair is used by the coordinator to speak http directly to buildlets,
+// inside their firewall, without TLS.
+var NoKeyPair = KeyPair{}
+
+// NewClient returns a *Client that will manipulate ipPort,
+// authenticated using the provided keypair.
+//
+// This constructor returns immediately without testing the host or auth.
+func NewClient(ipPort string, tls KeyPair) *Client {
+ return &Client{
+ ipPort: ipPort,
+ tls: tls,
+ }
+}
+
+// A Client interacts with a single buildlet.
+type Client struct {
+ ipPort string
+ tls KeyPair
+}
+
+// URL returns the buildlet's URL prefix, without a trailing slash.
+func (c *Client) URL() string {
+ if c.tls != NoKeyPair {
+ return "http://" + strings.TrimSuffix(c.ipPort, ":80")
+ }
+ return "https://" + strings.TrimSuffix(c.ipPort, ":443")
+}
+
+func (c *Client) PutTarball(r io.Reader) error {
+ req, err := http.NewRequest("PUT", c.URL()+"/writetgz", r)
+ if err != nil {
+ return err
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer res.Body.Close()
+ if res.StatusCode/100 != 2 {
+ slurp, _ := ioutil.ReadAll(io.LimitReader(res.Body, 4<<10))
+ return fmt.Errorf("%v; body: %s", res.Status, slurp)
+ }
+ return nil
+}
diff --git a/buildlet/gce.go b/buildlet/gce.go
new file mode 100644
index 0000000..325eb35
--- /dev/null
+++ b/buildlet/gce.go
@@ -0,0 +1,245 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// +build extdep
+
+package buildlet
+
+import (
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "golang.org/x/oauth2"
+ "golang.org/x/tools/dashboard"
+ "google.golang.org/api/compute/v1"
+)
+
+type VMOpts struct {
+ // Zone is the GCE zone to create the VM in. Required.
+ Zone string
+
+ // ProjectID is the GCE project ID. Required.
+ ProjectID string
+
+ // TLS optionally specifies the TLS keypair to use.
+ // If zero, http without auth is used.
+ TLS KeyPair
+
+ // Optional description of the VM.
+ Description string
+
+ // Optional metadata to put on the instance.
+ Meta map[string]string
+
+ // DeleteIn optionally specifies a duration at which
+ // to delete the VM.
+ DeleteIn time.Duration
+
+ // OnInstanceRequested optionally specifies a hook to run synchronously
+ // after the computeService.Instances.Insert call, but before
+ // waiting for its operation to proceed.
+ OnInstanceRequested func()
+
+ // OnInstanceCreated optionally specifies a hook to run synchronously
+ // after the instance operation succeeds.
+ OnInstanceCreated func()
+
+ // OnInstanceCreated optionally specifies a hook to run synchronously
+ // after the computeService.Instances.Get call.
+ OnGotInstanceInfo func()
+}
+
+// StartNewVM boots a new VM on GCE and returns a buildlet client
+// configured to speak to it.
+func StartNewVM(ts oauth2.TokenSource, instName, builderType string, opts VMOpts) (*Client, error) {
+ computeService, _ := compute.New(oauth2.NewClient(oauth2.NoContext, ts))
+
+ conf, ok := dashboard.Builders[builderType]
+ if !ok {
+ return nil, fmt.Errorf("invalid builder type %q", builderType)
+ }
+
+ zone := opts.Zone
+ if zone == "" {
+ // TODO: automatic? maybe that's not useful.
+ // For now just return an error.
+ return nil, errors.New("buildlet: missing required Zone option")
+ }
+ projectID := opts.ProjectID
+ if projectID == "" {
+ return nil, errors.New("buildlet: missing required ProjectID option")
+ }
+
+ prefix := "https://www.googleapis.com/compute/v1/projects/" + projectID
+ machType := prefix + "/zones/" + zone + "/machineTypes/" + conf.MachineType()
+
+ instance := &compute.Instance{
+ Name: instName,
+ Description: opts.Description,
+ MachineType: machType,
+ Disks: []*compute.AttachedDisk{
+ {
+ AutoDelete: true,
+ Boot: true,
+ Type: "PERSISTENT",
+ InitializeParams: &compute.AttachedDiskInitializeParams{
+ DiskName: instName,
+ SourceImage: "https://www.googleapis.com/compute/v1/projects/" + projectID + "/global/images/" + conf.VMImage,
+ DiskType: "https://www.googleapis.com/compute/v1/projects/" + projectID + "/zones/" + zone + "/diskTypes/pd-ssd",
+ },
+ },
+ },
+ Tags: &compute.Tags{
+ // Warning: do NOT list "http-server" or "allow-ssh" (our
+ // project's custom tag to allow ssh access) here; the
+ // buildlet provides full remote code execution.
+ // The https-server is authenticated, though.
+ Items: []string{"https-server"},
+ },
+ Metadata: &compute.Metadata{
+ Items: []*compute.MetadataItems{
+ // The buildlet-binary-url is the URL of the buildlet binary
+ // which the VMs are configured to download at boot and run.
+ // This lets us/ update the buildlet more easily than
+ // rebuilding the whole VM image.
+ {
+ Key: "buildlet-binary-url",
+ Value: "http://storage.googleapis.com/go-builder-data/buildlet." + conf.GOOS() + "-" + conf.GOARCH(),
+ },
+ },
+ },
+ NetworkInterfaces: []*compute.NetworkInterface{
+ &compute.NetworkInterface{
+ AccessConfigs: []*compute.AccessConfig{
+ &compute.AccessConfig{
+ Type: "ONE_TO_ONE_NAT",
+ Name: "External NAT",
+ },
+ },
+ Network: prefix + "/global/networks/default",
+ },
+ },
+ }
+
+ if opts.DeleteIn != 0 {
+ // In case the VM gets away from us (generally: if the
+ // coordinator dies while a build is running), then we
+ // set this attribute of when it should be killed so
+ // we can kill it later when the coordinator is
+ // restarted. The cleanUpOldVMs goroutine loop handles
+ // that killing.
+ instance.Metadata.Items = append(instance.Metadata.Items, &compute.MetadataItems{
+ Key: "delete-at",
+ Value: fmt.Sprint(time.Now().Add(opts.DeleteIn).Unix()),
+ })
+ }
+ for k, v := range opts.Meta {
+ instance.Metadata.Items = append(instance.Metadata.Items, &compute.MetadataItems{
+ Key: k,
+ Value: v,
+ })
+ }
+
+ op, err := computeService.Instances.Insert(projectID, zone, instance).Do()
+ if err != nil {
+ return nil, fmt.Errorf("Failed to create instance: %v", err)
+ }
+ if fn := opts.OnInstanceRequested; fn != nil {
+ fn()
+ }
+ createOp := op.Name
+
+ // Wait for instance create operation to succeed.
+OpLoop:
+ for {
+ time.Sleep(2 * time.Second)
+ op, err := computeService.ZoneOperations.Get(projectID, zone, createOp).Do()
+ if err != nil {
+ return nil, fmt.Errorf("Failed to get op %s: %v", createOp, err)
+ }
+ switch op.Status {
+ case "PENDING", "RUNNING":
+ continue
+ case "DONE":
+ if op.Error != nil {
+ for _, operr := range op.Error.Errors {
+ return nil, fmt.Errorf("Error creating instance: %+v", operr)
+ }
+ return nil, errors.New("Failed to start.")
+ }
+ break OpLoop
+ default:
+ return nil, fmt.Errorf("Unknown create status %q: %+v", op.Status, op)
+ }
+ }
+ if fn := opts.OnInstanceCreated; fn != nil {
+ fn()
+ }
+
+ inst, err := computeService.Instances.Get(projectID, zone, instName).Do()
+ if err != nil {
+ return nil, fmt.Errorf("Error getting instance %s details after creation: %v", instName, err)
+ }
+
+ // Find its internal IP.
+ var ip string
+ for _, iface := range inst.NetworkInterfaces {
+ if strings.HasPrefix(iface.NetworkIP, "10.") {
+ ip = iface.NetworkIP
+ }
+ }
+ if ip == "" {
+ return nil, errors.New("didn't find its internal IP address")
+ }
+
+ // Wait for it to boot and its buildlet to come up.
+ var buildletURL string
+ var ipPort string
+ if opts.TLS != NoKeyPair {
+ buildletURL = "https://" + ip
+ ipPort = ip + ":443"
+ } else {
+ buildletURL = "http://" + ip
+ ipPort = ip + ":80"
+ }
+ if fn := opts.OnGotInstanceInfo; fn != nil {
+ fn()
+ }
+
+ const timeout = 90 * time.Second
+ var alive bool
+ impatientClient := &http.Client{
+ Timeout: 5 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+ deadline := time.Now().Add(timeout)
+ try := 0
+ for time.Now().Before(deadline) {
+ try++
+ res, err := impatientClient.Get(buildletURL)
+ if err != nil {
+ time.Sleep(1 * time.Second)
+ continue
+ }
+ res.Body.Close()
+ if res.StatusCode != 200 {
+ return nil, fmt.Errorf("buildlet returned HTTP status code %d on try number %d", res.StatusCode, try)
+ }
+ alive = true
+ break
+ }
+ if !alive {
+ return nil, fmt.Errorf("buildlet didn't come up in %v", timeout)
+ }
+
+ return NewClient(ipPort, opts.TLS), nil
+}