diff --git a/cmd/apply.go b/cmd/apply.go index df3b79a74..01900ae06 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -16,6 +16,7 @@ import ( func newApplyCmd() *cobra.Command { var delete bool + var detailed bool var cmd = &cobra.Command{ Use: "apply [blueprint] [-- [args]]", Short: "Apply a deployment plan from a Mu blueprint", @@ -33,7 +34,8 @@ func newApplyCmd() *cobra.Command { "a path to a blueprint elsewhere can be provided as the [blueprint] argument.", Run: func(cmd *cobra.Command, args []string) { if comp, plan := plan(cmd, args, delete); plan != nil { - if err, _, _ := plan.Apply(&applyProgress{}); err != nil { + progress := newProgress(detailed) + if err, _, _ := plan.Apply(progress); err != nil { // TODO: we want richer diagnostics in the event that a plan apply fails. For instance, we want to // know precisely what step failed, we want to know whether it was catastrophic, etc. We also // probably want to plumb diag.Sink through apply so it can issue its own rich diagnostics. @@ -47,20 +49,28 @@ func newApplyCmd() *cobra.Command { cmd.PersistentFlags().BoolVar( &delete, "delete", false, "Delete the entirety of the blueprint's resources") + cmd.PersistentFlags().BoolVar( + &detailed, "detailed", false, + "Display detailed output during the application of changes") return cmd } // applyProgress pretty-prints the plan application process as it goes. type applyProgress struct { - c int + c int + detailed bool +} + +func newProgress(detailed bool) *applyProgress { + return &applyProgress{detailed: detailed} } func (prog *applyProgress) Before(step resource.Step) { var b bytes.Buffer prog.c++ b.WriteString(fmt.Sprintf("Applying step #%v\n", prog.c)) - printStep(&b, step, true, " ") + printStep(&b, step, !prog.detailed, " ") s := colors.Colorize(b.String()) fmt.Printf(s) } diff --git a/lib/aws/ec2/instance.ts b/lib/aws/ec2/instance.ts index 2bebca9e4..e2cb9ec4f 100644 --- a/lib/aws/ec2/instance.ts +++ b/lib/aws/ec2/instance.ts @@ -17,12 +17,12 @@ export class Instance extends cloudformation.Resource { } export interface InstanceArgs extends cloudformation.TagArgs { - // The instance type, such as t2.micro. The default type is "m3.medium". - instanceType: string; - // A list that contains the Amazon EC2 security groups to assign to the Amazon EC2 instance. - securityGroups: SecurityGroup[]; - // Provides the name of the Amazon EC2 key pair. - keyName: string; // Provides the unique ID of the Amazon Machine Image (AMI) that was assigned during registration. imageId: string; + // The instance type, such as t2.micro. The default type is "m3.medium". + instanceType?: string; + // A list that contains the Amazon EC2 security groups to assign to the Amazon EC2 instance. + securityGroups?: SecurityGroup[]; + // Provides the name of the Amazon EC2 key pair. + keyName?: string; } diff --git a/lib/aws/install.sh b/lib/aws/install.sh index 38ede9971..ca027a98e 100755 --- a/lib/aws/install.sh +++ b/lib/aws/install.sh @@ -4,7 +4,10 @@ set -e # bail on errors echo Compiling: -mujs # compile the package +mujs # compile the MuPackage +pushd provider/ && # compile the resource provider + go build -o ../bin/mu-ressrv-aws && + popd echo Sharing NPM links: yarn link # let NPM references resolve easily. diff --git a/lib/aws/provider/awsctx/context.go b/lib/aws/provider/awsctx/context.go new file mode 100644 index 000000000..57b11cf1f --- /dev/null +++ b/lib/aws/provider/awsctx/context.go @@ -0,0 +1,39 @@ +// Copyright 2016 Marapongo, Inc. All rights reserved. + +package awsctx + +import ( + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/marapongo/mu/pkg/util/contract" +) + +// Context represents state shared amongst all parties in this process. In particular, it wraps an AWS session +// object and offers convenient wrappers for creating connections to the various sub-services (EC2, S3, etc). +type Context struct { + sess *session.Session + ec2 *ec2.EC2 +} + +func New() (*Context, error) { + // Create an AWS session; note that this is safe to share among many operations. + // TODO: consider verifying credentials, region, etc. here. + // TODO: currently we just inherit the standard AWS SDK credentials logic; eventually we will want more + // flexibility, I assume, including possibly reading from configuration dynamically. + sess, err := session.NewSession() + if err != nil { + return nil, err + } + // Allocate a new global context with this session; note that all other connections are lazily allocated. + return &Context{ + sess: sess, + }, nil +} + +func (ctx *Context) EC2() *ec2.EC2 { + contract.Assert(ctx.sess != nil) + if ctx.ec2 == nil { + ctx.ec2 = ec2.New(ctx.sess) + } + return ctx.ec2 +} diff --git a/lib/aws/provider/ec2/instance.go b/lib/aws/provider/ec2/instance.go new file mode 100644 index 000000000..aaf98da45 --- /dev/null +++ b/lib/aws/provider/ec2/instance.go @@ -0,0 +1,126 @@ +// Copyright 2016 Marapongo, Inc. All rights reserved. + +package ec2 + +import ( + "errors" + + "github.com/aws/aws-sdk-go/service/ec2" + pbempty "github.com/golang/protobuf/ptypes/empty" + "github.com/marapongo/mu/pkg/resource" + "github.com/marapongo/mu/pkg/tokens" + "github.com/marapongo/mu/pkg/util/contract" + "github.com/marapongo/mu/sdk/go/pkg/murpc" + "golang.org/x/net/context" + + "github.com/marapongo/mu/lib/aws/provider/awsctx" +) + +const Instance = tokens.Type("aws:ec2/instance:Instance") + +// NewInstanceProvider creates a provider that handles EC2 instance operations. +func NewInstanceProvider(ctx *awsctx.Context) murpc.ResourceProviderServer { + return &instanceProvider{} +} + +type instanceProvider struct { + ctx *awsctx.Context +} + +// Create allocates a new instance of the provided resource and returns its unique ID afterwards. (The input ID +// must be blank.) If this call fails, the resource must not have been created (i.e., it is "transacational"). +func (p *instanceProvider) Create(ctx context.Context, req *murpc.CreateRequest) (*murpc.CreateResponse, error) { + contract.Assert(req.GetType() == string(Instance)) + props := resource.UnmarshalProperties(req.GetProperties()) + + // Read in the properties given by the request, validating as we go; if any fail, reject the request. + // TODO: this is a good example of a "benign" (StateOK) error; handle it accordingly. + inst, err := newInstance(props, true) + if err != nil { + return nil, err + } + + // Create the create instances request object. + var secgrpIDs []*string + if inst.SecurityGroupIDs != nil { + for _, sid := range *inst.SecurityGroupIDs { + secgrpIDs = append(secgrpIDs, &sid) + } + } + create := &ec2.RunInstancesInput{ + ImageId: &inst.ImageID, + InstanceType: inst.InstanceType, + SecurityGroupIds: secgrpIDs, + KeyName: inst.KeyName, + } + + // Now go ahead and perform the action. + out, err := p.ctx.EC2().RunInstances(create) + if err != nil { + return nil, err + } + contract.Assert(out != nil) + contract.Assert(len(out.Instances) == 1) + contract.Assert(out.Instances[0] != nil) + contract.Assert(out.Instances[0].InstanceId != nil) + + // TODO: memoize the ID. + // TODO: wait for the instance to finish spinning up. + + return &murpc.CreateResponse{ + Id: *out.Instances[0].InstanceId, + }, nil +} + +// Read reads the instance state identified by ID, returning a populated resource object, or an error if not found. +func (p *instanceProvider) Read(ctx context.Context, req *murpc.ReadRequest) (*murpc.ReadResponse, error) { + contract.Assert(req.GetType() == string(Instance)) + return nil, errors.New("Not yet implemented") +} + +// Update updates an existing resource with new values. Only those values in the provided property bag are updated +// 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, req *murpc.UpdateRequest) (*murpc.UpdateResponse, error) { + contract.Assert(req.GetType() == string(Instance)) + return nil, errors.New("Not yet implemented") +} + +// Delete tears down an existing resource with the given ID. If it fails, the resource is assumed to still exist. +func (p *instanceProvider) Delete(ctx context.Context, req *murpc.DeleteRequest) (*pbempty.Empty, error) { + contract.Assert(req.GetType() == string(Instance)) + return nil, errors.New("Not yet implemented") +} + +// instance represents the state associated with an instance. +type instance struct { + ImageID string + InstanceType *string + SecurityGroupIDs *[]string + KeyName *string +} + +// newInstance creates a new instance bag of state, validating required properties if asked to do so. +func newInstance(m resource.PropertyMap, req bool) (*instance, error) { + imageID, err := m.ReqStringOrErr("imageId") + if err != nil && (req || !resource.IsReqError(err)) { + return nil, err + } + instanceType, err := m.OptStringOrErr("instanceType") + if err != nil { + return nil, err + } + securityGroupIDs, err := m.OptStringArrayOrErr("securityGroups") + if err != nil { + return nil, err + } + keyName, err := m.OptStringOrErr("keyName") + if err != nil { + return nil, err + } + return &instance{ + ImageID: imageID, + InstanceType: instanceType, + SecurityGroupIDs: securityGroupIDs, + KeyName: keyName, + }, nil +} diff --git a/lib/aws/provider/ec2/security_group.go b/lib/aws/provider/ec2/security_group.go new file mode 100644 index 000000000..3015e34b7 --- /dev/null +++ b/lib/aws/provider/ec2/security_group.go @@ -0,0 +1,257 @@ +// Copyright 2016 Marapongo, Inc. All rights reserved. + +package ec2 + +import ( + "errors" + + "github.com/aws/aws-sdk-go/service/ec2" + pbempty "github.com/golang/protobuf/ptypes/empty" + "github.com/marapongo/mu/pkg/resource" + "github.com/marapongo/mu/pkg/tokens" + "github.com/marapongo/mu/pkg/util/contract" + "github.com/marapongo/mu/sdk/go/pkg/murpc" + "golang.org/x/net/context" + + "github.com/marapongo/mu/lib/aws/provider/awsctx" +) + +const SecurityGroup = tokens.Type("aws:ec2/securityGroup:SecurityGroup") + +// NewSecurityGroupProvider creates a provider that handles EC2 security group operations. +func NewSecurityGroupProvider(ctx *awsctx.Context) murpc.ResourceProviderServer { + return &securityGroupProvider{ctx} +} + +type securityGroupProvider struct { + ctx *awsctx.Context +} + +// Create allocates a new instance of the provided resource and returns its unique ID afterwards. (The input ID +// must be blank.) If this call fails, the resource must not have been created (i.e., it is "transacational"). +func (p *securityGroupProvider) Create(ctx context.Context, req *murpc.CreateRequest) (*murpc.CreateResponse, error) { + contract.Assert(req.GetType() == string(SecurityGroup)) + props := resource.UnmarshalProperties(req.GetProperties()) + + // Read in the properties given by the request, validating as we go; if any fail, reject the request. + // TODO: this is a good example of a "benign" (StateOK) error; handle it accordingly. + secgrp, err := newSecurityGroup(props, true) + if err != nil { + return nil, err + } + + // Make the security group creation parameters. + // TODO: the name needs to be figured out; CloudFormation doesn't expose it, presumably due to its requirement to + // be unique. I think we can use the moniker here, but that isn't necessarily stable. UUID? + create := &ec2.CreateSecurityGroupInput{ + GroupName: &secgrp.Description, + Description: &secgrp.Description, + VpcId: secgrp.VPCID, + } + + // Now go ahead and perform the action. + result, err := p.ctx.EC2().CreateSecurityGroup(create) + if err != nil { + return nil, err + } + contract.Assert(result != nil) + contract.Assert(result.GroupId != nil) + + // TODO: memoize the ID. + // TODO: wait for the group to finish spinning up. + // TODO: create the ingress/egress rules. + + return &murpc.CreateResponse{ + Id: *result.GroupId, + }, nil +} + +// Read reads the instance state identified by ID, returning a populated resource object, or an error if not found. +func (p *securityGroupProvider) Read(ctx context.Context, req *murpc.ReadRequest) (*murpc.ReadResponse, error) { + contract.Assert(req.GetType() == string(SecurityGroup)) + return nil, errors.New("Not yet implemented") +} + +// Update updates an existing resource with new values. Only those values in the provided property bag are updated +// to new values. The resource ID is returned and may be different if the resource had to be recreated. +func (p *securityGroupProvider) Update(ctx context.Context, req *murpc.UpdateRequest) (*murpc.UpdateResponse, error) { + contract.Assert(req.GetType() == string(SecurityGroup)) + return nil, errors.New("Not yet implemented") +} + +// Delete tears down an existing resource with the given ID. If it fails, the resource is assumed to still exist. +func (p *securityGroupProvider) Delete(ctx context.Context, req *murpc.DeleteRequest) (*pbempty.Empty, error) { + contract.Assert(req.GetType() == string(SecurityGroup)) + return nil, errors.New("Not yet implemented") +} + +// securityGroup represents the state associated with a security group. +type securityGroup struct { + Description string // description of the security group. + VPCID *string // the VPC in which this security group resides. + Egress *[]securityGroupEgressRule // a list of security group egress rules. + Ingress *[]securityGroupIngressRule // a list of security group ingress rules. +} + +// newSecurityGroup creates a new instance bag of state, validating required properties if asked to do so. +func newSecurityGroup(m resource.PropertyMap, req bool) (*securityGroup, error) { + description, err := m.ReqStringOrErr("groupDescription") + if err != nil && (req || !resource.IsReqError(err)) { + return nil, err + } + + // TODO: validate other aspects of the parameters; for instance, ensure that the description is < 255 characters, + // etc. Furthermore, consider doing this in a pass before performing *any* actions (and during planning), so + // that we can hoist failures to before even trying to execute a plan (when such errors are more costly). + + vpcID, err := m.OptStringOrErr("vpc") + if err != nil { + return nil, err + } + + var egress *[]securityGroupEgressRule + egressArray, err := m.OptObjectArrayOrErr("securityGroupEgress") + if err != nil { + return nil, err + } else { + var rules []securityGroupEgressRule + for _, rule := range *egressArray { + sger, err := newSecurityGroupEgressRule(rule, req) + if err != nil { + return nil, err + } + rules = append(rules, *sger) + } + egress = &rules + } + + var ingress *[]securityGroupIngressRule + ingressArray, err := m.OptObjectArrayOrErr("securityGroupIngress") + if err != nil { + return nil, err + } else { + var rules []securityGroupIngressRule + for _, rule := range *ingressArray { + sgir, err := newSecurityGroupIngressRule(rule, req) + if err != nil { + return nil, err + } + rules = append(rules, *sgir) + } + ingress = &rules + } + + return &securityGroup{ + Description: description, + VPCID: vpcID, + Egress: egress, + Ingress: ingress, + }, nil +} + +// securityGroupRule represents the state associated with a security group rule. +type securityGroupRule struct { + IPProtocol string // an IP protocol name or number. + CIDRIP *string // specifies a CIDR range. + FromPort *int64 // the start of port range for the TCP/UDP protocols, or an ICMP type number. + ToPort *int64 // the end of port range for the TCP/UDP protocols, or an ICMP code. +} + +// newSecurityGroupRule creates a new instance bag of state, validating required properties if asked to do so. +func newSecurityGroupRule(m resource.PropertyMap, req bool) (*securityGroupRule, error) { + ipProtocol, err := m.ReqStringOrErr("ipProtocol") + if err != nil && (req || !resource.IsReqError(err)) { + return nil, err + } + cidrIP, err := m.OptStringOrErr("cidrIp") + if err != nil { + return nil, err + } + + var fromPort *int64 + fromPortF, err := m.OptNumberOrErr("fromPort") + if err != nil { + return nil, err + } else { + fromPortI := int64(*fromPortF) + fromPort = &fromPortI + } + + var toPort *int64 + toPortF, err := m.OptNumberOrErr("toPort") + if err != nil { + return nil, err + } else { + toPortI := int64(*toPortF) + toPort = &toPortI + } + + return &securityGroupRule{ + IPProtocol: ipProtocol, + CIDRIP: cidrIP, + FromPort: fromPort, + ToPort: toPort, + }, nil +} + +// securityGroupEgressRule represents the state associated with a security group egress rule. +type securityGroupEgressRule struct { + securityGroupRule + DestinationPrefixListID *string // the AWS service prefix of an Amazon VPC endpoint. + DestinationSecurityGroupID *string // specifies the destination Amazon VPC security group. +} + +// newSecurityEgressGroupRule creates a new instance bag of state, validating required properties if asked to do so. +func newSecurityGroupEgressRule(m resource.PropertyMap, req bool) (*securityGroupEgressRule, error) { + rule, err := newSecurityGroupRule(m, req) + if err != nil && (req || !resource.IsReqError(err)) { + return nil, err + } + destPrefixListID, err := m.OptStringOrErr("destinationPrefixListId") + if err != nil { + return nil, err + } + destSecurityGroupID, err := m.OptStringOrErr("destinationSecurityGroup") + if err != nil { + return nil, err + } + return &securityGroupEgressRule{ + securityGroupRule: *rule, + DestinationPrefixListID: destPrefixListID, + DestinationSecurityGroupID: destSecurityGroupID, + }, nil +} + +// securityGroupIngressRule represents the state associated with a security group ingress rule. +type securityGroupIngressRule struct { + securityGroupRule + SourceSecurityGroupID *string // the ID of a security group to allow access (for VPC groups only). + SourceSecurityGroupName *string // the name of a security group to allow access (for non-VPC groups only). + SourceSecurityGroupOwnerID *string // the account ID of the owner of the group sepcified by the name, if any. +} + +// newSecurityIngressGroupRule creates a new instance bag of state, validating required properties if asked to do so. +func newSecurityGroupIngressRule(m resource.PropertyMap, req bool) (*securityGroupIngressRule, error) { + rule, err := newSecurityGroupRule(m, req) + if err != nil && (req || !resource.IsReqError(err)) { + return nil, err + } + srcSecurityGroupID, err := m.OptStringOrErr("sourceSecurityGroup") + if err != nil { + return nil, err + } + srcSecurityGroupName, err := m.OptStringOrErr("sourceSecurityGroupName") + if err != nil { + return nil, err + } + srcSecurityGroupOwnerID, err := m.OptStringOrErr("sourceSecurityGroupOwnerId") + if err != nil { + return nil, err + } + return &securityGroupIngressRule{ + securityGroupRule: *rule, + SourceSecurityGroupID: srcSecurityGroupID, + SourceSecurityGroupName: srcSecurityGroupName, + SourceSecurityGroupOwnerID: srcSecurityGroupOwnerID, + }, nil +} diff --git a/lib/aws/provider/main.go b/lib/aws/provider/main.go new file mode 100644 index 000000000..a19eb2f1d --- /dev/null +++ b/lib/aws/provider/main.go @@ -0,0 +1,56 @@ +// Copyright 2016 Marapongo, Inc. All rights reserved. + +package main + +import ( + "fmt" + "net" + "os" + "os/signal" + "strconv" + "syscall" + + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" + + "github.com/marapongo/mu/sdk/go/pkg/murpc" +) + +func main() { + // Listen on a TCP port, but let the kernel choose a free port for us. + lis, err := net.Listen("tcp", ":0") + if err != nil { + fmt.Fprintf(os.Stderr, "fatal: failed to listen on TCP port ':0': %v\n", err) + os.Exit(-1) + } + + // Now new up a gRPC server and register the resource provider implementation. + srv := grpc.NewServer() + prov, err := NewProvider() + if err != nil { + fmt.Fprintf(os.Stderr, "fatal: failed to create AWS resource provider: %v\n", err) + os.Exit(-1) + } + murpc.RegisterResourceProviderServer(srv, prov) + reflection.Register(srv) + + // The resource provider protocol requires that we now write out the port we have chosen to listen on. To do + // that, we must retrieve the port chosen by the kernel, by accessing the underlying TCP listener/address. + tcpl := lis.(*net.TCPListener) + tcpa := tcpl.Addr().(*net.TCPAddr) + fmt.Printf("%v\n", strconv.Itoa(tcpa.Port)) + + // Now register some signals to gracefully terminate the program upon request. + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) + go func() { + <-sigs + srv.Stop() + }() + + // Finally, serve; this returns only once the server shuts down (e.g., due to a signal). + if err := srv.Serve(lis); err != nil { + fmt.Fprintf(os.Stderr, "fatal: stopped serving: %v\n", err) + os.Exit(-1) + } +} diff --git a/lib/aws/provider/provider.go b/lib/aws/provider/provider.go new file mode 100644 index 000000000..f7c1c11e0 --- /dev/null +++ b/lib/aws/provider/provider.go @@ -0,0 +1,74 @@ +// Copyright 2016 Marapongo, Inc. All rights reserved. + +package main + +import ( + "fmt" + + pbempty "github.com/golang/protobuf/ptypes/empty" + "github.com/marapongo/mu/pkg/tokens" + "github.com/marapongo/mu/sdk/go/pkg/murpc" + "golang.org/x/net/context" + + "github.com/marapongo/mu/lib/aws/provider/awsctx" + "github.com/marapongo/mu/lib/aws/provider/ec2" +) + +// provider implements the AWS resource provider's operations for all known AWS types. +type Provider struct { + impls map[tokens.Type]murpc.ResourceProviderServer +} + +// NewProvider creates a new provider instance with server objects registered for every resource type. +func NewProvider() (*Provider, error) { + ctx, err := awsctx.New() + if err != nil { + return nil, err + } + return &Provider{ + impls: map[tokens.Type]murpc.ResourceProviderServer{ + ec2.Instance: ec2.NewInstanceProvider(ctx), + ec2.SecurityGroup: ec2.NewSecurityGroupProvider(ctx), + }, + }, nil +} + +var _ murpc.ResourceProviderServer = (*Provider)(nil) + +// Create allocates a new instance of the provided resource and returns its unique ID afterwards. (The input ID +// must be blank.) If this call fails, the resource must not have been created (i.e., it is "transacational"). +func (p *Provider) Create(ctx context.Context, req *murpc.CreateRequest) (*murpc.CreateResponse, error) { + t := tokens.Type(req.GetType()) + if prov, has := p.impls[t]; has { + return prov.Create(ctx, req) + } + return nil, fmt.Errorf("Unrecognized resource type (Create): %v", t) +} + +// Read reads the instance state identified by ID, returning a populated resource object, or an error if not found. +func (p *Provider) Read(ctx context.Context, req *murpc.ReadRequest) (*murpc.ReadResponse, error) { + t := tokens.Type(req.GetType()) + if prov, has := p.impls[t]; has { + return prov.Read(ctx, req) + } + return nil, fmt.Errorf("Unrecognized resource type (Read): %v", t) +} + +// Update updates an existing resource with new values. Only those values in the provided property bag are updated +// to new values. The resource ID is returned and may be different if the resource had to be recreated. +func (p *Provider) Update(ctx context.Context, req *murpc.UpdateRequest) (*murpc.UpdateResponse, error) { + t := tokens.Type(req.GetType()) + if prov, has := p.impls[t]; has { + return prov.Update(ctx, req) + } + return nil, fmt.Errorf("Unrecognized resource type (Update): %v", t) +} + +// Delete tears down an existing resource with the given ID. If it fails, the resource is assumed to still exist. +func (p *Provider) Delete(ctx context.Context, req *murpc.DeleteRequest) (*pbempty.Empty, error) { + t := tokens.Type(req.GetType()) + if prov, has := p.impls[t]; has { + return prov.Delete(ctx, req) + } + return nil, fmt.Errorf("Unrecognized resource type (Delete): %v", t) +} diff --git a/pkg/resource/plugin.go b/pkg/resource/plugin.go index 56716a922..733ac9fc9 100644 --- a/pkg/resource/plugin.go +++ b/pkg/resource/plugin.go @@ -9,12 +9,9 @@ import ( "os" "os/exec" "path/filepath" - "reflect" - "sort" "strconv" "strings" - structpb "github.com/golang/protobuf/ptypes/struct" "google.golang.org/grpc" "github.com/marapongo/mu/pkg/tokens" @@ -119,7 +116,7 @@ func (p *Plugin) Create(res Resource) (ID, error, ResourceState) { t := string(res.Type()) req := &murpc.CreateRequest{ Type: t, - Properties: marshalProperties(res.Properties()), + Properties: MarshalProperties(res.Properties()), } resp, err := p.client.Create(p.ctx.Request(), req) @@ -148,7 +145,7 @@ func (p *Plugin) Read(id ID, t tokens.Type) (PropertyMap, error) { return nil, err } - return unmarshalProperties(resp.GetProperties()), nil + return UnmarshalProperties(resp.GetProperties()), nil } // Update updates an existing resource with new values. Only those values in the provided property bag are updated @@ -164,8 +161,8 @@ func (p *Plugin) Update(old Resource, new Resource) (ID, error, ResourceState) { req := &murpc.UpdateRequest{ Id: string(old.ID()), Type: string(old.Type()), - Olds: marshalProperties(old.Properties()), - News: marshalProperties(new.Properties()), + Olds: MarshalProperties(old.Properties()), + News: MarshalProperties(new.Properties()), } resp, err := p.client.Update(p.ctx.Request(), req) @@ -202,121 +199,3 @@ func (p *Plugin) Close() error { } return cerr } - -// marshalProperties marshals a resource's property map as a "JSON-like" protobuf structure. -func marshalProperties(props PropertyMap) *structpb.Struct { - result := &structpb.Struct{ - Fields: make(map[string]*structpb.Value), - } - for _, key := range StablePropertyKeys(props) { - result.Fields[string(key)] = marshalPropertyValue(props[key]) - } - return result -} - -// marshalPropertyValue marshals a single resource property value into its "JSON-like" value representation. -func marshalPropertyValue(v PropertyValue) *structpb.Value { - if v.IsNull() { - return &structpb.Value{ - Kind: &structpb.Value_NullValue{ - structpb.NullValue_NULL_VALUE, - }, - } - } else if v.IsBool() { - return &structpb.Value{ - Kind: &structpb.Value_BoolValue{ - v.BoolValue(), - }, - } - } else if v.IsNumber() { - return &structpb.Value{ - Kind: &structpb.Value_NumberValue{ - v.NumberValue(), - }, - } - } else if v.IsString() { - return &structpb.Value{ - Kind: &structpb.Value_StringValue{ - v.StringValue(), - }, - } - } else if v.IsArray() { - var elems []*structpb.Value - for _, elem := range v.ArrayValue() { - elems = append(elems, marshalPropertyValue(elem)) - } - return &structpb.Value{ - Kind: &structpb.Value_ListValue{ - &structpb.ListValue{elems}, - }, - } - } else if v.IsObject() { - return &structpb.Value{ - Kind: &structpb.Value_StructValue{ - marshalProperties(v.ObjectValue()), - }, - } - } else if v.IsResource() { - // TODO: consider a tag so that the other end knows they are monikers. These just look like strings. - return &structpb.Value{ - Kind: &structpb.Value_StringValue{ - string(v.ResourceValue()), - }, - } - } else { - contract.Failf("Unrecognized property value: %v (type=%v)", v.V, reflect.TypeOf(v.V)) - return nil - } -} - -// unmarshalProperties unmarshals a "JSON-like" protobuf structure into a resource property map. -func unmarshalProperties(props *structpb.Struct) PropertyMap { - result := make(PropertyMap) - if props == nil { - return result - } - - // First sort the keys so we enumerate them in order (in case errors happen, we want determinism). - var keys []string - for k := range props.Fields { - keys = append(keys, k) - } - sort.Strings(keys) - - // And now unmarshal every field it into the map. - for _, k := range keys { - result[PropertyKey(k)] = unmarshalPropertyValue(props.Fields[k]) - } - - return result -} - -// unmarshalPropertyValue unmarshals a single "JSON-like" value into its property form. -func unmarshalPropertyValue(v *structpb.Value) PropertyValue { - if v != nil { - switch v.Kind.(type) { - case *structpb.Value_NullValue: - return NewPropertyNull() - case *structpb.Value_BoolValue: - return NewPropertyBool(v.GetBoolValue()) - case *structpb.Value_NumberValue: - return NewPropertyNumber(v.GetNumberValue()) - case *structpb.Value_StringValue: - // TODO: we have no way of determining that this is a moniker; consider tagging. - return NewPropertyString(v.GetStringValue()) - case *structpb.Value_ListValue: - var elems []PropertyValue - lst := v.GetListValue() - for _, elem := range lst.GetValues() { - elems = append(elems, unmarshalPropertyValue(elem)) - } - return NewPropertyArray(elems) - case *structpb.Value_StructValue: - props := unmarshalProperties(v.GetStructValue()) - return NewPropertyObject(props) - default: - contract.Failf("Unrecognized structpb value kind: %v", reflect.TypeOf(v.Kind)) - } - } - return NewPropertyNull() -} diff --git a/pkg/resource/properties.go b/pkg/resource/properties.go new file mode 100644 index 000000000..cb034c647 --- /dev/null +++ b/pkg/resource/properties.go @@ -0,0 +1,319 @@ +// Copyright 2016 Marapongo, Inc. All rights reserved. + +package resource + +import ( + "fmt" + "reflect" + + "github.com/marapongo/mu/pkg/tokens" +) + +// PropertyKey is the name of a property. +type PropertyKey tokens.Name + +// PropertyMap is a simple map keyed by property name with "JSON-like" values. +type PropertyMap map[PropertyKey]PropertyValue + +// PropertyValue is the value of a property, limited to a select few types (see below). +type PropertyValue struct { + V interface{} +} + +type ReqError struct { + K PropertyKey +} + +func IsReqError(err error) bool { + _, isreq := err.(*ReqError) + return isreq +} + +func (err *ReqError) Error() string { + return fmt.Sprintf("required property '%v' is missing", err.K) +} + +// BoolOrErr checks that the given property has the type bool, issuing an error if not; req indicates if required. +func (m PropertyMap) BoolOrErr(k PropertyKey, req bool) (*bool, error) { + if v, has := m[k]; has { + if !v.IsBool() { + return nil, fmt.Errorf("property '%v' is not a bool (%v)", k, reflect.TypeOf(v.V)) + } + b := v.BoolValue() + return &b, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// NumberOrErr checks that the given property has the type float64, issuing an error if not; req indicates if required. +func (m PropertyMap) NumberOrErr(k PropertyKey, req bool) (*float64, error) { + if v, has := m[k]; has { + if !v.IsNumber() { + return nil, fmt.Errorf("property '%v' is not a number (%v)", k, reflect.TypeOf(v.V)) + } + n := v.NumberValue() + return &n, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// StringOrErr checks that the given property has the type string, issuing an error if not; req indicates if required. +func (m PropertyMap) StringOrErr(k PropertyKey, req bool) (*string, error) { + if v, has := m[k]; has { + if !v.IsString() { + return nil, fmt.Errorf("property '%v' is not a string (%v)", k, reflect.TypeOf(v.V)) + } + s := v.StringValue() + return &s, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// ArrayOrErr checks that the given property has the type array, issuing an error if not; req indicates if required. +func (m PropertyMap) ArrayOrErr(k PropertyKey, req bool) (*[]PropertyValue, error) { + if v, has := m[k]; has { + if !v.IsArray() { + return nil, fmt.Errorf("property '%v' is not an array (%v)", k, reflect.TypeOf(v.V)) + } + a := v.ArrayValue() + return &a, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// ObjectArrayOrErr ensures a property is an array of objects, issuing an error if not; req indicates if required. +func (m PropertyMap) ObjectArrayOrErr(k PropertyKey, req bool) (*[]PropertyMap, error) { + if v, has := m[k]; has { + if !v.IsArray() { + return nil, fmt.Errorf("property '%v' is not an array (%v)", k, reflect.TypeOf(v.V)) + } + a := v.ArrayValue() + var objs []PropertyMap + for i, e := range a { + if e.IsObject() { + objs = append(objs, e.ObjectValue()) + } else { + return nil, fmt.Errorf( + "property '%v' array element %v is not an object (%v)", k, i, reflect.TypeOf(e)) + } + } + return &objs, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// StringArrayOrErr ensures a property is an array of strings, issuing an error if not; req indicates if required. +func (m PropertyMap) StringArrayOrErr(k PropertyKey, req bool) (*[]string, error) { + if v, has := m[k]; has { + if !v.IsArray() { + return nil, fmt.Errorf("property '%v' is not an array (%v)", k, reflect.TypeOf(v.V)) + } + a := v.ArrayValue() + var strs []string + for i, e := range a { + if e.IsString() { + strs = append(strs, e.StringValue()) + } else { + return nil, fmt.Errorf( + "property '%v' array element %v is not a string (%v)", k, i, reflect.TypeOf(e)) + } + } + return &strs, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// ObjectOrErr checks that the given property is an object, issuing an error if not; req indicates if required. +func (m PropertyMap) ObjectOrErr(k PropertyKey, req bool) (*PropertyMap, error) { + if v, has := m[k]; has { + if !v.IsObject() { + return nil, fmt.Errorf("property '%v' is not an object (%v)", k, reflect.TypeOf(v.V)) + } + o := v.ObjectValue() + return &o, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// ResourceOrErr checks that the given property is a resource, issuing an error if not; req indicates if required. +func (m PropertyMap) ResourceOrErr(k PropertyKey, req bool) (*Moniker, error) { + if v, has := m[k]; has { + if !v.IsResource() { + return nil, fmt.Errorf("property '%v' is not an object (%v)", k, reflect.TypeOf(v.V)) + } + m := v.ResourceValue() + return &m, nil + } else if req { + return nil, &ReqError{k} + } + return nil, nil +} + +// ReqBoolOrErr checks that the given property exists and has the type bool. +func (m PropertyMap) ReqBoolOrErr(k PropertyKey) (bool, error) { + b, err := m.BoolOrErr(k, true) + if err != nil { + return false, err + } + return *b, nil +} + +// ReqNumberOrErr checks that the given property exists and has the type float64. +func (m PropertyMap) ReqNumberOrErr(k PropertyKey) (float64, error) { + n, err := m.NumberOrErr(k, true) + if err != nil { + return 0, err + } + return *n, nil +} + +// ReqStringOrErr checks that the given property exists and has the type string. +func (m PropertyMap) ReqStringOrErr(k PropertyKey) (string, error) { + s, err := m.StringOrErr(k, true) + if err != nil { + return "", err + } + return *s, nil +} + +// ReqArrayOrErr checks that the given property exists and has the type array. +func (m PropertyMap) ReqArrayOrErr(k PropertyKey) ([]PropertyValue, error) { + a, err := m.ArrayOrErr(k, true) + if err != nil { + return nil, err + } + return *a, nil +} + +// ReqObjectArrayOrErr checks that the given property exists and has the type array of objects. +func (m PropertyMap) ReqObjectArrayOrErr(k PropertyKey) ([]PropertyMap, error) { + a, err := m.ObjectArrayOrErr(k, true) + if err != nil { + return nil, err + } + return *a, nil +} + +// ReqStringArrayOrErr checks that the given property exists and has the type array of objects. +func (m PropertyMap) ReqStringArrayOrErr(k PropertyKey) ([]string, error) { + a, err := m.StringArrayOrErr(k, true) + if err != nil { + return nil, err + } + return *a, nil +} + +// ReqObjectOrErr checks that the given property exists and has the type object. +func (m PropertyMap) ReqObjectOrErr(k PropertyKey) (PropertyMap, error) { + o, err := m.ObjectOrErr(k, true) + if err != nil { + return nil, err + } + return *o, nil +} + +// ReqResourceOrErr checks that the given property exists and has the type moniker. +func (m PropertyMap) ReqResourceOrErr(k PropertyKey) (Moniker, error) { + r, err := m.ResourceOrErr(k, true) + if err != nil { + return Moniker(""), err + } + return *r, nil +} + +// OptBoolOrErr checks that the given property has the type bool, if it exists. +func (m PropertyMap) OptBoolOrErr(k PropertyKey) (*bool, error) { + return m.BoolOrErr(k, false) +} + +// OptNumberOrErr checks that the given property has the type float64, if it exists. +func (m PropertyMap) OptNumberOrErr(k PropertyKey) (*float64, error) { + return m.NumberOrErr(k, false) +} + +// OptStringOrErr checks that the given property has the type string, if it exists. +func (m PropertyMap) OptStringOrErr(k PropertyKey) (*string, error) { + return m.StringOrErr(k, false) +} + +// OptArrayOrErr checks that the given property has the type array, if it exists. +func (m PropertyMap) OptArrayOrErr(k PropertyKey) (*[]PropertyValue, error) { + return m.ArrayOrErr(k, false) +} + +// OptObjectArrayOrErr checks that the given property has the type array of objects, if it exists. +func (m PropertyMap) OptObjectArrayOrErr(k PropertyKey) (*[]PropertyMap, error) { + return m.ObjectArrayOrErr(k, false) +} + +// OptStringArrayOrErr checks that the given property has the type array of objects, if it exists. +func (m PropertyMap) OptStringArrayOrErr(k PropertyKey) (*[]string, error) { + return m.StringArrayOrErr(k, false) +} + +// OptObjectOrErr checks that the given property has the type object, if it exists. +func (m PropertyMap) OptObjectOrErr(k PropertyKey) (*PropertyMap, error) { + return m.ObjectOrErr(k, false) +} + +// OptResourceOrErr checks that the given property has the type moniker, if it exists. +func (m PropertyMap) OptResourceOrErr(k PropertyKey) (*Moniker, error) { + return m.ResourceOrErr(k, false) +} + +func NewPropertyNull() PropertyValue { return PropertyValue{nil} } +func NewPropertyBool(v bool) PropertyValue { return PropertyValue{v} } +func NewPropertyNumber(v float64) PropertyValue { return PropertyValue{v} } +func NewPropertyString(v string) PropertyValue { return PropertyValue{v} } +func NewPropertyArray(v []PropertyValue) PropertyValue { return PropertyValue{v} } +func NewPropertyObject(v PropertyMap) PropertyValue { return PropertyValue{v} } +func NewPropertyResource(v Moniker) PropertyValue { return PropertyValue{v} } + +func (v PropertyValue) BoolValue() bool { return v.V.(bool) } +func (v PropertyValue) NumberValue() float64 { return v.V.(float64) } +func (v PropertyValue) StringValue() string { return v.V.(string) } +func (v PropertyValue) ArrayValue() []PropertyValue { return v.V.([]PropertyValue) } +func (v PropertyValue) ObjectValue() PropertyMap { return v.V.(PropertyMap) } +func (v PropertyValue) ResourceValue() Moniker { return v.V.(Moniker) } + +func (b PropertyValue) IsNull() bool { + return b.V == nil +} +func (b PropertyValue) IsBool() bool { + _, is := b.V.(bool) + return is +} +func (b PropertyValue) IsNumber() bool { + _, is := b.V.(float64) + return is +} +func (b PropertyValue) IsString() bool { + _, is := b.V.(string) + return is +} +func (b PropertyValue) IsArray() bool { + _, is := b.V.([]PropertyValue) + return is +} +func (b PropertyValue) IsObject() bool { + _, is := b.V.(PropertyMap) + return is +} +func (b PropertyValue) IsResource() bool { + _, is := b.V.(Moniker) + return is +} diff --git a/pkg/resource/resource.go b/pkg/resource/resource.go index 77a88454b..7940fe5cf 100644 --- a/pkg/resource/resource.go +++ b/pkg/resource/resource.go @@ -37,58 +37,6 @@ const ( StateUnknown ) -type PropertyMap map[PropertyKey]PropertyValue - -type PropertyKey tokens.Name // the name of a property. - -// PropertyValue is the value of a property, limited to a select few types (see below). -type PropertyValue struct { - V interface{} -} - -func NewPropertyNull() PropertyValue { return PropertyValue{nil} } -func NewPropertyBool(v bool) PropertyValue { return PropertyValue{v} } -func NewPropertyNumber(v float64) PropertyValue { return PropertyValue{v} } -func NewPropertyString(v string) PropertyValue { return PropertyValue{v} } -func NewPropertyArray(v []PropertyValue) PropertyValue { return PropertyValue{v} } -func NewPropertyObject(v PropertyMap) PropertyValue { return PropertyValue{v} } -func NewPropertyResource(v Moniker) PropertyValue { return PropertyValue{v} } - -func (v PropertyValue) BoolValue() bool { return v.V.(bool) } -func (v PropertyValue) NumberValue() float64 { return v.V.(float64) } -func (v PropertyValue) StringValue() string { return v.V.(string) } -func (v PropertyValue) ArrayValue() []PropertyValue { return v.V.([]PropertyValue) } -func (v PropertyValue) ObjectValue() PropertyMap { return v.V.(PropertyMap) } -func (v PropertyValue) ResourceValue() Moniker { return v.V.(Moniker) } - -func (b PropertyValue) IsNull() bool { - return b.V == nil -} -func (b PropertyValue) IsBool() bool { - _, is := b.V.(bool) - return is -} -func (b PropertyValue) IsNumber() bool { - _, is := b.V.(float64) - return is -} -func (b PropertyValue) IsString() bool { - _, is := b.V.(string) - return is -} -func (b PropertyValue) IsArray() bool { - _, is := b.V.([]PropertyValue) - return is -} -func (b PropertyValue) IsObject() bool { - _, is := b.V.(PropertyMap) - return is -} -func (b PropertyValue) IsResource() bool { - _, is := b.V.(Moniker) - return is -} - func IsResourceType(t symbols.Type) bool { return types.HasBaseName(t, predef.MuResourceClass) } func IsResourceVertex(v graph.Vertex) bool { return IsResourceType(v.Obj().Type()) } diff --git a/pkg/resource/rpc.go b/pkg/resource/rpc.go new file mode 100644 index 000000000..e8f80ed17 --- /dev/null +++ b/pkg/resource/rpc.go @@ -0,0 +1,130 @@ +// Copyright 2016 Marapongo, Inc. All rights reserved. + +package resource + +import ( + "reflect" + "sort" + + structpb "github.com/golang/protobuf/ptypes/struct" + + "github.com/marapongo/mu/pkg/util/contract" +) + +// MarshalProperties marshals a resource's property map as a "JSON-like" protobuf structure. +func MarshalProperties(props PropertyMap) *structpb.Struct { + result := &structpb.Struct{ + Fields: make(map[string]*structpb.Value), + } + for _, key := range StablePropertyKeys(props) { + result.Fields[string(key)] = MarshalPropertyValue(props[key]) + } + return result +} + +// MarshalPropertyValue marshals a single resource property value into its "JSON-like" value representation. +func MarshalPropertyValue(v PropertyValue) *structpb.Value { + if v.IsNull() { + return &structpb.Value{ + Kind: &structpb.Value_NullValue{ + structpb.NullValue_NULL_VALUE, + }, + } + } else if v.IsBool() { + return &structpb.Value{ + Kind: &structpb.Value_BoolValue{ + v.BoolValue(), + }, + } + } else if v.IsNumber() { + return &structpb.Value{ + Kind: &structpb.Value_NumberValue{ + v.NumberValue(), + }, + } + } else if v.IsString() { + return &structpb.Value{ + Kind: &structpb.Value_StringValue{ + v.StringValue(), + }, + } + } else if v.IsArray() { + var elems []*structpb.Value + for _, elem := range v.ArrayValue() { + elems = append(elems, MarshalPropertyValue(elem)) + } + return &structpb.Value{ + Kind: &structpb.Value_ListValue{ + &structpb.ListValue{elems}, + }, + } + } else if v.IsObject() { + return &structpb.Value{ + Kind: &structpb.Value_StructValue{ + MarshalProperties(v.ObjectValue()), + }, + } + } else if v.IsResource() { + // TODO: consider a tag so that the other end knows they are monikers. These just look like strings. + return &structpb.Value{ + Kind: &structpb.Value_StringValue{ + string(v.ResourceValue()), + }, + } + } else { + contract.Failf("Unrecognized property value: %v (type=%v)", v.V, reflect.TypeOf(v.V)) + return nil + } +} + +// UnmarshalProperties unmarshals a "JSON-like" protobuf structure into a resource property map. +func UnmarshalProperties(props *structpb.Struct) PropertyMap { + result := make(PropertyMap) + if props == nil { + return result + } + + // First sort the keys so we enumerate them in order (in case errors happen, we want determinism). + var keys []string + for k := range props.Fields { + keys = append(keys, k) + } + sort.Strings(keys) + + // And now unmarshal every field it into the map. + for _, k := range keys { + result[PropertyKey(k)] = UnmarshalPropertyValue(props.Fields[k]) + } + + return result +} + +// UnmarshalPropertyValue unmarshals a single "JSON-like" value into its property form. +func UnmarshalPropertyValue(v *structpb.Value) PropertyValue { + if v != nil { + switch v.Kind.(type) { + case *structpb.Value_NullValue: + return NewPropertyNull() + case *structpb.Value_BoolValue: + return NewPropertyBool(v.GetBoolValue()) + case *structpb.Value_NumberValue: + return NewPropertyNumber(v.GetNumberValue()) + case *structpb.Value_StringValue: + // TODO: we have no way of determining that this is a moniker; consider tagging. + return NewPropertyString(v.GetStringValue()) + case *structpb.Value_ListValue: + var elems []PropertyValue + lst := v.GetListValue() + for _, elem := range lst.GetValues() { + elems = append(elems, UnmarshalPropertyValue(elem)) + } + return NewPropertyArray(elems) + case *structpb.Value_StructValue: + props := UnmarshalProperties(v.GetStructValue()) + return NewPropertyObject(props) + default: + contract.Failf("Unrecognized structpb value kind: %v", reflect.TypeOf(v.Kind)) + } + } + return NewPropertyNull() +}