| 1 | // Package awsmembership is the AWS adapter of the console's membership |
| 2 | // read (region HA spec 2 §5 step 5, §25; H16, H28): read-only Auto Scaling |
| 3 | // and EC2 describe calls, made in the AWS region the roster names for the |
| 4 | // attempt's placement. The console role needs |
| 5 | // autoscaling:DescribeAutoScalingInstances and ec2:DescribeInstances, |
| 6 | // conditioned on aws:RequestedRegion being one of the deployment's AWS |
| 7 | // regions. No box role has either. |
| 8 | package awsmembership |
| 9 | |
| 10 | import ( |
| 11 | "context" |
| 12 | "errors" |
| 13 | |
| 14 | "github.com/aws/aws-sdk-go-v2/aws" |
| 15 | awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| 16 | awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" |
| 17 | "github.com/aws/aws-sdk-go-v2/config" |
| 18 | "github.com/aws/aws-sdk-go-v2/service/autoscaling" |
| 19 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 20 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 21 | "github.com/aws/smithy-go/middleware" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/hostactors" |
| 24 | ) |
| 25 | |
| 26 | // Reader is hostactors.MembershipReader over the console's own credentials. |
| 27 | type Reader struct{ cfg aws.Config } |
| 28 | |
| 29 | // New loads the console's AWS configuration (its instance role). |
| 30 | func New(ctx context.Context) (*Reader, error) { |
| 31 | cfg, err := config.LoadDefaultConfig(ctx) |
| 32 | if err != nil { |
| 33 | return nil, err |
| 34 | } |
| 35 | return &Reader{cfg: cfg}, nil |
| 36 | } |
| 37 | |
| 38 | // Member describes instance's Auto Scaling membership, then its EC2 state |
| 39 | // and owner: two calls, two request ids. A call that fails ends the read; |
| 40 | // the ids of the calls made come back with the error, the failed call's |
| 41 | // from the cloud's error response (failedID). |
| 42 | func (r *Reader) Member(ctx context.Context, awsRegion, instance string) (hostactors.Membership, error) { |
| 43 | asg := autoscaling.NewFromConfig(r.cfg, func(o *autoscaling.Options) { o.Region = awsRegion }) |
| 44 | ao, err := asg.DescribeAutoScalingInstances(ctx, &autoscaling.DescribeAutoScalingInstancesInput{InstanceIds: []string{instance}}) |
| 45 | if err != nil { |
| 46 | return hostactors.Membership{RequestIDs: []string{failedID(err)}}, err |
| 47 | } |
| 48 | ec := ec2.NewFromConfig(r.cfg, func(o *ec2.Options) { o.Region = awsRegion }) |
| 49 | eo, err := ec.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: []ec2types.Filter{{Name: aws.String("instance-id"), Values: []string{instance}}}}) |
| 50 | if err != nil { |
| 51 | return hostactors.Membership{RequestIDs: []string{requestID(ao.ResultMetadata), failedID(err)}}, err |
| 52 | } |
| 53 | return membershipFrom(instance, ao, eo), nil |
| 54 | } |
| 55 | |
| 56 | // failedID is the request id of a call that failed: the one the cloud gave |
| 57 | // in its error response, or hostactors.NoRequestID when the call failed |
| 58 | // before the cloud answered (no connection, a cancelled context). |
| 59 | func failedID(err error) string { |
| 60 | var re *awshttp.ResponseError |
| 61 | if errors.As(err, &re) && re.ServiceRequestID() != "" { |
| 62 | return re.ServiceRequestID() |
| 63 | } |
| 64 | return hostactors.NoRequestID |
| 65 | } |
| 66 | |
| 67 | // membershipFrom reads the two replies. Anything but exactly the one |
| 68 | // instance asked about, in exactly one group, is no membership. |
| 69 | func membershipFrom(instance string, ao *autoscaling.DescribeAutoScalingInstancesOutput, eo *ec2.DescribeInstancesOutput) hostactors.Membership { |
| 70 | m := hostactors.Membership{RequestIDs: []string{requestID(ao.ResultMetadata), requestID(eo.ResultMetadata)}} |
| 71 | if len(ao.AutoScalingInstances) == 1 && aws.ToString(ao.AutoScalingInstances[0].InstanceId) == instance { |
| 72 | d := ao.AutoScalingInstances[0] |
| 73 | m.GroupName = aws.ToString(d.AutoScalingGroupName) |
| 74 | m.LifecycleState = aws.ToString(d.LifecycleState) |
| 75 | m.AvailabilityZoneRegion = zoneRegion(aws.ToString(d.AvailabilityZone)) |
| 76 | } |
| 77 | if len(eo.Reservations) == 1 && len(eo.Reservations[0].Instances) == 1 && aws.ToString(eo.Reservations[0].Instances[0].InstanceId) == instance { |
| 78 | res := eo.Reservations[0] |
| 79 | m.Owner = aws.ToString(res.OwnerId) |
| 80 | if st := res.Instances[0].State; st != nil { |
| 81 | m.InstanceState = string(st.Name) |
| 82 | } |
| 83 | } |
| 84 | return m |
| 85 | } |
| 86 | |
| 87 | // zoneRegion is the region of an availability zone name ("eu-west-1a" is |
| 88 | // in "eu-west-1"): the name less one trailing letter after a digit. Any |
| 89 | // other shape (a Local Zone, a zone id) answers "", which matches no |
| 90 | // region. |
| 91 | func zoneRegion(az string) string { |
| 92 | n := len(az) |
| 93 | if n < 3 || az[n-1] < 'a' || az[n-1] > 'z' || az[n-2] < '0' || az[n-2] > '9' { |
| 94 | return "" |
| 95 | } |
| 96 | return az[:n-1] |
| 97 | } |
| 98 | |
| 99 | func requestID(md middleware.Metadata) string { |
| 100 | id, _ := awsmiddleware.GetRequestIDMetadata(md) |
| 101 | return id |
| 102 | } |
| 103 | |
| 104 | var _ hostactors.MembershipReader = (*Reader)(nil) |
| 105 | |