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 }