| 1 | package awsinv |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/aws/aws-sdk-go-v2/aws" |
| 10 | "github.com/aws/aws-sdk-go-v2/service/ec2" |
| 11 | ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" |
| 12 | "github.com/carlosframework/platform/internal/infraview" |
| 13 | ) |
| 14 | |
| 15 | // res builds one Resource with the fields every mapping sets the same way, |
| 16 | // so a new mapping cannot forget the provider stamp. |
| 17 | func res(kind, nativeType, id, name, region string) infraview.Resource { |
| 18 | return infraview.Resource{ |
| 19 | Provider: "aws", Kind: kind, NativeType: nativeType, |
| 20 | ID: id, Name: name, Region: region, Attrs: map[string]string{}, |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | // set writes an attribute only when it has a value: an absent key and an |
| 25 | // empty string are different claims, and the joins treat "" as absence. |
| 26 | func set(r *infraview.Resource, key, value string) { |
| 27 | if value != "" { |
| 28 | r.Attrs[key] = value |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | func tagsOf(tags []ec2types.Tag) map[string]string { |
| 33 | if len(tags) == 0 { |
| 34 | return nil |
| 35 | } |
| 36 | out := map[string]string{} |
| 37 | for _, t := range tags { |
| 38 | out[aws.ToString(t.Key)] = aws.ToString(t.Value) |
| 39 | } |
| 40 | return out |
| 41 | } |
| 42 | |
| 43 | // ec2Resources maps one region's EC2 surface. Each call contributes its own |
| 44 | // resources and, on failure, one line to partial — never an error: this |
| 45 | // function's whole contract is that a throttled volumes call costs the |
| 46 | // volumes and nothing else. |
| 47 | func ec2Resources(ctx context.Context, api EC2API, region string) (out []infraview.Resource, partial []string) { |
| 48 | fail := func(what string, err error) { |
| 49 | partial = append(partial, fmt.Sprintf("%s in %s: %v", what, region, err)) |
| 50 | } |
| 51 | |
| 52 | if o, err := api.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}); err != nil { |
| 53 | fail("instances", err) |
| 54 | } else if o != nil { |
| 55 | for _, rv := range o.Reservations { |
| 56 | for _, in := range rv.Instances { |
| 57 | tags := tagsOf(in.Tags) |
| 58 | r := res(infraview.KindMachine, "ec2:instance", aws.ToString(in.InstanceId), tags["Name"], region) |
| 59 | r.Tags = tags |
| 60 | set(&r, "type", string(in.InstanceType)) |
| 61 | if in.State != nil { |
| 62 | set(&r, "state", string(in.State.Name)) |
| 63 | } |
| 64 | if in.Placement != nil { |
| 65 | set(&r, "az", aws.ToString(in.Placement.AvailabilityZone)) |
| 66 | } |
| 67 | set(&r, "public_ip", aws.ToString(in.PublicIpAddress)) |
| 68 | set(&r, "private_ip", aws.ToString(in.PrivateIpAddress)) |
| 69 | // Which security groups and which instance role. Both come |
| 70 | // back on this same DescribeInstances call — no extra API |
| 71 | // call and no extra IAM action — and both are how |
| 72 | // /fleet/architecture tells a CARLOS security group and a |
| 73 | // CARLOS instance role from the account's other ones |
| 74 | // (console/archestate.go). GROUP IDS, not names: the |
| 75 | // firewall resource is keyed on the id, so the join is |
| 76 | // exact rather than a name match. |
| 77 | var groups []string |
| 78 | for _, g := range in.SecurityGroups { |
| 79 | if id := aws.ToString(g.GroupId); id != "" { |
| 80 | groups = append(groups, id) |
| 81 | } |
| 82 | } |
| 83 | set(&r, "security_groups", strings.Join(groups, ",")) |
| 84 | if in.IamInstanceProfile != nil { |
| 85 | set(&r, "iam_profile", aws.ToString(in.IamInstanceProfile.Arn)) |
| 86 | } |
| 87 | if in.LaunchTime != nil { |
| 88 | set(&r, "launched", in.LaunchTime.UTC().Format("2006-01-02")) |
| 89 | } |
| 90 | out = append(out, r) |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | if o, err := api.DescribeVolumes(ctx, &ec2.DescribeVolumesInput{}); err != nil { |
| 96 | fail("volumes", err) |
| 97 | } else if o != nil { |
| 98 | for _, v := range o.Volumes { |
| 99 | r := res(infraview.KindDisk, "ec2:volume", aws.ToString(v.VolumeId), tagsOf(v.Tags)["Name"], region) |
| 100 | r.Tags = tagsOf(v.Tags) |
| 101 | if v.Size != nil { |
| 102 | set(&r, "size_gb", strconv.Itoa(int(*v.Size))) |
| 103 | } |
| 104 | set(&r, "volume_type", string(v.VolumeType)) |
| 105 | for _, a := range v.Attachments { |
| 106 | set(&r, "attached_to", aws.ToString(a.InstanceId)) |
| 107 | } |
| 108 | out = append(out, r) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | if o, err := api.DescribeAddresses(ctx, &ec2.DescribeAddressesInput{}); err != nil { |
| 113 | fail("elastic IPs", err) |
| 114 | } else if o != nil { |
| 115 | for _, a := range o.Addresses { |
| 116 | r := res(infraview.KindIP, "ec2:eip", aws.ToString(a.AllocationId), tagsOf(a.Tags)["Name"], region) |
| 117 | r.Tags = tagsOf(a.Tags) |
| 118 | set(&r, "address", aws.ToString(a.PublicIp)) |
| 119 | set(&r, "attached_to", aws.ToString(a.InstanceId)) |
| 120 | out = append(out, r) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | if o, err := api.DescribeSecurityGroups(ctx, &ec2.DescribeSecurityGroupsInput{}); err != nil { |
| 125 | fail("security groups", err) |
| 126 | } else if o != nil { |
| 127 | for _, g := range o.SecurityGroups { |
| 128 | r := res(infraview.KindFirewall, "ec2:security-group", aws.ToString(g.GroupId), aws.ToString(g.GroupName), region) |
| 129 | r.Tags = tagsOf(g.Tags) |
| 130 | set(&r, "ingress", summarizePerms(g.IpPermissions)) |
| 131 | set(&r, "egress", summarizePerms(g.IpPermissionsEgress)) |
| 132 | out = append(out, r) |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | if o, err := api.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{}); err != nil { |
| 137 | fail("VPCs", err) |
| 138 | } else if o != nil { |
| 139 | for _, v := range o.Vpcs { |
| 140 | r := res(infraview.KindNetwork, "ec2:vpc", aws.ToString(v.VpcId), tagsOf(v.Tags)["Name"], region) |
| 141 | r.Tags = tagsOf(v.Tags) |
| 142 | set(&r, "cidr", aws.ToString(v.CidrBlock)) |
| 143 | out = append(out, r) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | if o, err := api.DescribeSubnets(ctx, &ec2.DescribeSubnetsInput{}); err != nil { |
| 148 | fail("subnets", err) |
| 149 | } else if o != nil { |
| 150 | for _, s := range o.Subnets { |
| 151 | r := res(infraview.KindNetwork, "ec2:subnet", aws.ToString(s.SubnetId), tagsOf(s.Tags)["Name"], region) |
| 152 | r.Tags = tagsOf(s.Tags) |
| 153 | set(&r, "cidr", aws.ToString(s.CidrBlock)) |
| 154 | set(&r, "az", aws.ToString(s.AvailabilityZone)) |
| 155 | set(&r, "vpc", aws.ToString(s.VpcId)) |
| 156 | out = append(out, r) |
| 157 | } |
| 158 | } |
| 159 | return out, partial |
| 160 | } |
| 161 | |
| 162 | // summarizePerms renders a rule set as "tcp/22 from 10.0.0.0/8 · tcp/443 |
| 163 | // from 0.0.0.0/0" — a sentence an operator reads, never the raw JSON, which |
| 164 | // would be both unreadable and a wall of detail the page does not want. |
| 165 | func summarizePerms(perms []ec2types.IpPermission) string { |
| 166 | out := "" |
| 167 | for _, p := range perms { |
| 168 | proto := aws.ToString(p.IpProtocol) |
| 169 | if proto == "-1" { |
| 170 | proto = "all" |
| 171 | } |
| 172 | port := "all" |
| 173 | if p.FromPort != nil { |
| 174 | port = strconv.Itoa(int(*p.FromPort)) |
| 175 | if p.ToPort != nil && *p.ToPort != *p.FromPort { |
| 176 | port += "-" + strconv.Itoa(int(*p.ToPort)) |
| 177 | } |
| 178 | } |
| 179 | for _, rng := range p.IpRanges { |
| 180 | if out != "" { |
| 181 | out += " · " |
| 182 | } |
| 183 | out += fmt.Sprintf("%s/%s from %s", proto, port, aws.ToString(rng.CidrIp)) |
| 184 | } |
| 185 | } |
| 186 | return out |
| 187 | } |
| 188 | |