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-req- 123
- i-new
16running `,
`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)
}
})
}
}