package awsinv import ( "context" "fmt" "strconv" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/carlosframework/platform/internal/infraview" ) // res builds one Resource with the fields every mapping sets the same way, // so a new mapping cannot forget the provider stamp. func res(kind, nativeType, id, name, region string) infraview.Resource { return infraview.Resource{ Provider: "aws", Kind: kind, NativeType: nativeType, ID: id, Name: name, Region: region, Attrs: map[string]string{}, } } // set writes an attribute only when it has a value: an absent key and an // empty string are different claims, and the joins treat "" as absence. func set(r *infraview.Resource, key, value string) { if value != "" { r.Attrs[key] = value } } func tagsOf(tags []ec2types.Tag) map[string]string { if len(tags) == 0 { return nil } out := map[string]string{} for _, t := range tags { out[aws.ToString(t.Key)] = aws.ToString(t.Value) } return out } // ec2Resources maps one region's EC2 surface. Each call contributes its own // resources and, on failure, one line to partial — never an error: this // function's whole contract is that a throttled volumes call costs the // volumes and nothing else. func ec2Resources(ctx context.Context, api EC2API, region string) (out []infraview.Resource, partial []string) { fail := func(what string, err error) { partial = append(partial, fmt.Sprintf("%s in %s: %v", what, region, err)) } if o, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}); err != nil { fail("instances", err) } else if o != nil { for _, rv := range o.Reservations { for _, in := range rv.Instances { tags := tagsOf(in.Tags) r := res(infraview.KindMachine, "ec2:instance", aws.ToString(in.InstanceId), tags["Name"], region) r.Tags = tags set(&r, "type", string(in.InstanceType)) if in.State != nil { set(&r, "state", string(in.State.Name)) } if in.Placement != nil { set(&r, "az", aws.ToString(in.Placement.AvailabilityZone)) } set(&r, "public_ip", aws.ToString(in.PublicIpAddress)) set(&r, "private_ip", aws.ToString(in.PrivateIpAddress)) // Which security groups and which instance role. Both come // back on this same DescribeInstances call — no extra API // call and no extra IAM action — and both are how // /fleet/architecture tells a CARLOS security group and a // CARLOS instance role from the account's other ones // (console/archestate.go). GROUP IDS, not names: the // firewall resource is keyed on the id, so the join is // exact rather than a name match. var groups []string for _, g := range in.SecurityGroups { if id := aws.ToString(g.GroupId); id != "" { groups = append(groups, id) } } set(&r, "security_groups", strings.Join(groups, ",")) if in.IamInstanceProfile != nil { set(&r, "iam_profile", aws.ToString(in.IamInstanceProfile.Arn)) } if in.LaunchTime != nil { set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02")) } out = append(out, r) } } } if o, err := api.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{}); err != nil { fail("volumes", err) } else if o != nil { for _, v := range o.Volumes { r := res(infraview.KindDisk, "ec2:volume", aws.ToString(v.VolumeId), tagsOf(v.Tags)["Name"], region) r.Tags = tagsOf(v.Tags) if v.Size != nil { set(&r, "size_gb", strconv.Itoa(int(*v.Size))) } set(&r, "volume_type", string(v.VolumeType)) for _, a := range v.Attachments { set(&r, "attached_to", aws.ToString(a.InstanceId)) } out = append(out, r) } } if o, err := api.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{}); err != nil { fail("elastic IPs", err) } else if o != nil { for _, a := range o.Addresses { r := res(infraview.KindIP, "ec2:eip", aws.ToString(a.AllocationId), tagsOf(a.Tags)["Name"], region) r.Tags = tagsOf(a.Tags) set(&r, "address", aws.ToString(a.PublicIp)) set(&r, "attached_to", aws.ToString(a.InstanceId)) out = append(out, r) } } if o, err := api.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{}); err != nil { fail("security groups", err) } else if o != nil { for _, g := range o.SecurityGroups { r := res(infraview.KindFirewall, "ec2:security-group", aws.ToString(g.GroupId), aws.ToString(g.GroupName), region) r.Tags = tagsOf(g.Tags) set(&r, "ingress", summarizePerms(g.IpPermissions)) set(&r, "egress", summarizePerms(g.IpPermissionsEgress)) out = append(out, r) } } if o, err := api.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{}); err != nil { fail("VPCs", err) } else if o != nil { for _, v := range o.Vpcs { r := res(infraview.KindNetwork, "ec2:vpc", aws.ToString(v.VpcId), tagsOf(v.Tags)["Name"], region) r.Tags = tagsOf(v.Tags) set(&r, "cidr", aws.ToString(v.CidrBlock)) out = append(out, r) } } if o, err := api.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{}); err != nil { fail("subnets", err) } else if o != nil { for _, s := range o.Subnets { r := res(infraview.KindNetwork, "ec2:subnet", aws.ToString(s.SubnetId), tagsOf(s.Tags)["Name"], region) r.Tags = tagsOf(s.Tags) set(&r, "cidr", aws.ToString(s.CidrBlock)) set(&r, "az", aws.ToString(s.AvailabilityZone)) set(&r, "vpc", aws.ToString(s.VpcId)) out = append(out, r) } } return out, partial } // summarizePerms renders a rule set as "tcp/22 from 10.0.0.0/8 · tcp/443 // from 0.0.0.0/0" — a sentence an operator reads, never the raw JSON, which // would be both unreadable and a wall of detail the page does not want. func summarizePerms(perms []ec2types.IpPermission) string { out := "" for _, p := range perms { proto := aws.ToString(p.IpProtocol) if proto == "-1" { proto = "all" } port := "all" if p.FromPort != nil { port = strconv.Itoa(int(*p.FromPort)) if p.ToPort != nil && *p.ToPort != *p.FromPort { port += "-" + strconv.Itoa(int(*p.ToPort)) } } for _, rng := range p.IpRanges { if out != "" { out += " · " } out += fmt.Sprintf("%s/%s from %s", proto, port, aws.ToString(rng.CidrIp)) } } return out }