carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package awsmembership
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "net/http"
9 "reflect"
10 "strings"
11 "testing"
12
13 "github.com/aws/aws-sdk-go-v2/aws"
14 awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
15 "github.com/aws/aws-sdk-go-v2/service/autoscaling"
16 asgtypes "github.com/aws/aws-sdk-go-v2/service/autoscaling/types"
17 "github.com/aws/aws-sdk-go-v2/service/ec2"
18 ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
19 "github.com/aws/smithy-go/middleware"
20
21 "github.com/carlosframework/platform/internal/hostactors"
22)
23
24func meta(id string) middleware.Metadata {
25 var md middleware.Metadata
26 awsmiddleware.SetRequestIDMetadata(&md, id)
27 return md
28}
29
30func asgReply(instance, group, zone, state string) *autoscaling.DescribeAutoScalingInstancesOutput {
31 return &autoscaling.DescribeAutoScalingInstancesOutput{ResultMetadata: meta("asg-req"), AutoScalingInstances: []asgtypes.AutoScalingInstanceDetails{{
32 InstanceId: aws.String(instance), AutoScalingGroupName: aws.String(group), AvailabilityZone: aws.String(zone), LifecycleState: aws.String(state)}}}
33}
34
35func ec2Reply(owner string, instances map[string]ec2types.InstanceStateName) *ec2.DescribeInstancesOutput {
36 res := ec2types.Reservation{OwnerId: aws.String(owner)}
37 for id, st := range instances {
38 res.Instances = append(res.Instances, ec2types.Instance{InstanceId: aws.String(id), State: &ec2types.InstanceState{Name: st}})
39 }
40 return &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req"), Reservations: []ec2types.Reservation{res}}
41}
42
43func TestMembershipFromTheTwoReplies(t *testing.T) {
44 got := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "InService"), ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning}))
45 want := hostactors.Membership{GroupName: "routers-ie", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}}
46 if !reflect.DeepEqual(got, want) {
47 t.Fatalf("got %+v\nwant %+v", got, want)
48 }
49 // Not a member: Auto Scaling knows nothing of it, or answers for
50 // another instance. EC2 knowing it changes nothing.
51 for _, ao := range []*autoscaling.DescribeAutoScalingInstancesOutput{{ResultMetadata: meta("asg-req")}, asgReply("i-other", "routers-ie", "eu-west-1b", "InService")} {
52 m := membershipFrom("i-new", ao, ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning}))
53 if m.GroupName != "" || m.LifecycleState != "" {
54 t.Fatalf("non-member read as %+v", m)
55 }
56 }
57 // EC2 no longer lists it: no state and no owner.
58 m := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "Terminating"), &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req")})
59 if m.InstanceState != "" || m.Owner != "" {
60 t.Fatalf("forgotten instance read as %+v", m)
61 }
62}
63
64func TestZoneRegion(t *testing.T) {
65 for az, want := range map[string]string{"eu-west-1a": "eu-west-1", "ap-southeast-2c": "ap-southeast-2", "us-west-2-lax-1a": "us-west-2-lax-1", "euw1-az1": "", "eu-west-1": "", "": ""} {
66 if got := zoneRegion(az); got != want {
67 t.Fatalf("zoneRegion(%q) = %q, want %q", az, got, want)
68 }
69 }
70}
71
72// cloudHTTP stands in for the network under the SDK: Auto Scaling and EC2
73// in one region. Each service answers its reply, an error response
74// carrying the cloud's request id, or (down) no answer at all.
75type cloudHTTP struct {
76 asg, ec2 string // "ok", "error" or "down"
77}
78
79func (h cloudHTTP) Do(r *http.Request) (*http.Response, error) {
80 mode, id, okBody, errBody := h.ec2, "ec2", `<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/"><requestId>ec2-req</requestId><reservationSet><item><ownerId>123</ownerId><instancesSet><item><instanceId>i-new</instanceId><instanceState><code>16</code><name>running</name></instanceState></item></instancesSet></item></reservationSet></DescribeInstancesResponse>`,
81 `<Response><Errors><Error><Code>UnauthorizedOperation</Code><Message>no</Message></Error></Errors><RequestID>ec2-err</RequestID></Response>`
82 if strings.HasPrefix(r.URL.Host, "autoscaling.") {
83 mode, id, okBody, errBody = h.asg, "asg", `<DescribeAutoScalingInstancesResponse xmlns="http://autoscaling.amazonaws.com/doc/2011-01-01/"><DescribeAutoScalingInstancesResult><AutoScalingInstances><member><InstanceId>i-new</InstanceId><AutoScalingGroupName>routers-ie</AutoScalingGroupName><AvailabilityZone>eu-west-1b</AvailabilityZone><LifecycleState>InService</LifecycleState><HealthStatus>HEALTHY</HealthStatus><ProtectedFromScaleIn>false</ProtectedFromScaleIn></member></AutoScalingInstances></DescribeAutoScalingInstancesResult><ResponseMetadata><RequestId>asg-req</RequestId></ResponseMetadata></DescribeAutoScalingInstancesResponse>`,
84 `<ErrorResponse><Error><Type>Sender</Type><Code>AccessDenied</Code><Message>no</Message></Error><RequestId>asg-err</RequestId></ErrorResponse>`
85 }
86 if r.Body != nil {
87 _, _ = io.Copy(io.Discard, r.Body)
88 }
89 if !strings.Contains(r.URL.Host, ".eu-west-1.") {
90 return nil, fmt.Errorf("a call to %s, not the attempt's AWS region", r.URL.Host)
91 }
92 resp := &http.Response{Header: http.Header{"Content-Type": {"text/xml"}}, Request: r, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1}
93 switch mode {
94 case "down":
95 return nil, errors.New("dial tcp: connection refused")
96 case "error":
97 resp.StatusCode = http.StatusForbidden
98 resp.Header.Set("X-Amzn-Requestid", id+"-err")
99 resp.Body = io.NopCloser(strings.NewReader(errBody))
100 default:
101 resp.StatusCode = http.StatusOK
102 resp.Header.Set("X-Amzn-Requestid", id+"-req")
103 resp.Body = io.NopCloser(strings.NewReader(okBody))
104 }
105 return resp, nil
106}
107
108// Member, through the SDK: both ids on success, and on a failed read the
109// ids of the calls made, the failed call's from the cloud's error
110// response, or NoRequestID when the cloud never answered.
111func TestMemberKeepsTheIdsOfAFailedRead(t *testing.T) {
112 for name, c := range map[string]struct {
113 cloud cloudHTTP
114 ids []string
115 fails bool
116 }{
117 "both calls answer": {cloudHTTP{"ok", "ok"}, []string{"asg-req", "ec2-req"}, false},
118 "the second call fails": {cloudHTTP{"ok", "error"}, []string{"asg-req", "ec2-err"}, true},
119 "the first call fails": {cloudHTTP{"error", "ok"}, []string{"asg-err"}, true},
120 "the second call never reaches a cloud": {cloudHTTP{"ok", "down"}, []string{"asg-req", hostactors.NoRequestID}, true},
121 "the first call never reaches a cloud": {cloudHTTP{"down", "ok"}, []string{hostactors.NoRequestID}, true},
122 } {
123 t.Run(name, func(t *testing.T) {
124 r := &Reader{cfg: aws.Config{Region: "us-east-1", Credentials: aws.AnonymousCredentials{}, HTTPClient: c.cloud, Retryer: func() aws.Retryer { return aws.NopRetryer{} }}}
125 m, err := r.Member(context.Background(), "eu-west-1", "i-new")
126 if (err != nil) != c.fails || !reflect.DeepEqual(m.RequestIDs, c.ids) {
127 t.Fatalf("Member: %+v, %v; want ids %v", m, err, c.ids)
128 }
129 if !c.fails && (m.GroupName != "routers-ie" || m.InstanceState != "running" || m.Owner != "123" || m.AvailabilityZoneRegion != "eu-west-1") {
130 t.Fatalf("a member read as %+v", m)
131 }
132 })
133 }
134}
135