From 01f8da94e47c7cc68b734d33429f16de4163ff6d Mon Sep 17 00:00:00 2001 From: Louis Ruch Date: Wed, 27 May 2026 13:12:34 -0700 Subject: [PATCH] feat(host): add catalog attribute to only sync instance primary addresses (#71) * feat(host): add catalog attribute to only sync instance primary addresses --- plugin/service/host/attributes.go | 16 +++- plugin/service/host/attributes_test.go | 16 ++++ plugin/service/host/const.go | 5 + plugin/service/host/plugin.go | 17 +++- plugin/service/host/plugin_test.go | 102 +++++++++++++++++--- testing/e2e_host_test.go | 92 ++++++++++++++++++ testing/testdata/host/variable.tf | 2 +- testing/testdata/host/vpc_ec2.tf | 125 ++++++++++++++++++++++++- 8 files changed, 351 insertions(+), 24 deletions(-) diff --git a/plugin/service/host/attributes.go b/plugin/service/host/attributes.go index 5890adb..8b1aa0c 100644 --- a/plugin/service/host/attributes.go +++ b/plugin/service/host/attributes.go @@ -22,6 +22,11 @@ type CatalogAttributes struct { // DualStack is used for configuring how the aws client will resolve requests. DualStack bool + + // InstanceAddressesOnly specifies whether only the instance addresses (private IPv4/DNS, + // public IPv4/DNS, and IPv6) are synced, or all addresses, including those from + // secondary ENIs, are synced. + InstanceAddressesOnly bool } func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { @@ -40,6 +45,12 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { } delete(unknownFields, ConstAwsDualStack) + instanceAddressesOnly, err := values.GetBoolValue(in, ConstInstanceAddressesOnly, false) + if err != nil { + badFields[fmt.Sprintf("attributes.%s", ConstInstanceAddressesOnly)] = err.Error() + } + delete(unknownFields, ConstInstanceAddressesOnly) + for s := range unknownFields { switch s { // Ignore knownFields from CredentialAttributes @@ -65,8 +76,9 @@ func getCatalogAttributes(in *structpb.Struct) (*CatalogAttributes, error) { } return &CatalogAttributes{ - CredentialAttributes: credAttributes, - DualStack: dualStack, + CredentialAttributes: credAttributes, + DualStack: dualStack, + InstanceAddressesOnly: instanceAddressesOnly, }, nil } diff --git a/plugin/service/host/attributes_test.go b/plugin/service/host/attributes_test.go index 5e90f17..ad91a77 100644 --- a/plugin/service/host/attributes_test.go +++ b/plugin/service/host/attributes_test.go @@ -59,6 +59,22 @@ func TestGetCatalogAttributes(t *testing.T) { DualStack: true, }, }, + { + name: "withInstanceAddressesOnly", + in: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "region": structpb.NewStringValue("us-west-2"), + "instance_addresses_only": structpb.NewBoolValue(true), + }, + }, + expected: &CatalogAttributes{ + CredentialAttributes: &credential.CredentialAttributes{ + Region: "us-west-2", + DisableCredentialRotation: false, + }, + InstanceAddressesOnly: true, + }, + }, { name: "default", in: &structpb.Struct{ diff --git a/plugin/service/host/const.go b/plugin/service/host/const.go index 1a6a8d2..4a5d070 100644 --- a/plugin/service/host/const.go +++ b/plugin/service/host/const.go @@ -13,4 +13,9 @@ const ( // ConstAwsDualStack is the key for the dualstack flag in the aws s3 client. ConstAwsDualStack = "dual_stack" + + // ConstInstanceAddressesOnly is the key (instance_addresses_only) that specifies whether + // only the instance addresses (private IPv4/DNS, public IPv4/DNS, and IPv6) are synced, + // or all addresses, including those from secondary ENIs, are synced. + ConstInstanceAddressesOnly = "instance_addresses_only" ) diff --git a/plugin/service/host/plugin.go b/plugin/service/host/plugin.go index b490205..17b1836 100644 --- a/plugin/service/host/plugin.go +++ b/plugin/service/host/plugin.go @@ -550,7 +550,7 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (* // set of hosts afterwards (possibly removing duplicates). for _, reservation := range output.Reservations { for _, instance := range reservation.Instances { - host, err := awsInstanceToHost(instance) + host, err := awsInstanceToHost(instance, catalogAttributes) if err != nil { return nil, errors.BadRequestStatusf("error processing host results for host set id %q: %s", query.Id, err) } @@ -648,10 +648,13 @@ func buildDescribeInstancesInput(attrs *SetAttributes, dryRun bool) (*ec2.Descri // awsInstanceToHost processes data from an ec2.Instance and returns // a ListHostsResponseHost with the ID and network addresses // populated. -func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, error) { +func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttributes) (*pb.ListHostsResponseHost, error) { if aws.ToString(instance.InstanceId) == "" { return nil, fmt.Errorf("response integrity error: missing instance id") } + if catalogAttributes == nil { + return nil, fmt.Errorf("response integrity error: missing catalog attributes") + } result := new(pb.ListHostsResponseHost) @@ -666,10 +669,16 @@ func awsInstanceToHost(instance types.Instance) (*pb.ListHostsResponseHost, erro } } - result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress) + // First let's get the instance's Private IPv4/DNS, Public IPv4/DNS, and IPv6 addresses + result.IpAddresses = appendDistinct(result.IpAddresses, instance.PrivateIpAddress, instance.PublicIpAddress, instance.Ipv6Address) result.DnsNames = appendDistinct(result.DnsNames, instance.PrivateDnsName, instance.PublicDnsName) - // Now go through all of the interfaces and log the IP address of + if catalogAttributes.InstanceAddressesOnly { + // return early as we already have the instance addresses + return result, nil + } + + // Now go through all of the interfaces and sync the IP address of // every interface. for _, iface := range instance.NetworkInterfaces { // Populate default IP addresses/DNS name similar to how we do diff --git a/plugin/service/host/plugin_test.go b/plugin/service/host/plugin_test.go index 0844e3e..5e4bbd1 100644 --- a/plugin/service/host/plugin_test.go +++ b/plugin/service/host/plugin_test.go @@ -638,7 +638,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -664,7 +665,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -694,7 +696,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -721,7 +724,8 @@ func TestPluginOnCreateCatalogErr(t *testing.T) { Fields: map[string]*structpb.Value{ credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"), credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"), - }}, + }, + }, Attrs: &hostcatalogs.HostCatalog_Attributes{ Attributes: &structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -2383,15 +2387,25 @@ func TestBuildDescribeInstancesInput(t *testing.T) { func TestAwsInstanceToHost(t *testing.T) { cases := []struct { - name string - instance types.Instance - expected *pb.ListHostsResponseHost - expectedErr string + name string + instance types.Instance + catalogAttrs *CatalogAttributes + expected *pb.ListHostsResponseHost + expectedErr string }{ { - name: "missing instance id", - instance: types.Instance{}, - expectedErr: "response integrity error: missing instance id", + name: "missing instance id", + instance: types.Instance{}, + catalogAttrs: &CatalogAttributes{}, + expectedErr: "response integrity error: missing instance id", + }, + { + name: "missing catalog attributes", + instance: types.Instance{ + InstanceId: aws.String("foobar"), + }, + catalogAttrs: nil, + expectedErr: "response integrity error: missing catalog attributes", }, { name: "good, single IP w/public addr", @@ -2418,6 +2432,7 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", IpAddresses: []string{"10.0.0.1", "1.1.1.1"}, @@ -2443,6 +2458,7 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", IpAddresses: []string{"10.0.0.1"}, @@ -2484,6 +2500,7 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", IpAddresses: []string{"10.0.0.1", "1.1.1.1", "10.0.0.2"}, @@ -2529,6 +2546,7 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", IpAddresses: []string{"10.0.0.1", "1.1.1.1", "10.0.0.2", "1.1.1.2"}, @@ -2564,6 +2582,7 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", IpAddresses: []string{"10.0.0.1", "1.1.1.1", "10.0.0.2"}, @@ -2578,6 +2597,7 @@ func TestAwsInstanceToHost(t *testing.T) { PrivateDnsName: aws.String("test.example.internal"), PublicIpAddress: aws.String("1.1.1.1"), PublicDnsName: aws.String("test.example.com"), + Ipv6Address: aws.String("instance::ipv6::address"), NetworkInterfaces: []types.InstanceNetworkInterface{ { PrivateIpAddress: aws.String("10.0.0.1"), @@ -2596,9 +2616,63 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", - IpAddresses: []string{"10.0.0.1", "1.1.1.1", "some::fake::address"}, + IpAddresses: []string{"10.0.0.1", "1.1.1.1", "instance::ipv6::address", "some::fake::address"}, + DnsNames: []string{"test.example.internal", "test.example.com"}, + }, + }, + { + name: "good, instance addresses only excludes interface secondary addresses", + instance: types.Instance{ + InstanceId: aws.String("foobar"), + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PublicIpAddress: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + Ipv6Address: aws.String("instance::ipv6::address"), + NetworkInterfaces: []types.InstanceNetworkInterface{ + { + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("1.1.1.1"), + PublicDnsName: aws.String("test.example.com"), + }, + PrivateIpAddress: aws.String("10.0.0.1"), + PrivateDnsName: aws.String("test.example.internal"), + }, + { + PrivateIpAddress: aws.String("10.0.0.2"), + PrivateDnsName: aws.String("test2.example.internal"), + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("iface::ipv6::address")}}, + }, + { + PrivateIpAddress: aws.String("10.0.1.1"), + PrivateDnsName: aws.String("secondary.example.internal"), + PrivateIpAddresses: []types.InstancePrivateIpAddress{ + { + Association: &types.InstanceNetworkInterfaceAssociation{ + PublicIp: aws.String("2.2.2.2"), + PublicDnsName: aws.String("secondary.example.com"), + }, + PrivateIpAddress: aws.String("10.0.1.1"), + PrivateDnsName: aws.String("secondary.example.internal"), + }, + }, + Ipv6Addresses: []types.InstanceIpv6Address{{Ipv6Address: aws.String("secondary::ipv6::address")}}, + }, + }, + }, + catalogAttrs: &CatalogAttributes{InstanceAddressesOnly: true}, + expected: &pb.ListHostsResponseHost{ + ExternalId: "foobar", + IpAddresses: []string{"10.0.0.1", "1.1.1.1", "instance::ipv6::address"}, DnsNames: []string{"test.example.internal", "test.example.com"}, }, }, @@ -2632,6 +2706,7 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", ExternalName: "test-instance-actual-name", @@ -2669,6 +2744,7 @@ func TestAwsInstanceToHost(t *testing.T) { }, }, }, + catalogAttrs: &CatalogAttributes{}, expected: &pb.ListHostsResponseHost{ ExternalId: "foobar", ExternalName: "test-instance-actual-name", @@ -2682,7 +2758,7 @@ func TestAwsInstanceToHost(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { require := require.New(t) - actual, err := awsInstanceToHost(tc.instance) + actual, err := awsInstanceToHost(tc.instance, tc.catalogAttrs) if tc.expectedErr != "" { require.EqualError(err, tc.expectedErr) return diff --git a/testing/e2e_host_test.go b/testing/e2e_host_test.go index c0056b9..2856a63 100644 --- a/testing/e2e_host_test.go +++ b/testing/e2e_host_test.go @@ -165,6 +165,9 @@ func TestHostPlugin(t *testing.T) { testPluginListHosts(ctx, t, p, region, keyid, secret, tc, expectedTagInstancesMap) // TODO: add OnDeleteSet if it needs to be implemented } + + // finally lets test instance_addresses_only catalog attribute correctly syncs only instance addresses + testInstanceAddressesOnlyListHosts(ctx, t, p, tf, region, keyid, secret) } func testPluginOnCreateCatalog(ctx context.Context, t *testing.T, p *host.HostPlugin, region, accessKeyId, secretAccessKey string, rotate bool) (string, string) { @@ -450,3 +453,92 @@ func testPluginListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, // Success t.Logf("testing ListHosts: success (region=%s, tags=%v, expected/actual=(len=%d, ids=%s))", region, tags, len(actualInstances), actualInstances) } + +func testInstanceAddressesOnlyListHosts(ctx context.Context, t *testing.T, p *host.HostPlugin, tf *TestTerraformer, region, accessKeyId, secretAccessKey string) { + t.Helper() + t.Logf("testing testInstanceAddressesOnlyListHosts") + + // Get the instance details from tf output + instanceID, err := tf.GetOutputString("multi_address_instance_id") + require.NoError(t, err) + primaryAddresses, err := tf.GetOutputMap("multi_address_primary_addresses") + require.NoError(t, err) + secondaryAddresses, err := tf.GetOutputMap("multi_address_secondary_addresses") + require.NoError(t, err) + + var primary, allAddresses []string + for _, addr := range primaryAddresses { + primary = append(primary, addr.(string)) + } + allAddresses = append(allAddresses, primary...) + for _, addr := range secondaryAddresses { + allAddresses = append(allAddresses, addr.(string)) + } + + cases := []struct { + name string + instanceAddressesOnly bool + expectedIPs []string + }{ + { + name: "all addresses", + expectedIPs: allAddresses, + }, + { + name: "instance addresses only", + instanceAddressesOnly: true, + expectedIPs: primary, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require := require.New(t) + + catalogAttrMap := map[string]any{ + credential.ConstRegion: region, + credential.ConstDisableCredentialRotation: true, + } + if tc.instanceAddressesOnly { + catalogAttrMap[host.ConstInstanceAddressesOnly] = true + } + + catalogAttrs, err := structpb.NewStruct(catalogAttrMap) + require.NoError(err) + setAttrs, err := structpb.NewStruct(map[string]any{ + host.ConstDescribeInstancesFilters: []any{fmt.Sprintf("instance-id=%s", instanceID)}, + }) + require.NoError(err) + + reqPersistedSecrets, err := structpb.NewStruct(map[string]any{ + credential.ConstAccessKeyId: accessKeyId, + credential.ConstSecretAccessKey: secretAccessKey, + credential.ConstCredsLastRotatedTime: (time.Time{}).Format(time.RFC3339Nano), + }) + require.NoError(err) + request := &pb.ListHostsRequest{ + Catalog: &hostcatalogs.HostCatalog{ + Attrs: &hostcatalogs.HostCatalog_Attributes{ + Attributes: catalogAttrs, + }, + }, + Sets: []*hostsets.HostSet{{ + Id: "hostset-0", + Attrs: &hostsets.HostSet_Attributes{Attributes: setAttrs}, + }}, + Persisted: &pb.HostCatalogPersisted{ + Secrets: reqPersistedSecrets, + }, + } + + response, err := p.ListHosts(ctx, request) + require.NoError(err) + require.Len(response.GetHosts(), 1) + hostResp := response.GetHosts()[0] + + require.Equal(instanceID, hostResp.ExternalId) + require.ElementsMatch(hostResp.IpAddresses, tc.expectedIPs) + }) + } +} diff --git a/testing/testdata/host/variable.tf b/testing/testdata/host/variable.tf index 6ac1c6c..62f93c7 100644 --- a/testing/testdata/host/variable.tf +++ b/testing/testdata/host/variable.tf @@ -26,7 +26,7 @@ resource "random_id" "baz_key" { locals { hashicorp_email = split(":", data.aws_caller_identity.current.user_id)[1] - + instance_tags = [ { "${random_id.foo_key.dec}" = "true" diff --git a/testing/testdata/host/vpc_ec2.tf b/testing/testdata/host/vpc_ec2.tf index 23d0e55..1686cc9 100644 --- a/testing/testdata/host/vpc_ec2.tf +++ b/testing/testdata/host/vpc_ec2.tf @@ -20,13 +20,41 @@ data "aws_ami" "ubuntu" { } resource "aws_vpc" "vpc" { - cidr_block = "10.0.0.0/16" + cidr_block = "10.0.0.0/16" + assign_generated_ipv6_cidr_block = true } resource "aws_subnet" "subnet" { - vpc_id = aws_vpc.vpc.id - cidr_block = aws_vpc.vpc.cidr_block - availability_zone = data.aws_availability_zones.azs.names[0] + vpc_id = aws_vpc.vpc.id + cidr_block = aws_vpc.vpc.cidr_block + ipv6_cidr_block = cidrsubnet(aws_vpc.vpc.ipv6_cidr_block, 8, 0) + availability_zone = data.aws_availability_zones.azs.names[0] + map_public_ip_on_launch = true + assign_ipv6_address_on_creation = true +} + +# Need to add a gateway for public IP addr assignments +resource "aws_internet_gateway" "igw" { + vpc_id = aws_vpc.vpc.id +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.vpc.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.igw.id + } + + route { + ipv6_cidr_block = "::/0" + gateway_id = aws_internet_gateway.igw.id + } +} + +resource "aws_route_table_association" "public" { + subnet_id = aws_subnet.subnet.id + route_table_id = aws_route_table.public.id } resource "aws_instance" "instances" { @@ -38,6 +66,73 @@ resource "aws_instance" "instances" { tags = local.instance_tags[count.index] } +resource "aws_instance" "multi_address_instance" { + ami = data.aws_ami.ubuntu.id + instance_type = "t3.nano" + subnet_id = aws_subnet.subnet.id + secondary_private_ips = ["10.0.10.10"] + ipv6_address_count = 1 + associate_public_ip_address = true + + tags = { + Name = "boundary-multi-address-instance" + "boundary-demo" = local.hashicorp_email + } +} + +resource "aws_network_interface" "multi_address_secondary_eni" { + subnet_id = aws_subnet.subnet.id + private_ips = ["10.0.20.10"] + ipv6_addresses = [cidrhost(aws_subnet.subnet.ipv6_cidr_block, 32)] + + tags = { + Name = "boundary-multi-address-secondary-eni" + "boundary-demo" = local.hashicorp_email + } +} + +resource "aws_network_interface_attachment" "multi_address_secondary_eni" { + instance_id = aws_instance.multi_address_instance.id + network_interface_id = aws_network_interface.multi_address_secondary_eni.id + device_index = 1 +} + +resource "aws_eip" "multi_address_primary" { + domain = "vpc" + network_interface = aws_instance.multi_address_instance.primary_network_interface_id + associate_with_private_ip = aws_instance.multi_address_instance.private_ip + depends_on = [aws_internet_gateway.igw, aws_route_table_association.public] + + tags = { + Name = "boundary-multi-address-primary-eip" + "boundary-demo" = local.hashicorp_email + } +} + +resource "aws_eip" "multi_address_primary_secondary_private" { + domain = "vpc" + network_interface = aws_instance.multi_address_instance.primary_network_interface_id + associate_with_private_ip = "10.0.10.10" + depends_on = [aws_internet_gateway.igw, aws_route_table_association.public, aws_instance.multi_address_instance] + + tags = { + Name = "boundary-multi-address-primary-secondary-eip" + "boundary-demo" = local.hashicorp_email + } +} + +resource "aws_eip" "multi_address_secondary_eni" { + domain = "vpc" + network_interface = aws_network_interface.multi_address_secondary_eni.id + associate_with_private_ip = "10.0.20.10" + depends_on = [aws_internet_gateway.igw, aws_route_table_association.public, aws_network_interface_attachment.multi_address_secondary_eni] + + tags = { + Name = "boundary-multi-address-secondary-eni-eip" + "boundary-demo" = local.hashicorp_email + } +} + output "instance_ids" { value = aws_instance.instances.*.id } @@ -61,3 +156,25 @@ output "instance_tag_keys" { random_id.baz_key.dec, ] } + +output "multi_address_instance_id" { + value = aws_instance.multi_address_instance.id +} + +output "multi_address_primary_addresses" { + value = { + private_ip = aws_instance.multi_address_instance.private_ip + public_ip = aws_eip.multi_address_primary.public_ip + ipv6 = tolist(aws_instance.multi_address_instance.ipv6_addresses)[0] + } +} + +output "multi_address_secondary_addresses" { + value = { + primary_eni_private_ip = "10.0.10.10" + primary_eni_public_ip = aws_eip.multi_address_primary_secondary_private.public_ip + secondary_eni_private_ip = aws_network_interface.multi_address_secondary_eni.private_ip + secondary_eni_public_ip = aws_eip.multi_address_secondary_eni.public_ip + secondary_eni_ipv6 = tolist(aws_network_interface.multi_address_secondary_eni.ipv6_addresses)[0] + } +}