github.com/coreos/mantle@v0.13.0/platform/api/do/api.go (about)

     1  // Copyright 2017 CoreOS, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package do
    16  
    17  import (
    18  	"context"
    19  	"fmt"
    20  	"strconv"
    21  	"time"
    22  
    23  	"github.com/coreos/pkg/capnslog"
    24  	"github.com/digitalocean/godo"
    25  	"golang.org/x/oauth2"
    26  
    27  	"github.com/coreos/mantle/auth"
    28  	"github.com/coreos/mantle/platform"
    29  	"github.com/coreos/mantle/util"
    30  )
    31  
    32  var (
    33  	plog = capnslog.NewPackageLogger("github.com/coreos/mantle", "platform/api/do")
    34  )
    35  
    36  type Options struct {
    37  	*platform.Options
    38  
    39  	// Config file. Defaults to $HOME/.config/digitalocean.json.
    40  	ConfigPath string
    41  	// Profile name
    42  	Profile string
    43  	// Personal access token (overrides config profile)
    44  	AccessToken string
    45  
    46  	// Region slug (e.g. "sfo2")
    47  	Region string
    48  	// Droplet size slug (e.g. "512mb")
    49  	Size string
    50  	// Numeric image ID, {alpha, beta, stable}, or user image name
    51  	Image string
    52  }
    53  
    54  type API struct {
    55  	c     *godo.Client
    56  	opts  *Options
    57  	image godo.DropletCreateImage
    58  }
    59  
    60  func New(opts *Options) (*API, error) {
    61  	if opts.AccessToken == "" {
    62  		profiles, err := auth.ReadDOConfig(opts.ConfigPath)
    63  		if err != nil {
    64  			return nil, fmt.Errorf("couldn't read DigitalOcean config: %v", err)
    65  		}
    66  
    67  		if opts.Profile == "" {
    68  			opts.Profile = "default"
    69  		}
    70  		profile, ok := profiles[opts.Profile]
    71  		if !ok {
    72  			return nil, fmt.Errorf("no such profile %q", opts.Profile)
    73  		}
    74  		if opts.AccessToken == "" {
    75  			opts.AccessToken = profile.AccessToken
    76  		}
    77  	}
    78  
    79  	ctx := context.TODO()
    80  	client := godo.NewClient(oauth2.NewClient(ctx, &tokenSource{opts.AccessToken}))
    81  
    82  	a := &API{
    83  		c:    client,
    84  		opts: opts,
    85  	}
    86  
    87  	var err error
    88  	a.image, err = a.resolveImage(ctx, opts.Image)
    89  	if err != nil {
    90  		return nil, err
    91  	}
    92  
    93  	return a, nil
    94  }
    95  
    96  func (a *API) resolveImage(ctx context.Context, imageSpec string) (godo.DropletCreateImage, error) {
    97  	// try numeric image ID first
    98  	imageID, err := strconv.Atoi(imageSpec)
    99  	if err == nil {
   100  		return godo.DropletCreateImage{ID: imageID}, nil
   101  	}
   102  
   103  	// handle magic values
   104  	switch imageSpec {
   105  	case "":
   106  		// pick the most conservative default
   107  		imageSpec = "stable"
   108  		fallthrough
   109  	case "alpha", "beta", "stable":
   110  		return godo.DropletCreateImage{Slug: "coreos-" + imageSpec}, nil
   111  	}
   112  
   113  	// resolve to user image ID
   114  	image, err := a.GetUserImage(ctx, imageSpec, true)
   115  	if err == nil {
   116  		return godo.DropletCreateImage{ID: image.ID}, nil
   117  	}
   118  
   119  	return godo.DropletCreateImage{}, fmt.Errorf("couldn't resolve image %q in %v", imageSpec, a.opts.Region)
   120  }
   121  
   122  func (a *API) PreflightCheck(ctx context.Context) error {
   123  	_, _, err := a.c.Account.Get(ctx)
   124  	if err != nil {
   125  		return fmt.Errorf("querying account: %v", err)
   126  	}
   127  	return nil
   128  }
   129  
   130  func (a *API) CreateDroplet(ctx context.Context, name string, sshKeyID int, userdata string) (*godo.Droplet, error) {
   131  	var droplet *godo.Droplet
   132  	var err error
   133  	// DO frequently gives us 422 errors saying "Please try again". Retry every 10 seconds
   134  	// for up to 5 min
   135  	err = util.RetryConditional(5*6, 10*time.Second, shouldRetry, func() error {
   136  		droplet, _, err = a.c.Droplets.Create(ctx, &godo.DropletCreateRequest{
   137  			Name:              name,
   138  			Region:            a.opts.Region,
   139  			Size:              a.opts.Size,
   140  			Image:             a.image,
   141  			SSHKeys:           []godo.DropletCreateSSHKey{{ID: sshKeyID}},
   142  			IPv6:              true,
   143  			PrivateNetworking: true,
   144  			UserData:          userdata,
   145  			Tags:              []string{"mantle"},
   146  		})
   147  		if err != nil {
   148  			plog.Errorf("Error creating droplet: %v. Retrying...", err)
   149  		}
   150  		return err
   151  	})
   152  	if err != nil {
   153  		return nil, fmt.Errorf("couldn't create droplet: %v", err)
   154  	}
   155  	dropletID := droplet.ID
   156  
   157  	err = util.WaitUntilReady(5*time.Minute, 10*time.Second, func() (bool, error) {
   158  		var err error
   159  		// update droplet in closure
   160  		droplet, _, err = a.c.Droplets.Get(ctx, dropletID)
   161  		if err != nil {
   162  			return false, err
   163  		}
   164  		return droplet.Status == "active", nil
   165  	})
   166  	if err != nil {
   167  		a.DeleteDroplet(ctx, dropletID)
   168  		return nil, fmt.Errorf("waiting for droplet to run: %v", err)
   169  	}
   170  
   171  	return droplet, nil
   172  }
   173  
   174  func (a *API) listDropletsWithTag(ctx context.Context, tag string) ([]godo.Droplet, error) {
   175  	page := godo.ListOptions{
   176  		Page:    1,
   177  		PerPage: 200,
   178  	}
   179  	var ret []godo.Droplet
   180  	for {
   181  		droplets, _, err := a.c.Droplets.ListByTag(ctx, tag, &page)
   182  		if err != nil {
   183  			return nil, err
   184  		}
   185  		ret = append(ret, droplets...)
   186  		if len(droplets) < page.PerPage {
   187  			return ret, nil
   188  		}
   189  		page.Page += 1
   190  	}
   191  }
   192  
   193  func (a *API) GetDroplet(ctx context.Context, dropletID int) (*godo.Droplet, error) {
   194  	droplet, _, err := a.c.Droplets.Get(ctx, dropletID)
   195  	if err != nil {
   196  		return nil, err
   197  	}
   198  	return droplet, nil
   199  }
   200  
   201  // SnapshotDroplet creates a snapshot of a droplet and waits until complete.
   202  // The Snapshot API doesn't return the snapshot ID, so we don't either.
   203  func (a *API) SnapshotDroplet(ctx context.Context, dropletID int, name string) error {
   204  	action, _, err := a.c.DropletActions.Snapshot(ctx, dropletID, name)
   205  	if err != nil {
   206  		return err
   207  	}
   208  	actionID := action.ID
   209  
   210  	err = util.WaitUntilReady(30*time.Minute, 15*time.Second, func() (bool, error) {
   211  		action, _, err := a.c.Actions.Get(ctx, actionID)
   212  		if err != nil {
   213  			return false, err
   214  		}
   215  		switch action.Status {
   216  		case "in-progress":
   217  			return false, nil
   218  		case "completed":
   219  			return true, nil
   220  		default:
   221  			return false, fmt.Errorf("snapshot failed")
   222  		}
   223  	})
   224  	if err != nil {
   225  		return err
   226  	}
   227  
   228  	return nil
   229  }
   230  
   231  func (a *API) DeleteDroplet(ctx context.Context, dropletID int) error {
   232  	_, err := a.c.Droplets.Delete(ctx, dropletID)
   233  	if err != nil {
   234  		return fmt.Errorf("deleting droplet %d: %v", dropletID, err)
   235  	}
   236  	return nil
   237  }
   238  
   239  func (a *API) GetUserImage(ctx context.Context, imageName string, inRegion bool) (*godo.Image, error) {
   240  	var ret *godo.Image
   241  	var regionMessage string
   242  	if inRegion {
   243  		regionMessage = fmt.Sprintf(" in %v", a.opts.Region)
   244  	}
   245  	page := godo.ListOptions{
   246  		Page:    1,
   247  		PerPage: 200,
   248  	}
   249  	for {
   250  		images, _, err := a.c.Images.ListUser(ctx, &page)
   251  		if err != nil {
   252  			return nil, err
   253  		}
   254  		for _, image := range images {
   255  			image := image
   256  			if image.Name != imageName {
   257  				continue
   258  			}
   259  			for _, region := range image.Regions {
   260  				if inRegion && region != a.opts.Region {
   261  					continue
   262  				}
   263  				if ret != nil {
   264  					return nil, fmt.Errorf("found multiple images named %q%s", imageName, regionMessage)
   265  				}
   266  				ret = &image
   267  				break
   268  			}
   269  		}
   270  		if len(images) < page.PerPage {
   271  			break
   272  		}
   273  		page.Page += 1
   274  	}
   275  
   276  	if ret == nil {
   277  		return nil, fmt.Errorf("couldn't find image %q%s", imageName, regionMessage)
   278  	}
   279  	return ret, nil
   280  }
   281  
   282  func (a *API) DeleteImage(ctx context.Context, imageID int) error {
   283  	_, err := a.c.Images.Delete(ctx, imageID)
   284  	if err != nil {
   285  		return fmt.Errorf("deleting image %d: %v", imageID, err)
   286  	}
   287  	return nil
   288  }
   289  
   290  func (a *API) AddKey(ctx context.Context, name, key string) (int, error) {
   291  	sshKey, _, err := a.c.Keys.Create(ctx, &godo.KeyCreateRequest{
   292  		Name:      name,
   293  		PublicKey: key,
   294  	})
   295  	if err != nil {
   296  		return 0, fmt.Errorf("couldn't create SSH key: %v", err)
   297  	}
   298  	return sshKey.ID, nil
   299  }
   300  
   301  func (a *API) DeleteKey(ctx context.Context, keyID int) error {
   302  	_, err := a.c.Keys.DeleteByID(ctx, keyID)
   303  	if err != nil {
   304  		return fmt.Errorf("couldn't delete SSH key: %v", err)
   305  	}
   306  	return nil
   307  }
   308  
   309  func (a *API) ListKeys(ctx context.Context) ([]godo.Key, error) {
   310  	page := godo.ListOptions{
   311  		Page:    1,
   312  		PerPage: 200,
   313  	}
   314  	var ret []godo.Key
   315  	for {
   316  		keys, _, err := a.c.Keys.List(ctx, &page)
   317  		if err != nil {
   318  			return nil, err
   319  		}
   320  		ret = append(ret, keys...)
   321  		if len(keys) < page.PerPage {
   322  			return ret, nil
   323  		}
   324  		page.Page += 1
   325  	}
   326  }
   327  
   328  func (a *API) GC(ctx context.Context, gracePeriod time.Duration) error {
   329  	threshold := time.Now().Add(-gracePeriod)
   330  
   331  	droplets, err := a.listDropletsWithTag(ctx, "mantle")
   332  	if err != nil {
   333  		return fmt.Errorf("listing droplets: %v", err)
   334  	}
   335  	for _, droplet := range droplets {
   336  		if droplet.Status == "archive" {
   337  			continue
   338  		}
   339  
   340  		created, err := time.Parse(time.RFC3339, droplet.Created)
   341  		if err != nil {
   342  			return fmt.Errorf("couldn't parse %q: %v", droplet.Created, err)
   343  		}
   344  		if created.After(threshold) {
   345  			continue
   346  		}
   347  
   348  		if err := a.DeleteDroplet(ctx, droplet.ID); err != nil {
   349  			return fmt.Errorf("couldn't delete droplet %d: %v", droplet.ID, err)
   350  		}
   351  	}
   352  	return nil
   353  }
   354  
   355  type tokenSource struct {
   356  	token string
   357  }
   358  
   359  func (t *tokenSource) Token() (*oauth2.Token, error) {
   360  	return &oauth2.Token{
   361  		AccessToken: t.token,
   362  	}, nil
   363  }
   364  
   365  // shouldRetry returns if the error is from DigitalOcean and we should
   366  // retry the request which generated it
   367  func shouldRetry(err error) bool {
   368  	errResp, ok := err.(*godo.ErrorResponse)
   369  	if !ok {
   370  		return false
   371  	}
   372  	status := errResp.Response.StatusCode
   373  	return status == 422 || status >= 500
   374  }