package awsmembership import ( "context" "errors" "fmt" "io" "net/http" "reflect" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" "github.com/aws/aws-sdk-go-v2/service/autoscaling" asgtypes "github.com/aws/aws-sdk-go-v2/service/autoscaling/types" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/smithy-go/middleware" "github.com/carlosframework/platform/internal/hostactors" ) func meta(id string) middleware.Metadata { var md middleware.Metadata awsmiddleware.SetRequestIDMetadata(&md, id) return md } func asgReply(instance, group, zone, state string) *autoscaling.DescribeAutoScalingInstancesOutput { return &autoscaling.DescribeAutoScalingInstancesOutput{ResultMetadata: meta("asg-req"), AutoScalingInstances: []asgtypes.AutoScalingInstanceDetails{{ InstanceId: aws.String(instance), AutoScalingGroupName: aws.String(group), AvailabilityZone: aws.String(zone), LifecycleState: aws.String(state)}}} } func ec2Reply(owner string, instances map[string]ec2types.InstanceStateName) *ec2.DescribeInstancesOutput { res := ec2types.Reservation{OwnerId: aws.String(owner)} for id, st := range instances { res.Instances = append(res.Instances, ec2types.Instance{InstanceId: aws.String(id), State: &ec2types.InstanceState{Name: st}}) } return &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req"), Reservations: []ec2types.Reservation{res}} } func TestMembershipFromTheTwoReplies(t *testing.T) { got := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "InService"), ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning})) want := hostactors.Membership{GroupName: "routers-ie", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", Owner: "123", RequestIDs: []string{"asg-req", "ec2-req"}} if !reflect.DeepEqual(got, want) { t.Fatalf("got %+v\nwant %+v", got, want) } // Not a member: Auto Scaling knows nothing of it, or answers for // another instance. EC2 knowing it changes nothing. for _, ao := range []*autoscaling.DescribeAutoScalingInstancesOutput{{ResultMetadata: meta("asg-req")}, asgReply("i-other", "routers-ie", "eu-west-1b", "InService")} { m := membershipFrom("i-new", ao, ec2Reply("123", map[string]ec2types.InstanceStateName{"i-new": ec2types.InstanceStateNameRunning})) if m.GroupName != "" || m.LifecycleState != "" { t.Fatalf("non-member read as %+v", m) } } // EC2 no longer lists it: no state and no owner. m := membershipFrom("i-new", asgReply("i-new", "routers-ie", "eu-west-1b", "Terminating"), &ec2.DescribeInstancesOutput{ResultMetadata: meta("ec2-req")}) if m.InstanceState != "" || m.Owner != "" { t.Fatalf("forgotten instance read as %+v", m) } } func TestZoneRegion(t *testing.T) { 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": "", "": ""} { if got := zoneRegion(az); got != want { t.Fatalf("zoneRegion(%q) = %q, want %q", az, got, want) } } } // cloudHTTP stands in for the network under the SDK: Auto Scaling and EC2 // in one region. Each service answers its reply, an error response // carrying the cloud's request id, or (down) no answer at all. type cloudHTTP struct { asg, ec2 string // "ok", "error" or "down" } func (h cloudHTTP) Do(r *http.Request) (*http.Response, error) { mode, id, okBody, errBody := h.ec2, "ec2", `ec2-req123i-new16running`, `UnauthorizedOperationnoec2-err` if strings.HasPrefix(r.URL.Host, "autoscaling.") { mode, id, okBody, errBody = h.asg, "asg", `i-newrouters-ieeu-west-1bInServiceHEALTHYfalseasg-req`, `SenderAccessDeniednoasg-err` } if r.Body != nil { _, _ = io.Copy(io.Discard, r.Body) } if !strings.Contains(r.URL.Host, ".eu-west-1.") { return nil, fmt.Errorf("a call to %s, not the attempt's AWS region", r.URL.Host) } resp := &http.Response{Header: http.Header{"Content-Type": {"text/xml"}}, Request: r, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1} switch mode { case "down": return nil, errors.New("dial tcp: connection refused") case "error": resp.StatusCode = http.StatusForbidden resp.Header.Set("X-Amzn-Requestid", id+"-err") resp.Body = io.NopCloser(strings.NewReader(errBody)) default: resp.StatusCode = http.StatusOK resp.Header.Set("X-Amzn-Requestid", id+"-req") resp.Body = io.NopCloser(strings.NewReader(okBody)) } return resp, nil } // Member, through the SDK: both ids on success, and on a failed read the // ids of the calls made, the failed call's from the cloud's error // response, or NoRequestID when the cloud never answered. func TestMemberKeepsTheIdsOfAFailedRead(t *testing.T) { for name, c := range map[string]struct { cloud cloudHTTP ids []string fails bool }{ "both calls answer": {cloudHTTP{"ok", "ok"}, []string{"asg-req", "ec2-req"}, false}, "the second call fails": {cloudHTTP{"ok", "error"}, []string{"asg-req", "ec2-err"}, true}, "the first call fails": {cloudHTTP{"error", "ok"}, []string{"asg-err"}, true}, "the second call never reaches a cloud": {cloudHTTP{"ok", "down"}, []string{"asg-req", hostactors.NoRequestID}, true}, "the first call never reaches a cloud": {cloudHTTP{"down", "ok"}, []string{hostactors.NoRequestID}, true}, } { t.Run(name, func(t *testing.T) { r := &Reader{cfg: aws.Config{Region: "us-east-1", Credentials: aws.AnonymousCredentials{}, HTTPClient: c.cloud, Retryer: func() aws.Retryer { return aws.NopRetryer{} }}} m, err := r.Member(context.Background(), "eu-west-1", "i-new") if (err != nil) != c.fails || !reflect.DeepEqual(m.RequestIDs, c.ids) { t.Fatalf("Member: %+v, %v; want ids %v", m, err, c.ids) } if !c.fails && (m.GroupName != "routers-ie" || m.InstanceState != "running" || m.Owner != "123" || m.AvailabilityZoneRegion != "eu-west-1") { t.Fatalf("a member read as %+v", m) } }) } }