Merge pull request #3707 from andrewsykim/droplet

Automatic merge from submit-queue.

Implement DigitalOcean Droplet FI Task

Implements cloudup fi tasks for DigitalOcean droplets. It makes a few assumptions to reduce the size of this PR, those will be addressed in future PRs. 

Also does some cleanup in the DigitalOcean `dns` package.
This commit is contained in:
Kubernetes Submit Queue 2017-10-27 08:30:57 -07:00 committed by GitHub
commit a4d6895472
11 changed files with 364 additions and 91 deletions

View File

@ -444,6 +444,28 @@ func RunCreateCluster(f *util.Factory, out io.Writer, c *CreateClusterOptions) e
}
zoneToSubnetMap[zoneName] = subnet
}
} else if api.CloudProviderID(cluster.Spec.CloudProvider) == api.CloudProviderDO {
if len(c.Zones) > 1 {
return fmt.Errorf("digitalocean cloud provider currently only supports 1 region, expect multi-region support when digitalocean support is in beta")
}
// For DO we just pass in the region for --zones
region := c.Zones[0]
subnet := model.FindSubnet(cluster, region)
// for DO, subnets are just regions
subnetName := region
if subnet == nil {
subnet = &api.ClusterSubnetSpec{
Name: subnetName,
// region and zone are the same for DO
Region: region,
Zone: region,
}
cluster.Spec.Subnets = append(cluster.Spec.Subnets, *subnet)
}
zoneToSubnetMap[region] = subnet
} else {
for _, zoneName := range allZones.List() {
// We create default subnets named the same as the zones

View File

@ -75,6 +75,7 @@ k8s.io/kops/pkg/model
k8s.io/kops/pkg/model/awsmodel
k8s.io/kops/pkg/model/components
k8s.io/kops/pkg/model/defaults
k8s.io/kops/pkg/model/domodel
k8s.io/kops/pkg/model/gcemodel
k8s.io/kops/pkg/model/iam
k8s.io/kops/pkg/model/resources

View File

@ -80,9 +80,6 @@ func ValidateCluster(c *kops.Cluster, strict bool) *field.Error {
switch kops.CloudProviderID(c.Spec.CloudProvider) {
case kops.CloudProviderBareMetal:
requiresSubnets = false
if c.Spec.NetworkCIDR != "" {
return field.Invalid(fieldSpec.Child("NetworkCIDR"), c.Spec.NetworkCIDR, "NetworkCIDR should not be set on bare metal")
}
requiresNetworkCIDR = false
if c.Spec.NetworkCIDR != "" {
return field.Invalid(fieldSpec.Child("NetworkCIDR"), c.Spec.NetworkCIDR, "NetworkCIDR should not be set on bare metal")
@ -96,6 +93,12 @@ func ValidateCluster(c *kops.Cluster, strict bool) *field.Error {
requiresSubnetCIDR = false
case kops.CloudProviderDO:
requiresSubnets = false
requiresSubnetCIDR = false
requiresNetworkCIDR = false
if c.Spec.NetworkCIDR != "" {
return field.Invalid(fieldSpec.Child("NetworkCIDR"), c.Spec.NetworkCIDR, "NetworkCIDR should not be set on DigitalOcean")
}
case kops.CloudProviderAWS:
case kops.CloudProviderVSphere:

View File

@ -0,0 +1,24 @@
/*
Copyright 2016 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package domodel
import "k8s.io/kops/pkg/model"
// DigitalOcean Model Context
type DOModelContext struct {
*model.KopsModelContext
}

View File

@ -0,0 +1,71 @@
/*
Copyright 2016 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package domodel
import (
"strings"
"k8s.io/kops/pkg/model"
"k8s.io/kops/upup/pkg/fi"
"k8s.io/kops/upup/pkg/fi/cloudup/dotasks"
)
// DropletBuilder configures droplets for the cluster
type DropletBuilder struct {
*DOModelContext
BootstrapScript *model.BootstrapScript
Lifecycle *fi.Lifecycle
}
var _ fi.ModelBuilder = &DropletBuilder{}
func (d *DropletBuilder) Build(c *fi.ModelBuilderContext) error {
sshKeyName, err := d.SSHKeyName()
if err != nil {
return err
}
splitSSHKeyName := strings.Split(sshKeyName, "-")
sshKeyFingerPrint := splitSSHKeyName[len(splitSSHKeyName)-1]
// replace "." with "-" since DO API does not accept "."
clusterTag := "KubernetesCluster:" + strings.Replace(d.ClusterName(), ".", "-", -1)
for _, ig := range d.InstanceGroups {
name := d.AutoscalingGroupName(ig)
var droplet dotasks.Droplet
droplet.Name = fi.String(name)
// during alpha support we only allow 1 region
// validation for only 1 region is done at this point
droplet.Region = fi.String(d.Cluster.Spec.Subnets[0].Region)
droplet.Size = fi.String(ig.Spec.MachineType)
droplet.Image = fi.String(ig.Spec.Image)
droplet.SSHKey = fi.String(sshKeyFingerPrint)
droplet.Tags = []string{clusterTag}
userData, err := d.BootstrapScript.ResourceNodeUp(ig, &d.Cluster.Spec)
if err != nil {
return err
}
droplet.UserData = userData
c.AddTask(&droplet)
}
return nil
}

View File

@ -114,6 +114,10 @@ func (c *Cloud) Volumes() godo.StorageService {
return c.Client.Storage
}
func (c *Cloud) Droplets() godo.DropletsService {
return c.Client.Droplets
}
// FindVPCInfo is not implemented, it's only here to satisfy the fi.Cloud interface
func (c *Cloud) FindVPCInfo(id string) (*fi.VPCInfo, error) {
return nil, errors.New("not implemented")

View File

@ -18,8 +18,6 @@ package dns
import (
"fmt"
"io/ioutil"
"net/http"
"github.com/digitalocean/godo"
"github.com/digitalocean/godo/context"
@ -311,7 +309,7 @@ func (r *resourceRecordChangeset) Apply() error {
err := deleteRecord(r.client, r.zone.Name(), desiredRecord.ID)
if err != nil {
return err
return fmt.Errorf("failed to delete record: %v", err)
}
}
@ -321,7 +319,7 @@ func (r *resourceRecordChangeset) Apply() error {
if len(r.upserts) > 0 {
records, err := getRecords(r.client, r.zone.Name())
if err != nil {
return err
return fmt.Errorf("failed to get records: %v", err)
}
for _, record := range r.upserts {
@ -346,7 +344,7 @@ func (r *resourceRecordChangeset) Apply() error {
}
err := editRecord(r.client, r.zone.Name(), desiredRecord.ID, domainEditRequest)
if err != nil {
return err
return fmt.Errorf("failed to edit record: %v", err)
}
}
@ -374,112 +372,70 @@ func (r *resourceRecordChangeset) ResourceRecordSets() dnsprovider.ResourceRecor
// listDomains returns a list of godo.Domain
func listDomains(c *godo.Client) ([]godo.Domain, error) {
// TODO (andrewsykim): pagination in ListOptions
domains, resp, err := c.Domains.List(context.TODO(), &godo.ListOptions{})
domains, _, err := c.Domains.List(context.TODO(), &godo.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list domains: %v", err)
}
if err = handleResponse(resp); err != nil {
return nil, err
}
return domains, err
}
// createDomain creates a domain provided godo.DomainCreateRequest
func createDomain(c *godo.Client, createRequest *godo.DomainCreateRequest) (*godo.Domain, error) {
domain, resp, err := c.Domains.Create(context.TODO(), createRequest)
domain, _, err := c.Domains.Create(context.TODO(), createRequest)
if err != nil {
return nil, err
}
if err = handleResponse(resp); err != nil {
return nil, err
}
return domain, nil
}
// deleteDomain deletes a domain given its name
func deleteDomain(c *godo.Client, name string) error {
resp, err := c.Domains.Delete(context.TODO(), name)
_, err := c.Domains.Delete(context.TODO(), name)
if err != nil {
return err
}
if err = handleResponse(resp); err != nil {
return err
}
return nil
}
// getRecords returns a list of godo.DomainRecord given a zone name
func getRecords(c *godo.Client, zoneName string) ([]godo.DomainRecord, error) {
records, resp, err := c.Domains.Records(context.TODO(), zoneName, &godo.ListOptions{})
records, _, err := c.Domains.Records(context.TODO(), zoneName, &godo.ListOptions{})
if err != nil {
return nil, err
}
if err = handleResponse(resp); err != nil {
return nil, err
}
return records, nil
}
// createRecord creates a record given an associated zone and a godo.DomainRecordEditRequest
func createRecord(c *godo.Client, zoneName string, createRequest *godo.DomainRecordEditRequest) error {
_, resp, err := c.Domains.CreateRecord(context.TODO(), zoneName, createRequest)
_, _, err := c.Domains.CreateRecord(context.TODO(), zoneName, createRequest)
if err != nil {
return fmt.Errorf("error applying changeset: %v", err)
}
if err = handleResponse(resp); err != nil {
return err
}
return nil
}
// editRecord edits a record given an associated ozone and a godo.DomainRecordEditRequest
func editRecord(c *godo.Client, zoneName string, recordID int, editRequest *godo.DomainRecordEditRequest) error {
_, resp, err := c.Domains.EditRecord(context.TODO(), zoneName, recordID, editRequest)
_, _, err := c.Domains.EditRecord(context.TODO(), zoneName, recordID, editRequest)
if err != nil {
return fmt.Errorf("error applying changeset: %v", err)
}
if err = handleResponse(resp); err != nil {
return err
}
return nil
}
// deleteRecord deletes a record given an associated zone and a record ID
func deleteRecord(c *godo.Client, zoneName string, recordID int) error {
resp, err := c.Domains.DeleteRecord(context.TODO(), zoneName, recordID)
_, err := c.Domains.DeleteRecord(context.TODO(), zoneName, recordID)
if err != nil {
return fmt.Errorf("error applying changeset: %v", err)
}
if err = handleResponse(resp); err != nil {
return err
}
return nil
}
func handleResponse(resp *godo.Response) error {
if resp.StatusCode != http.StatusOK {
respData, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response body: %v", err)
}
return fmt.Errorf("received non 200 status code: %d from api: %v",
resp.StatusCode, string(respData))
}
return nil
}

View File

@ -91,11 +91,7 @@ func TestZonesList(t *testing.T) {
},
}
resp := &godo.Response{
Response: &http.Response{},
}
resp.StatusCode = http.StatusOK
return domains, resp, nil
return domains, nil, nil
}
client.Domains = fake
@ -122,12 +118,7 @@ func TestZonesList(t *testing.T) {
},
}
resp := &godo.Response{
Response: &http.Response{},
}
resp.StatusCode = http.StatusInternalServerError
resp.Body = ioutil.NopCloser(bytes.NewBufferString("error!"))
return domains, resp, nil
return domains, nil, errors.New("internal error!")
}
client.Domains = fake
@ -191,13 +182,8 @@ func TestAdd(t *testing.T) {
// bad status code
fake.createFunc = func(ctx context.Context, domainCreateRequest *godo.DomainCreateRequest) (*godo.Domain, *godo.Response, error) {
domain := &godo.Domain{Name: domainCreateRequest.Name}
resp := &godo.Response{
Response: &http.Response{},
}
resp.StatusCode = http.StatusInternalServerError
resp.Body = ioutil.NopCloser(bytes.NewBufferString("error!"))
return domain, resp, nil
return domain, nil, errors.New("bad response!")
}
client.Domains = fake
@ -218,11 +204,8 @@ func TestAdd(t *testing.T) {
// godo returns error
fake.createFunc = func(ctx context.Context, domainCreateRequest *godo.DomainCreateRequest) (*godo.Domain, *godo.Response, error) {
domain := &godo.Domain{Name: domainCreateRequest.Name}
resp := &godo.Response{
Response: &http.Response{},
}
return domain, resp, errors.New("error!")
return domain, nil, errors.New("error!")
}
client.Domains = fake
@ -247,11 +230,7 @@ func TestRemove(t *testing.T) {
// happy path
fake.deleteFunc = func(ctx context.Context, name string) (*godo.Response, error) {
resp := &godo.Response{
Response: &http.Response{},
}
resp.StatusCode = http.StatusOK
return resp, nil
return nil, nil
}
client.Domains = fake
@ -266,12 +245,7 @@ func TestRemove(t *testing.T) {
// bad status code
fake.deleteFunc = func(ctx context.Context, name string) (*godo.Response, error) {
resp := &godo.Response{
Response: &http.Response{},
}
resp.StatusCode = http.StatusInternalServerError
resp.Body = ioutil.NopCloser(bytes.NewBufferString("error!"))
return resp, nil
return nil, errors.New("bad response!")
}
client.Domains = fake

View File

@ -36,6 +36,7 @@ import (
"k8s.io/kops/pkg/model"
"k8s.io/kops/pkg/model/awsmodel"
"k8s.io/kops/pkg/model/components"
"k8s.io/kops/pkg/model/domodel"
"k8s.io/kops/pkg/model/gcemodel"
"k8s.io/kops/pkg/model/vspheremodel"
"k8s.io/kops/pkg/resources/digitalocean"
@ -351,8 +352,11 @@ func (c *ApplyClusterCmd) Run() error {
return fmt.Errorf("DigitalOcean support is currently (very) alpha and is feature-gated. export KOPS_FEATURE_FLAGS=AlphaAllowDO to enable it")
}
modelContext.SSHPublicKeys = sshPublicKeys
l.AddTypes(map[string]interface{}{
"volume": &dotasks.Volume{},
"volume": &dotasks.Volume{},
"droplet": &dotasks.Droplet{},
})
}
case kops.CloudProviderAWS:
@ -686,7 +690,15 @@ func (c *ApplyClusterCmd) Run() error {
Lifecycle: clusterLifecycle,
})
case kops.CloudProviderDO:
// DigitalOcean tasks will go here
doModelContext := &domodel.DOModelContext{
KopsModelContext: modelContext,
}
l.Builders = append(l.Builders, &domodel.DropletBuilder{
DOModelContext: doModelContext,
BootstrapScript: bootstrapScriptBuilder,
Lifecycle: clusterLifecycle,
})
case kops.CloudProviderGCE:
{
gceModelContext := &gcemodel.GCEModelContext{

View File

@ -0,0 +1,136 @@
/*
Copyright 2016 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dotasks
import (
"context"
"strconv"
"github.com/digitalocean/godo"
"k8s.io/kops/pkg/resources/digitalocean"
"k8s.io/kops/upup/pkg/fi"
"k8s.io/kops/upup/pkg/fi/cloudup/do"
_ "k8s.io/kops/upup/pkg/fi/cloudup/terraform"
)
//go:generate fitask -type=Droplet
type Droplet struct {
Name *string
ID *string
Lifecycle *fi.Lifecycle
Region *string
Size *string
Image *string
SSHKey *string
Tags []string
UserData *fi.ResourceHolder
}
var _ fi.CompareWithID = &Droplet{}
func (d *Droplet) CompareWithID() *string {
return d.ID
}
func (d *Droplet) Find(c *fi.Context) (*Droplet, error) {
cloud := c.Cloud.(*digitalocean.Cloud)
dropletService := cloud.Droplets()
droplets, _, err := dropletService.List(context.TODO(), &godo.ListOptions{})
if err != nil {
return nil, err
}
for _, droplet := range droplets {
if droplet.Name == fi.StringValue(d.Name) {
return &Droplet{
Name: fi.String(droplet.Name),
ID: fi.String(strconv.Itoa(droplet.ID)),
Region: fi.String(droplet.Region.Slug),
Size: fi.String(droplet.Size.Slug),
Image: fi.String(droplet.Image.Slug),
Lifecycle: d.Lifecycle,
}, nil
}
}
return nil, nil
}
func (d *Droplet) Run(c *fi.Context) error {
return fi.DefaultDeltaRunMethod(d, c)
}
func (_ *Droplet) RenderDO(t *do.DOAPITarget, a, e, changes *Droplet) error {
if a != nil {
return nil
}
userData, err := e.UserData.AsString()
if err != nil {
return err
}
dropletService := t.Cloud.Droplets()
_, _, err = dropletService.Create(context.TODO(), &godo.DropletCreateRequest{
Name: fi.StringValue(e.Name),
Region: fi.StringValue(e.Region),
Size: fi.StringValue(e.Size),
Image: godo.DropletCreateImage{Slug: fi.StringValue(e.Image)},
PrivateNetworking: true,
Tags: e.Tags,
UserData: userData,
SSHKeys: []godo.DropletCreateSSHKey{{Fingerprint: fi.StringValue(e.SSHKey)}},
})
return err
}
func (_ *Droplet) CheckChanges(a, e, changes *Droplet) error {
if a != nil {
if changes.Name != nil {
return fi.CannotChangeField("Name")
}
if changes.ID != nil {
return fi.CannotChangeField("ID")
}
if changes.Region != nil {
return fi.CannotChangeField("Region")
}
if changes.Size != nil {
return fi.CannotChangeField("Size")
}
if changes.Image != nil {
return fi.CannotChangeField("Image")
}
} else {
if e.Name == nil {
return fi.RequiredField("Name")
}
if e.Region == nil {
return fi.RequiredField("Region")
}
if e.Size == nil {
return fi.RequiredField("Size")
}
if e.Image == nil {
return fi.RequiredField("Image")
}
}
return nil
}

View File

@ -0,0 +1,70 @@
/*
Copyright 2016 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Code generated by ""fitask" -type=Droplet"; DO NOT EDIT
package dotasks
import (
"encoding/json"
"k8s.io/kops/upup/pkg/fi"
)
// Droplet
// JSON marshalling boilerplate
type realDroplet Droplet
// UnmarshalJSON implements conversion to JSON, supporitng an alternate specification of the object as a string
func (o *Droplet) UnmarshalJSON(data []byte) error {
var jsonName string
if err := json.Unmarshal(data, &jsonName); err == nil {
o.Name = &jsonName
return nil
}
var r realDroplet
if err := json.Unmarshal(data, &r); err != nil {
return err
}
*o = Droplet(r)
return nil
}
var _ fi.HasLifecycle = &Droplet{}
// GetLifecycle returns the Lifecycle of the object, implementing fi.HasLifecycle
func (o *Droplet) GetLifecycle() *fi.Lifecycle {
return o.Lifecycle
}
var _ fi.HasName = &Droplet{}
// GetName returns the Name of the object, implementing fi.HasName
func (o *Droplet) GetName() *string {
return o.Name
}
// SetName sets the Name of the object, implementing fi.SetName
func (o *Droplet) SetName(name string) {
o.Name = &name
}
// String is the stringer function for the task, producing readable output using fi.TaskAsString
func (o *Droplet) String() string {
return fi.TaskAsString(o)
}