Support Tags on aws.ec2.Instance

Also adds test coverage for aws.ec2.Instance resources.
This commit is contained in:
Luke Hoban 2017-06-02 10:59:57 -07:00
parent 72919f7526
commit 5d2dffdcc9
7 changed files with 412 additions and 159 deletions

View file

@ -31,6 +31,8 @@ type Instance struct {
SecurityGroups *[]*SecurityGroup `lumi:"securityGroups,optional,replaces"`
// Provides the name of the Amazon EC2 key pair.
KeyName *string `lumi:"keyName,optional"`
// Provides a list of tags to attach to the instance.
Tags *[]Tag `lumi:"tags,optional"`
// Output properties:
@ -46,6 +48,12 @@ type Instance struct {
PublicIP *string `lumi:"publicIP,out,optional"`
}
// A Tag applied to an EC2 instance.
type Tag struct {
Key string `lumi:"key"`
Value string `lumi:"value"`
}
// InstanceType is an enum type with all the names of instance types available in EC2.
type InstanceType string

View file

@ -69,6 +69,7 @@ export class Instance extends lumi.Resource implements InstanceArgs {
public instanceType?: InstanceType;
public readonly securityGroups?: SecurityGroup[];
public keyName?: string;
public tags?: Tag[];
@lumi.out public availabilityZone: string;
@lumi.out public privateDNSName?: string;
@lumi.out public publicDNSName?: string;
@ -88,6 +89,7 @@ export class Instance extends lumi.Resource implements InstanceArgs {
this.instanceType = args.instanceType;
this.securityGroups = args.securityGroups;
this.keyName = args.keyName;
this.tags = args.tags;
}
}
@ -96,6 +98,7 @@ export interface InstanceArgs {
instanceType?: InstanceType;
readonly securityGroups?: SecurityGroup[];
keyName?: string;
tags?: Tag[];
}
export type InstanceType =
@ -157,4 +160,9 @@ export type InstanceType =
"x1.16xlarge" |
"x1.32xlarge";
export interface Tag {
key: string;
value: string;
}

View file

@ -4,45 +4,96 @@ import (
"fmt"
"testing"
"encoding/json"
"strings"
"github.com/aws/aws-sdk-go/aws"
awsdynamodb "github.com/aws/aws-sdk-go/service/dynamodb"
structpb "github.com/golang/protobuf/ptypes/struct"
"github.com/pulumi/lumi/lib/aws/provider/awsctx"
"github.com/pulumi/lumi/lib/aws/provider/testutil"
"github.com/pulumi/lumi/lib/aws/rpc/dynamodb"
"github.com/pulumi/lumi/pkg/resource"
"github.com/pulumi/lumi/sdk/go/pkg/lumirpc"
"github.com/stretchr/testify/assert"
)
const TABLENAMEPREFIX = "lumitest"
const RESOURCEPREFIX = "lumitest"
func Test(t *testing.T) {
t.Parallel()
ctx, err := awsctx.New()
assert.Nil(t, err, "expected no error getting AWS context")
cleanup(ctx)
testutil.ProviderTest(t, NewTableProvider(ctx), TableToken, []interface{}{
&dynamodb.Table{
Name: aws.String(RESOURCEPREFIX),
Attributes: []dynamodb.Attribute{
{Name: "Album", Type: "S"},
{Name: "Artist", Type: "S"},
{Name: "Sales", Type: "N"},
},
HashKey: "Album",
RangeKey: aws.String("Artist"),
ReadCapacity: 2,
WriteCapacity: 2,
GlobalSecondaryIndexes: &[]dynamodb.GlobalSecondaryIndex{
{
IndexName: "myGSI",
HashKey: "Sales",
RangeKey: aws.String("Artist"),
ReadCapacity: 1,
WriteCapacity: 1,
NonKeyAttributes: []string{"Album"},
ProjectionType: "INCLUDE",
},
},
},
&dynamodb.Table{
Name: aws.String(RESOURCEPREFIX),
Attributes: []dynamodb.Attribute{
{Name: "Album", Type: "S"},
{Name: "Artist", Type: "S"},
{Name: "NumberOfSongs", Type: "N"},
{Name: "Sales", Type: "N"},
},
HashKey: "Album",
RangeKey: aws.String("Artist"),
ReadCapacity: 1,
WriteCapacity: 1,
GlobalSecondaryIndexes: &[]dynamodb.GlobalSecondaryIndex{
{
IndexName: "myGSI",
HashKey: "Sales",
RangeKey: aws.String("Artist"),
ReadCapacity: 1,
WriteCapacity: 1,
NonKeyAttributes: []string{"Album"},
ProjectionType: "INCLUDE",
},
{
IndexName: "myGSI2",
HashKey: "NumberOfSongs",
RangeKey: aws.String("Sales"),
NonKeyAttributes: []string{"Album", "Artist"},
ProjectionType: "INCLUDE",
ReadCapacity: 1,
WriteCapacity: 1,
},
},
},
})
func marshal(table dynamodb.Table) (*structpb.Struct, error) {
byts, err := json.Marshal(table)
if err != nil {
return nil, err
}
var obj map[string]interface{}
err = json.Unmarshal(byts, &obj)
if err != nil {
return nil, err
}
props := resource.NewPropertyMapFromMap(obj)
return resource.MarshalProperties(nil, props, resource.MarshalOptions{}), nil
}
func cleanup(ctx *awsctx.Context) {
fmt.Printf("Cleaning up tables with prefix: %v\n", TABLENAMEPREFIX)
fmt.Printf("Cleaning up tables with prefix: %v\n", RESOURCEPREFIX)
list, err := ctx.DynamoDB().ListTables(&awsdynamodb.ListTablesInput{})
if err != nil {
return
}
cleaned := 0
for _, table := range list.TableNames {
if strings.HasPrefix(aws.StringValue(table), TABLENAMEPREFIX) {
if strings.HasPrefix(aws.StringValue(table), RESOURCEPREFIX) {
ctx.DynamoDB().DeleteTable(&awsdynamodb.DeleteTableInput{
TableName: table,
})
@ -51,135 +102,3 @@ func cleanup(ctx *awsctx.Context) {
}
fmt.Printf("Cleaned up %v tables\n", cleaned)
}
func Test(t *testing.T) {
t.Parallel()
// Create a TableProvider
ctx, err := awsctx.New()
assert.Nil(t, err, "expected no error getting AWS context")
tableProvider := NewTableProvider(ctx)
defer cleanup(ctx)
// Table to create
tablename := TABLENAMEPREFIX
table := dynamodb.Table{
Name: &tablename,
Attributes: []dynamodb.Attribute{
{Name: "Album", Type: "S"},
{Name: "Artist", Type: "S"},
{Name: "Sales", Type: "N"},
},
HashKey: "Album",
RangeKey: aws.String("Artist"),
ReadCapacity: 2,
WriteCapacity: 2,
GlobalSecondaryIndexes: &[]dynamodb.GlobalSecondaryIndex{
{
IndexName: "myGSI",
HashKey: "Sales",
RangeKey: aws.String("Artist"),
ReadCapacity: 1,
WriteCapacity: 1,
NonKeyAttributes: []string{"Album"},
ProjectionType: "INCLUDE",
},
},
}
props, err := marshal(table)
if !assert.NoError(t, err, "expected no error marshaling object to protobuf") {
return
}
checkResp, err := tableProvider.Check(nil, &lumirpc.CheckRequest{
Type: string(TableToken),
Properties: props,
})
if !assert.NoError(t, err, "expected no error checking table") {
return
}
assert.Equal(t, 0, len(checkResp.Failures), "expected no check failures")
// Invoke Create request
resp, err := tableProvider.Create(nil, &lumirpc.CreateRequest{
Type: string(TableToken),
Properties: props,
})
if !assert.NoError(t, err, "expected no error creating resource") {
return
}
if !assert.NotNil(t, resp, "expected a non-nil response") {
return
}
id := resp.Id
assert.Contains(t, id, "lumitest", "expected resource ID to contain `lumitest`")
// Table for update
tablename2 := "lumitest"
table2 := dynamodb.Table{
Name: &tablename2,
Attributes: []dynamodb.Attribute{
{Name: "Album", Type: "S"},
{Name: "Artist", Type: "S"},
{Name: "NumberOfSongs", Type: "N"},
{Name: "Sales", Type: "N"},
},
HashKey: "Album",
RangeKey: aws.String("Artist"),
ReadCapacity: 1,
WriteCapacity: 1,
GlobalSecondaryIndexes: &[]dynamodb.GlobalSecondaryIndex{
{
IndexName: "myGSI",
HashKey: "Sales",
RangeKey: aws.String("Artist"),
ReadCapacity: 1,
WriteCapacity: 1,
NonKeyAttributes: []string{"Album"},
ProjectionType: "INCLUDE",
},
{
IndexName: "myGSI2",
HashKey: "NumberOfSongs",
RangeKey: aws.String("Sales"),
NonKeyAttributes: []string{"Album", "Artist"},
ProjectionType: "INCLUDE",
ReadCapacity: 1,
WriteCapacity: 1,
},
},
}
props2, err := marshal(table2)
if !assert.NoError(t, err, "expected no error marshaling object to protobuf") {
return
}
checkResp, err = tableProvider.Check(nil, &lumirpc.CheckRequest{
Type: string(TableToken),
Properties: props2,
})
if !assert.NoError(t, err, "expected no error checking table") {
return
}
assert.Equal(t, 0, len(checkResp.Failures), "expected no check failures")
// Invoke Update request
_, err = tableProvider.Update(nil, &lumirpc.UpdateRequest{
Type: string(TableToken),
Id: id,
Olds: props,
News: props2,
})
if !assert.NoError(t, err, "expected no error creating resource") {
return
}
// Invoke the Delete request
_, err = tableProvider.Delete(nil, &lumirpc.DeleteRequest{
Type: string(TableToken),
Id: id,
})
if !assert.NoError(t, err, "expected no error deleting resource") {
return
}
}

View file

@ -89,13 +89,28 @@ func (p *instanceProvider) Create(ctx context.Context, obj *ec2.Instance) (resou
its := string(*obj.InstanceType)
instanceType = &its
}
var tagSpecifications []*awsec2.TagSpecification
if obj.Tags != nil {
var tags []*awsec2.Tag
for _, tag := range *obj.Tags {
tags = append(tags, &awsec2.Tag{
Key: aws.String(tag.Key),
Value: aws.String(tag.Value),
})
}
tagSpecifications = []*awsec2.TagSpecification{{
ResourceType: aws.String("instance"),
Tags: tags,
}}
}
create := &awsec2.RunInstancesInput{
ImageId: aws.String(obj.ImageID),
InstanceType: instanceType,
SecurityGroupIds: secgrpIDs,
KeyName: obj.KeyName,
MinCount: aws.Int64(int64(1)),
MaxCount: aws.Int64(int64(1)),
ImageId: aws.String(obj.ImageID),
InstanceType: instanceType,
SecurityGroupIds: secgrpIDs,
KeyName: obj.KeyName,
MinCount: aws.Int64(int64(1)),
MaxCount: aws.Int64(int64(1)),
TagSpecifications: tagSpecifications,
}
// Now go ahead and perform the action.
@ -159,6 +174,14 @@ func (p *instanceProvider) Get(ctx context.Context, id resource.ID) (*ec2.Instan
secgrpIDs = &ids
}
var tags []ec2.Tag
for _, tag := range inst.Tags {
tags = append(tags, ec2.Tag{
Key: aws.StringValue(tag.Key),
Value: aws.StringValue(tag.Value),
})
}
instanceType := ec2.InstanceType(aws.StringValue(inst.InstanceType))
return &ec2.Instance{
ImageID: aws.StringValue(inst.ImageId),
@ -170,6 +193,7 @@ func (p *instanceProvider) Get(ctx context.Context, id resource.ID) (*ec2.Instan
PublicDNSName: inst.PublicDnsName,
PrivateIP: inst.PrivateIpAddress,
PublicIP: inst.PublicIpAddress,
Tags: &tags,
}, nil
}
@ -184,7 +208,50 @@ func (p *instanceProvider) InspectChange(ctx context.Context, id resource.ID,
// to new values. The resource ID is returned and may be different if the resource had to be recreated.
func (p *instanceProvider) Update(ctx context.Context, id resource.ID,
old *ec2.Instance, new *ec2.Instance, diff *resource.ObjectDiff) error {
return errors.New("No known updatable instance properties")
iid, err := arn.ParseResourceName(id)
if err != nil {
return err
}
if diff.Changed(ec2.Instance_Tags) {
newTagSet := newTagHashSet(new.Tags)
oldTagSet := newTagHashSet(old.Tags)
d := oldTagSet.Diff(newTagSet)
var addOrUpdateTags []*awsec2.Tag
for _, o := range d.AddOrUpdates() {
option := o.(tagHash).item
addOrUpdateTags = append(addOrUpdateTags, &awsec2.Tag{
Key: aws.String(option.Key),
Value: aws.String(option.Value),
})
}
if len(addOrUpdateTags) > 0 {
_, err := p.ctx.EC2().CreateTags(&awsec2.CreateTagsInput{
Resources: []*string{aws.String(iid)},
Tags: addOrUpdateTags,
})
if err != nil {
return err
}
}
var deleteTags []*awsec2.Tag
for _, o := range d.Deletes() {
option := o.(tagHash).item
deleteTags = append(deleteTags, &awsec2.Tag{
Key: aws.String(option.Key),
Value: nil,
})
}
if len(deleteTags) > 0 {
_, err = p.ctx.EC2().DeleteTags(&awsec2.DeleteTagsInput{
Resources: []*string{aws.String(iid)},
Tags: deleteTags,
})
if err != nil {
return err
}
}
}
return nil
}
// Delete tears down an existing resource with the given ID. If it fails, the resource is assumed to still exist.
@ -203,3 +270,26 @@ func (p *instanceProvider) Delete(ctx context.Context, id resource.ID) error {
return p.ctx.EC2().WaitUntilInstanceTerminated(
&awsec2.DescribeInstancesInput{InstanceIds: []*string{aws.String(iid)}})
}
type tagHash struct {
item ec2.Tag
}
var _ awsctx.Hashable = tagHash{}
func (option tagHash) HashKey() awsctx.Hash {
return awsctx.Hash(option.item.Key)
}
func (option tagHash) HashValue() awsctx.Hash {
return awsctx.Hash(option.item.Key + ":" + option.item.Value)
}
func newTagHashSet(options *[]ec2.Tag) *awsctx.HashSet {
set := awsctx.NewHashSet()
if options == nil {
return set
}
for _, option := range *options {
set.Add(tagHash{option})
}
return set
}

View file

@ -0,0 +1,102 @@
package ec2
import (
"fmt"
"testing"
"github.com/aws/aws-sdk-go/aws"
awsec2 "github.com/aws/aws-sdk-go/service/ec2"
"github.com/pulumi/lumi/lib/aws/provider/awsctx"
"github.com/pulumi/lumi/lib/aws/provider/testutil"
"github.com/pulumi/lumi/lib/aws/rpc/ec2"
"github.com/stretchr/testify/assert"
)
const RESOURCEPREFIX = "lumitest"
var amis = map[string]string{
"us-east-1": "ami-6869aa05",
"us-west-2": "ami-7172b611",
"us-west-1": "ami-31490d51",
"eu-west-1": "ami-f9dd458a",
"eu-west-2": "ami-886369ec",
"eu-central-1": "ami-ea26ce85",
"ap-northeast-1": "ami-374db956",
"ap-northeast-2": "ami-2b408b45",
"ap-southeast-1": "ami-a59b49c6",
"ap-southeast-2": "ami-dc361ebf",
"ap-south-1": "ami-ffbdd790",
"us-east-2": "ami-f6035893",
"ca-central-1": "ami-730ebd17",
"sa-east-1": "ami-6dd04501",
"cn-north-1": "ami-8e6aa0e3",
}
func Test(t *testing.T) {
t.Parallel()
ctx, err := awsctx.New()
assert.Nil(t, err, "expected no error getting AWS context")
cleanup(ctx)
instanceType := ec2.InstanceType("t2.nano")
testutil.ProviderTest(t, NewInstanceProvider(ctx), InstanceToken, []interface{}{
&ec2.Instance{
Name: aws.String(RESOURCEPREFIX),
InstanceType: &instanceType,
ImageID: amis[ctx.Region()],
Tags: &[]ec2.Tag{{
Key: RESOURCEPREFIX,
Value: RESOURCEPREFIX,
}},
},
&ec2.Instance{
Name: aws.String(RESOURCEPREFIX),
InstanceType: &instanceType,
ImageID: amis[ctx.Region()],
Tags: &[]ec2.Tag{{
Key: RESOURCEPREFIX,
Value: RESOURCEPREFIX,
}, {
Key: "Hello",
Value: "World",
}},
},
})
}
func cleanup(ctx *awsctx.Context) {
fmt.Printf("Cleaning up instances with tag:%v=%v\n", RESOURCEPREFIX, RESOURCEPREFIX)
list, err := ctx.EC2().DescribeInstances(&awsec2.DescribeInstancesInput{
Filters: []*awsec2.Filter{{
Name: aws.String("tag:" + RESOURCEPREFIX),
Values: []*string{aws.String(RESOURCEPREFIX)},
}},
})
if err != nil {
return
}
cleaned := 0
instanceIds := []*string{}
for _, reservation := range list.Reservations {
for _, instance := range reservation.Instances {
if aws.StringValue(instance.State.Name) != awsec2.InstanceStateNameTerminated {
instanceIds = append(instanceIds, instance.InstanceId)
cleaned++
}
}
}
if len(instanceIds) > 0 {
_, err = ctx.EC2().TerminateInstances(&awsec2.TerminateInstancesInput{
InstanceIds: instanceIds,
})
if err != nil {
fmt.Printf("Failed cleaning up %v tables: %v\n", cleaned, err)
} else {
fmt.Printf("Cleaned up %v tables\n", cleaned)
}
}
}

View file

@ -0,0 +1,110 @@
package testutil
import (
"testing"
structpb "github.com/golang/protobuf/ptypes/struct"
"github.com/pulumi/lumi/pkg/resource"
"github.com/pulumi/lumi/pkg/tokens"
"github.com/pulumi/lumi/sdk/go/pkg/lumirpc"
"github.com/stretchr/testify/assert"
)
// ProviderTest takes a resource provider and array of resource steps and performs a Create, as many Udpates
// as neeed, and finally a Delete operation to walk the resource through the requested lifecycle. It also
// performs Check operations on each provided resource.
func ProviderTest(t *testing.T, provider lumirpc.ResourceProviderServer, token tokens.Type, steps []interface{}) {
p := &providerTest{
t: t,
provider: provider,
token: token,
res: nil,
lastProps: nil,
}
id := ""
for _, res := range steps {
p.res = res
if id == "" {
id = p.createResource()
if id == "" {
t.Fatal("expected to succesfully create resource")
}
} else {
if !p.updateResource(id) {
t.Fatal("expected to succesfully update resource")
}
}
}
if !p.deleteResource(id) {
t.Fatal("expected to succesfully delete resource")
}
}
type providerTest struct {
t *testing.T
provider lumirpc.ResourceProviderServer
token tokens.Type
res interface{}
lastProps *structpb.Struct
}
func (p *providerTest) createResource() string {
props := resource.MarshalProperties(nil, resource.NewPropertyMap(p.res), resource.MarshalOptions{})
checkResp, err := p.provider.Check(nil, &lumirpc.CheckRequest{
Type: string(p.token),
Properties: props,
})
if !assert.NoError(p.t, err, "expected no error checking table") {
return ""
}
assert.Equal(p.t, 0, len(checkResp.Failures), "expected no check failures")
resp, err := p.provider.Create(nil, &lumirpc.CreateRequest{
Type: string(p.token),
Properties: props,
})
if !assert.NoError(p.t, err, "expected no error creating resource") {
return ""
}
if !assert.NotNil(p.t, resp, "expected a non-nil response") {
return ""
}
id := resp.Id
p.lastProps = props
return id
}
func (p *providerTest) updateResource(id string) bool {
newProps := resource.MarshalProperties(nil, resource.NewPropertyMap(p.res), resource.MarshalOptions{})
checkResp, err := p.provider.Check(nil, &lumirpc.CheckRequest{
Type: string(p.token),
Properties: newProps,
})
if !assert.NoError(p.t, err, "expected no error checking resource") {
return false
}
assert.Equal(p.t, 0, len(checkResp.Failures), "expected no check failures")
_, err = p.provider.Update(nil, &lumirpc.UpdateRequest{
Type: string(p.token),
Id: id,
Olds: p.lastProps,
News: newProps,
})
if !assert.NoError(p.t, err, "expected no error creating resource") {
return false
}
p.lastProps = newProps
return true
}
func (p *providerTest) deleteResource(id string) bool {
_, err := p.provider.Delete(nil, &lumirpc.DeleteRequest{
Type: string(p.token),
Id: id,
})
if !assert.NoError(p.t, err, "expected no error deleting resource") {
return false
}
return true
}

View file

@ -182,6 +182,7 @@ type Instance struct {
InstanceType *InstanceType `json:"instanceType,omitempty"`
SecurityGroups *[]resource.ID `json:"securityGroups,omitempty"`
KeyName *string `json:"keyName,omitempty"`
Tags *[]Tag `json:"tags,omitempty"`
AvailabilityZone string `json:"availabilityZone,omitempty"`
PrivateDNSName *string `json:"privateDNSName,omitempty"`
PublicDNSName *string `json:"publicDNSName,omitempty"`
@ -196,6 +197,7 @@ const (
Instance_InstanceType = "instanceType"
Instance_SecurityGroups = "securityGroups"
Instance_KeyName = "keyName"
Instance_Tags = "tags"
Instance_AvailabilityZone = "availabilityZone"
Instance_PrivateDNSName = "privateDNSName"
Instance_PublicDNSName = "publicDNSName"
@ -203,6 +205,20 @@ const (
Instance_PublicIP = "publicIP"
)
/* Marshalable Tag structure(s) */
// Tag is a marshalable representation of its corresponding IDL type.
type Tag struct {
Key string `json:"key"`
Value string `json:"value"`
}
// Tag's properties have constants to make dealing with diffs and property bags easier.
const (
Tag_Key = "key"
Tag_Value = "value"
)
/* Typedefs */
type (