// Package awsmembership is the AWS adapter of the console's membership // read (region HA spec 2 §5 step 5, §25; H16, H28): read-only Auto Scaling // and EC2 describe calls, made in the AWS region the roster names for the // attempt's placement. The console role needs // autoscaling:DescribeAutoScalingInstances and ec2:DescribeInstances, // conditioned on aws:RequestedRegion being one of the deployment's AWS // regions. No box role has either. package awsmembership import ( "context" "errors" "github.com/aws/aws-sdk-go-v2/aws" awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/autoscaling" "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" ) // Reader is hostactors.MembershipReader over the console's own credentials. type Reader struct{ cfg aws.Config } // New loads the console's AWS configuration (its instance role). func New(ctx context.Context) (*Reader, error) { cfg, err := config.LoadDefaultConfig(ctx) if err != nil { return nil, err } return &Reader{cfg: cfg}, nil } // Member describes instance's Auto Scaling membership, then its EC2 state // and owner: two calls, two request ids. A call that fails ends the read; // the ids of the calls made come back with the error, the failed call's // from the cloud's error response (failedID). func (r *Reader) Member(ctx context.Context, awsRegion, instance string) (hostactors.Membership, error) { asg := autoscaling.NewFromConfig(r.cfg, func(o *autoscaling.Options) { o.Region = awsRegion }) ao, err := asg.DescribeAutoScalingInstances(ctx, &autoscaling.DescribeAutoScalingInstancesInput{InstanceIds: []string{instance}}) if err != nil { return hostactors.Membership{RequestIDs: []string{failedID(err)}}, err } ec := ec2.NewFromConfig(r.cfg, func(o *ec2.Options) { o.Region = awsRegion }) eo, err := ec.DescribeInstances(ctx, &ec2.DescribeInstancesInput{Filters: []ec2types.Filter{{Name: aws.String("instance-id"), Values: []string{instance}}}}) if err != nil { return hostactors.Membership{RequestIDs: []string{requestID(ao.ResultMetadata), failedID(err)}}, err } return membershipFrom(instance, ao, eo), nil } // failedID is the request id of a call that failed: the one the cloud gave // in its error response, or hostactors.NoRequestID when the call failed // before the cloud answered (no connection, a cancelled context). func failedID(err error) string { var re *awshttp.ResponseError if errors.As(err, &re) && re.ServiceRequestID() != "" { return re.ServiceRequestID() } return hostactors.NoRequestID } // membershipFrom reads the two replies. Anything but exactly the one // instance asked about, in exactly one group, is no membership. func membershipFrom(instance string, ao *autoscaling.DescribeAutoScalingInstancesOutput, eo *ec2.DescribeInstancesOutput) hostactors.Membership { m := hostactors.Membership{RequestIDs: []string{requestID(ao.ResultMetadata), requestID(eo.ResultMetadata)}} if len(ao.AutoScalingInstances) == 1 && aws.ToString(ao.AutoScalingInstances[0].InstanceId) == instance { d := ao.AutoScalingInstances[0] m.GroupName = aws.ToString(d.AutoScalingGroupName) m.LifecycleState = aws.ToString(d.LifecycleState) m.AvailabilityZoneRegion = zoneRegion(aws.ToString(d.AvailabilityZone)) } if len(eo.Reservations) == 1 && len(eo.Reservations[0].Instances) == 1 && aws.ToString(eo.Reservations[0].Instances[0].InstanceId) == instance { res := eo.Reservations[0] m.Owner = aws.ToString(res.OwnerId) if st := res.Instances[0].State; st != nil { m.InstanceState = string(st.Name) } } return m } // zoneRegion is the region of an availability zone name ("eu-west-1a" is // in "eu-west-1"): the name less one trailing letter after a digit. Any // other shape (a Local Zone, a zone id) answers "", which matches no // region. func zoneRegion(az string) string { n := len(az) if n < 3 || az[n-1] < 'a' || az[n-1] > 'z' || az[n-2] < '0' || az[n-2] > '9' { return "" } return az[:n-1] } func requestID(md middleware.Metadata) string { id, _ := awsmiddleware.GetRequestIDMetadata(md) return id } var _ hostactors.MembershipReader = (*Reader)(nil)