diff --git a/.chloggen/mx-psi_host-addresses.yaml b/.chloggen/mx-psi_host-addresses.yaml new file mode 100755 index 00000000..e1ce8e32 --- /dev/null +++ b/.chloggen/mx-psi_host-addresses.yaml @@ -0,0 +1,16 @@ +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: enhancement + +# The name of the component (e.g. pkg/quantile) +component: pkg/inframetadata + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Add support for host.ip and host.mac semantic conventions for host metadata + +# The PR related to this change +issues: [225] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: diff --git a/pkg/inframetadata/gohai/gohai.go b/pkg/inframetadata/gohai/gohai.go index 269d739e..95fbdb2b 100644 --- a/pkg/inframetadata/gohai/gohai.go +++ b/pkg/inframetadata/gohai/gohai.go @@ -46,6 +46,11 @@ func (p *Payload) CPU() map[string]string { return p.Gohai.Gohai.CPU.(map[string]string) } +// Network returns a reference to the Gohai payload 'network' map. +func (p *Payload) Network() map[string]string { + return p.Gohai.Gohai.Network.(map[string]string) +} + // gohaiSerializer implements json.Marshaler and json.Unmarshaler on top of a gohai payload type gohaiMarshaler struct { Gohai *Gohai @@ -87,6 +92,7 @@ func NewEmpty() Payload { Gohai: &Gohai{ Platform: map[string]string{}, CPU: map[string]string{}, + Network: map[string]string{}, }, }, } diff --git a/pkg/inframetadata/internal/hostmap/constants.go b/pkg/inframetadata/internal/hostmap/constants.go index 71a56356..54fb77e2 100644 --- a/pkg/inframetadata/internal/hostmap/constants.go +++ b/pkg/inframetadata/internal/hostmap/constants.go @@ -76,3 +76,17 @@ var cpuAttributesMap map[string]string = map[string]string{ fieldCPUModel: attributeHostCPUModelID, fieldCPUStepping: attributeHostCPUStepping, } + +// Network related OpenTelemetry Semantic Conventions for resource attributes. +// TODO: Replace by conventions constants once available. +const ( + attributeHostIP = "host.ip" + attributeHostMAC = "host.mac" +) + +// This set of constants represent fields in the Gohai payload's Network field. +const ( + fieldNetworkIPAddressIPv4 = "ipaddress" + fieldNetworkIPAddressIPv6 = "ipaddressv6" + fieldNetworkMACAddress = "macaddress" +) diff --git a/pkg/inframetadata/internal/hostmap/hostmap.go b/pkg/inframetadata/internal/hostmap/hostmap.go index 6c61d71f..e9a8ab76 100644 --- a/pkg/inframetadata/internal/hostmap/hostmap.go +++ b/pkg/inframetadata/internal/hostmap/hostmap.go @@ -7,6 +7,7 @@ package hostmap import ( "fmt" + "strings" "sync" "go.opentelemetry.io/collector/pdata/pcommon" @@ -58,6 +59,60 @@ func strField(m pcommon.Map, key string) (string, bool, error) { return value, true, nil } +// strSliceField gets a field as a slice from a resource attribute map. +// It can handle fields of type "Slice". +// It returns: +// - The field's value, if available +// - Whether the field was present in the map +// - Any errors found in the process +func strSliceField(m pcommon.Map, key string) ([]string, bool, error) { + val, ok := m.Get(key) + if !ok { + // Field not available, don't update but don't fail either + return nil, false, nil + } + if val.Type() != pcommon.ValueTypeSlice { + return nil, false, fmt.Errorf("%q has type %q, expected type \"Slice\" instead", key, val.Type()) + } + if val.Slice().Len() == 0 { + return nil, false, fmt.Errorf("%q is an empty slice, expected at least one item", key) + } + + var strSlice []string + for i := 0; i < val.Slice().Len(); i++ { + item := val.Slice().At(i) + if item.Type() != pcommon.ValueTypeStr { + return nil, false, fmt.Errorf("%s[%d] has type %q, expected type \"Str\" instead", key, i, item.Type()) + } + strSlice = append(strSlice, item.Str()) + } + return strSlice, true, nil +} + +// isIPv4 checks if a string is an IPv4 address. +// From https://stackoverflow.com/a/48519490 +func isIPv4(address string) bool { + return strings.Count(address, ":") < 2 +} + +var macReplacer = strings.NewReplacer("-", ":") + +// ieeeRAtoGolangFormat converts a MAC address from IEEE RA format to the Go format for MAC addresses. +// The Gohai payload expects MAC addresses in the Go format. +// +// Per the spec: "MAC Addresses MUST be represented in IEEE RA hexadecimal form: as hyphen-separated +// octets in uppercase hexadecimal form from most to least significant." +// +// Golang returns MAC addresses as colon-separated octets in lowercase hexadecimal form from most +// to least significant, so we need to: +// - Replace hyphens with colons +// - Convert to lowercase +// +// This is the inverse of toIEEERA from the resource detection processor system detector. +func ieeeRAtoGolangFormat(IEEERAMACaddress string) string { + return strings.ToLower(macReplacer.Replace(IEEERAMACaddress)) +} + // isAWS checks if a resource attribute map // is coming from an AWS VM. func isAWS(m pcommon.Map) (bool, error) { @@ -178,6 +233,44 @@ func (m *HostMap) Update(host string, res pcommon.Resource) (changed bool, md pa } } + // Gohai - Network + if macAddresses, ok, fieldErr := strSliceField(res.Attributes(), attributeHostMAC); fieldErr != nil { + err = multierr.Append(err, fieldErr) + } else if ok { + old := md.Network()[fieldNetworkMACAddress] + // Take the first MAC addresses for consistency with the Agent's implementation + // Map from IEEE RA format to the Go format for MAC addresses. + new := ieeeRAtoGolangFormat(macAddresses[0]) + changed = changed || old != new + md.Network()[fieldNetworkMACAddress] = new + } + + if ipAddresses, ok, fieldErr := strSliceField(res.Attributes(), attributeHostIP); fieldErr != nil { + err = multierr.Append(err, fieldErr) + } else if ok { + oldIPv4 := md.Network()[fieldNetworkIPAddressIPv4] + oldIPv6 := md.Network()[fieldNetworkIPAddressIPv6] + + var foundIPv4 bool + var foundIPv6 bool + // Take the first IPv4 and the first IPv6 addresses for consistency with the Agent's implementation + for _, ip := range ipAddresses { + if foundIPv4 && foundIPv6 { + break + } + + if !foundIPv4 && isIPv4(ip) { + changed = changed || oldIPv4 != ip + md.Network()[fieldNetworkIPAddressIPv4] = ip + foundIPv4 = true + } else if !foundIPv6 { // not IPv4, so it must be IPv6 + changed = changed || oldIPv6 != ip + md.Network()[fieldNetworkIPAddressIPv6] = ip + foundIPv6 = true + } + } + } + m.hosts[host] = md changed = changed && found return diff --git a/pkg/inframetadata/internal/hostmap/hostmap_test.go b/pkg/inframetadata/internal/hostmap/hostmap_test.go index a8792f1a..3f4e545c 100644 --- a/pkg/inframetadata/internal/hostmap/hostmap_test.go +++ b/pkg/inframetadata/internal/hostmap/hostmap_test.go @@ -16,6 +16,111 @@ import ( "github.com/DataDog/opentelemetry-mapping-go/pkg/inframetadata/payload" ) +func TestStrSliceField(t *testing.T) { + tests := []struct { + attributes map[string]any + key string + expected []string + expectedOk bool + expectedErr string + }{ + { + attributes: map[string]any{}, + key: "nonexistingkey", + expected: nil, + expectedOk: false, + expectedErr: "", + }, + { + attributes: map[string]any{ + "host.ip": "192.168.1.1", + }, + key: "host.ip", + expected: nil, + expectedOk: false, + expectedErr: "\"host.ip\" has type \"Str\", expected type \"Slice\" instead", + }, + { + attributes: map[string]any{ + "host.ip": []any{}, + }, + key: "host.ip", + expected: nil, + expectedOk: false, + expectedErr: "\"host.ip\" is an empty slice, expected at least one item", + }, + { + attributes: map[string]any{ + "host.ip": []any{"192.168.1.1", true}, + }, + key: "host.ip", + expected: nil, + expectedOk: false, + expectedErr: "host.ip[1] has type \"Bool\", expected type \"Str\" instead", + }, + } + + for _, tt := range tests { + t.Run(tt.key+"/"+tt.expectedErr, func(t *testing.T) { + res := testutils.NewResourceFromMap(t, tt.attributes) + actual, ok, err := strSliceField(res.Attributes(), tt.key) + assert.Equal(t, tt.expected, actual) + assert.Equal(t, tt.expectedOk, ok) + if tt.expectedErr != "" { + assert.EqualError(t, err, tt.expectedErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestIsIPv4(t *testing.T) { + // Test cases come from https://stackoverflow.com/a/48519490 + tests := []struct { + ip string + isIPv4 bool + }{ + {ip: "192.168.0.1", isIPv4: true}, + {ip: "192.168.0.1:80", isIPv4: true}, + {ip: "::FFFF:C0A8:1", isIPv4: false}, + {ip: "::FFFF:C0A8:0001", isIPv4: false}, + {ip: "0000:0000:0000:0000:0000:FFFF:C0A8:1", isIPv4: false}, + {ip: "::FFFF:C0A8:1%1", isIPv4: false}, + {ip: "::FFFF:192.168.0.1", isIPv4: false}, + {ip: "[::FFFF:C0A8:1]:80", isIPv4: false}, + {ip: "[::FFFF:C0A8:1%1]:80", isIPv4: false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + assert.Equal(t, tt.isIPv4, isIPv4(tt.ip)) + }) + } +} + +func TestIEEERAToGolangFormat(t *testing.T) { + tests := []struct { + ieeeRA string + golangFormat string + }{ + { + ieeeRA: "AB-01-00-00-00-00-00-00", + golangFormat: "ab:01:00:00:00:00:00:00", + }, + { + ieeeRA: "AB-CD-EF-00-00-00", + golangFormat: "ab:cd:ef:00:00:00", + }, + } + + for _, tt := range tests { + t.Run(tt.ieeeRA, func(t *testing.T) { + assert.Equal(t, tt.golangFormat, ieeeRAtoGolangFormat(tt.ieeeRA)) + }) + } +} + func TestUpdate(t *testing.T) { hostInfo := []struct { hostname string @@ -41,6 +146,8 @@ func TestUpdate(t *testing.T) { attributeHostCPUModelName: "11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz", attributeHostCPUStepping: 1, attributeHostCPUCacheL2Size: 12288000, + attributeHostIP: []any{"192.168.1.140", "fe80::abc2:4a28:737a:609e"}, + attributeHostMAC: []any{"AC-DE-48-23-45-67", "AC-DE-48-23-45-67-01-9F"}, }, expectedChanged: false, }, @@ -149,9 +256,13 @@ func TestUpdate(t *testing.T) { fieldCPUStepping: "1", fieldCPUVendorID: "GenuineIntel", }) + assert.Equal(t, md.Payload.Gohai.Gohai.Network, map[string]string{ + fieldNetworkIPAddressIPv4: "192.168.1.140", + fieldNetworkIPAddressIPv6: "fe80::abc2:4a28:737a:609e", + fieldNetworkMACAddress: "ac:de:48:23:45:67", + }) assert.Nil(t, md.Payload.Gohai.Gohai.FileSystem) assert.Nil(t, md.Payload.Gohai.Gohai.Memory) - assert.Nil(t, md.Payload.Gohai.Gohai.Network) } if assert.Contains(t, hosts, "host-2-hostid") { @@ -170,9 +281,9 @@ func TestUpdate(t *testing.T) { fieldPlatformGOOARCH: "arm64", }) assert.Empty(t, md.Payload.Gohai.Gohai.CPU) + assert.Empty(t, md.Payload.Gohai.Gohai.Network) assert.Nil(t, md.Payload.Gohai.Gohai.FileSystem) assert.Nil(t, md.Payload.Gohai.Gohai.Memory) - assert.Nil(t, md.Payload.Gohai.Gohai.Network) } assert.Empty(t, hostMap.Flush(), "returned map must be empty after double flush")