Skip to content

Commit

Permalink
[pkg/inframetadata] Add support for host.ip and host.mac (#225)
Browse files Browse the repository at this point in the history
* [pkg/inframetadata] Add support for host.ip and host.mac

* Fix comments
  • Loading branch information
mx-psi authored Dec 18, 2023
1 parent 5bac6ca commit 5865ccc
Show file tree
Hide file tree
Showing 5 changed files with 242 additions and 2 deletions.
16 changes: 16 additions & 0 deletions .chloggen/mx-psi_host-addresses.yaml
Original file line number Diff line number Diff line change
@@ -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:
6 changes: 6 additions & 0 deletions pkg/inframetadata/gohai/gohai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,6 +92,7 @@ func NewEmpty() Payload {
Gohai: &Gohai{
Platform: map[string]string{},
CPU: map[string]string{},
Network: map[string]string{},
},
},
}
Expand Down
14 changes: 14 additions & 0 deletions pkg/inframetadata/internal/hostmap/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
93 changes: 93 additions & 0 deletions pkg/inframetadata/internal/hostmap/hostmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package hostmap

import (
"fmt"
"strings"
"sync"

"go.opentelemetry.io/collector/pdata/pcommon"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
115 changes: 113 additions & 2 deletions pkg/inframetadata/internal/hostmap/hostmap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
},
Expand Down Expand Up @@ -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") {
Expand All @@ -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")
Expand Down

0 comments on commit 5865ccc

Please sign in to comment.