| 1 | package awsmembership |
| 2 | |
| 3 | import ( |
| 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 | |
| 24 | func meta(id string) middleware.Metadata { |
| 25 | var md middleware.Metadata |
| 26 | awsmiddleware.SetRequestIDMetadata(&md, id) |
| 27 | return md |
| 28 | } |
| 29 | |
| 30 | func 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 | |
| 35 | func 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 | |
| 43 | func 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 | |
| 64 | func 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. |
| 75 | type cloudHTTP struct { |
| 76 | asg, ec2 string // "ok", "error" or "down" |
| 77 | } |
| 78 | |
| 79 | func (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. |
| 111 | func 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 | |