Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pkg/inframetadata] Add support for host.ip and host.mac #225

Merged
merged 2 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading