| 1 | package awsinv |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/aws/aws-sdk-go-v2/aws" |
| 10 | awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" |
| 11 | awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| 12 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 13 | "github.com/carlosframework/platform/internal/rootaws" |
| 14 | ) |
| 15 | |
| 16 | // ErrExactInstance marks every result that fails to prove that the exact |
| 17 | // enrolled EC2 instance is stopped or terminated. Callers must treat it as a |
| 18 | // refusal, including when AWS says that the instance does not exist. |
| 19 | var ErrExactInstance = errors.New("awsinv: exact EC2 instance is not fenced") |
| 20 | |
| 21 | // ExactInstance is the complete observation accepted by the declare-lost |
| 22 | // client. Account comes from Reservation.OwnerId, not from a request field or |
| 23 | // the caller's default profile. |
| 24 | type ExactInstance struct { |
| 25 | Account string `json:"account"` |
| 26 | Region string `json:"region"` |
| 27 | InstanceID string `json:"instance_id"` |
| 28 | State string `json:"state"` |
| 29 | RequestID string `json:"request_id"` |
| 30 | } |
| 31 | |
| 32 | // ExactEC2API is the DescribeInstances slice of EC2API. The production EC2 |
| 33 | // client and every existing awsinv EC2 fake satisfy it; keeping the verifier |
| 34 | // on this one-method view makes a write-capable client unnecessary. |
| 35 | type ExactEC2API interface { |
| 36 | DescribeInstances(context.Context, *ec2.DescribeInstancesInput, ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) |
| 37 | } |
| 38 | |
| 39 | // NewEC2Reader resolves the operator's ordinary AWS credential chain and |
| 40 | // returns the existing read-only EC2 seam in the enrolled region. It grants |
| 41 | // no stop, terminate or restart operation. |
| 42 | func NewEC2Reader(ctx context.Context, region string) (ExactEC2API, error) { |
| 43 | if strings.TrimSpace(region) == "" { |
| 44 | return nil, fmt.Errorf("%w: enrolled region is empty", ErrExactInstance) |
| 45 | } |
| 46 | cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) |
| 47 | if err != nil { |
| 48 | return nil, fmt.Errorf("%w: AWS config: %v", ErrExactInstance, err) |
| 49 | } |
| 50 | probeCtx, cancel := context.WithTimeout(ctx, credentialProbe) |
| 51 | defer cancel() |
| 52 | if _, err := cfg.Credentials.Retrieve(probeCtx); err != nil { |
| 53 | return nil, fmt.Errorf("%w: no usable AWS credential: %v", ErrExactInstance, err) |
| 54 | } |
| 55 | return factoryFor(cfg)(region).EC2, nil |
| 56 | } |
| 57 | |
| 58 | // VerifyExactStoppedInstance makes one DescribeInstances call constrained to |
| 59 | // the exact enrolled ID. Any API error, pagination, missing field, duplicate |
| 60 | // result, owner mismatch or live state refuses; InvalidInstanceID.NotFound is |
| 61 | // therefore never treated as termination evidence. |
| 62 | func VerifyExactStoppedInstance(ctx context.Context, api ExactEC2API, account, region, instanceID string) (ExactInstance, error) { |
| 63 | wantAccount := strings.TrimSpace(account) |
| 64 | wantRegion := strings.TrimSpace(region) |
| 65 | wantID := strings.TrimSpace(instanceID) |
| 66 | if api == nil || wantAccount == "" || wantRegion == "" || wantID == "" { |
| 67 | return ExactInstance{}, fmt.Errorf("%w: incomplete enrolled cloud binding", ErrExactInstance) |
| 68 | } |
| 69 | out, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{InstanceIds: []string{wantID}}, func(o *ec2.Options) { |
| 70 | o.Region = wantRegion |
| 71 | }) |
| 72 | if err != nil { |
| 73 | return ExactInstance{}, fmt.Errorf("%w: DescribeInstances %s in %s/%s: %v", ErrExactInstance, wantID, wantAccount, wantRegion, err) |
| 74 | } |
| 75 | if out == nil || aws.ToString(out.NextToken) != "" || len(out.Reservations) != 1 { |
| 76 | return ExactInstance{}, fmt.Errorf("%w: DescribeInstances returned a partial or ambiguous result", ErrExactInstance) |
| 77 | } |
| 78 | requestID, ok := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata) |
| 79 | if !ok || strings.TrimSpace(requestID) == "" { |
| 80 | return ExactInstance{}, ErrExactInstance |
| 81 | } |
| 82 | r := out.Reservations[0] |
| 83 | owner := aws.ToString(r.OwnerId) |
| 84 | if owner == "" || owner != wantAccount || len(r.Instances) != 1 { |
| 85 | return ExactInstance{}, fmt.Errorf("%w: reservation owner or instance count does not match enrollment", ErrExactInstance) |
| 86 | } |
| 87 | in := r.Instances[0] |
| 88 | gotID := aws.ToString(in.InstanceId) |
| 89 | if gotID == "" || gotID != wantID || in.State == nil { |
| 90 | return ExactInstance{}, fmt.Errorf("%w: returned instance does not exactly match enrollment", ErrExactInstance) |
| 91 | } |
| 92 | state := string(in.State.Name) |
| 93 | if state != "stopped" && state != "terminated" { |
| 94 | return ExactInstance{}, fmt.Errorf("%w: instance %s is %q", ErrExactInstance, wantID, state) |
| 95 | } |
| 96 | return ExactInstance{Account: owner, Region: wantRegion, InstanceID: gotID, State: state, RequestID: requestID}, nil |
| 97 | } |
| 98 | |