Skip to content
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
69 changes: 69 additions & 0 deletions internal/inventory/hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"time"
)

Expand All @@ -31,6 +32,22 @@ func ComputeHash(snap *Snapshot) (string, error) {
normalized := *snap
normalized.CollectedAt = time.Time{}
normalized.InventoryHash = ""
normalized.AgentConfig.EnabledComponents = sortedStrings(snap.AgentConfig.EnabledComponents)
normalized.AgentConfig.DisabledComponents = sortedStrings(snap.AgentConfig.DisabledComponents)

var err error
normalized.Resources.GPUInfo.GPUs, err = sortedByCanonicalJSON(snap.Resources.GPUInfo.GPUs)
if err != nil {
return "", fmt.Errorf("canonicalize GPU inventory: %w", err)
}
normalized.Resources.DiskInfo.BlockDevices, err = normalizedBlockDevices(snap.Resources.DiskInfo.BlockDevices)
if err != nil {
return "", fmt.Errorf("canonicalize disk inventory: %w", err)
}
normalized.Resources.NICInfo.PrivateIPInterfaces, err = sortedByCanonicalJSON(snap.Resources.NICInfo.PrivateIPInterfaces)
if err != nil {
return "", fmt.Errorf("canonicalize NIC inventory: %w", err)
}

payload, err := json.Marshal(normalized)
if err != nil {
Expand All @@ -39,3 +56,55 @@ func ComputeHash(snap *Snapshot) (string, error) {
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:]), nil
}

func normalizedBlockDevices(values []BlockDevice) ([]BlockDevice, error) {
if values == nil {
return nil, nil
}

normalized := append([]BlockDevice(nil), values...)
for i := range normalized {
normalized[i].Parents = sortedStrings(values[i].Parents)
}
return sortedByCanonicalJSON(normalized)
}

func sortedStrings(values []string) []string {
if values == nil {
return nil
}
sorted := append([]string(nil), values...)
sort.Strings(sorted)
return sorted
}

// sortedByCanonicalJSON treats a slice as order-insensitive for hashing purposes.
// Comparing the complete serialized values avoids relying on optional identity
// fields and automatically includes fields added to an inventory item in the future.
func sortedByCanonicalJSON[T any](values []T) ([]T, error) {
if values == nil {
return nil, nil
}

type sortableValue struct {
value T
encoded string
}
items := make([]sortableValue, len(values))
for i, value := range values {
encoded, err := json.Marshal(value)
if err != nil {
return nil, fmt.Errorf("marshal inventory list item: %w", err)
}
items[i] = sortableValue{value: value, encoded: string(encoded)}
}
sort.SliceStable(items, func(i, j int) bool {
return items[i].encoded < items[j].encoded
})

sorted := make([]T, len(items))
for i := range items {
sorted[i] = items[i].value
}
return sorted, nil
}
99 changes: 99 additions & 0 deletions internal/inventory/hash_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,102 @@ func TestComputeHashIgnoresCollectedAtAndExistingHash(t *testing.T) {
require.NoError(t, err)
require.NotEqual(t, hash1, hash3)
}

func TestComputeHashIgnoresSetLikeInventoryListOrder(t *testing.T) {
base := &Snapshot{
Hostname: "host-a",
AgentConfig: AgentConfig{
EnabledComponents: []string{"gpu", "cpu"},
DisabledComponents: []string{"nic", "disk"},
},
Resources: Resources{
GPUInfo: GPUInfo{GPUs: []GPUDevice{
{UUID: "GPU-b", BusID: "2"},
{UUID: "GPU-a", BusID: "1"},
}},
DiskInfo: DiskInfo{BlockDevices: []BlockDevice{
{Name: "/dev/b", WWN: "wwn-b", Parents: []string{"parent-b-1", "parent-b-2"}},
{Name: "/dev/a", WWN: "wwn-a", Parents: []string{"parent-a-1", "parent-a-2"}},
}},
NICInfo: NICInfo{PrivateIPInterfaces: []NICInterface{
{Interface: "eth1", MAC: "00:00:00:00:00:02", IP: "10.0.0.2"},
{Interface: "eth0", MAC: "00:00:00:00:00:01", IP: "10.0.0.1"},
}},
},
}
reordered := &Snapshot{
Hostname: "host-a",
AgentConfig: AgentConfig{
EnabledComponents: []string{"cpu", "gpu"},
DisabledComponents: []string{"disk", "nic"},
},
Resources: Resources{
GPUInfo: GPUInfo{GPUs: []GPUDevice{
{UUID: "GPU-a", BusID: "1"},
{UUID: "GPU-b", BusID: "2"},
}},
DiskInfo: DiskInfo{BlockDevices: []BlockDevice{
{Name: "/dev/a", WWN: "wwn-a", Parents: []string{"parent-a-2", "parent-a-1"}},
{Name: "/dev/b", WWN: "wwn-b", Parents: []string{"parent-b-2", "parent-b-1"}},
}},
NICInfo: NICInfo{PrivateIPInterfaces: []NICInterface{
{Interface: "eth0", MAC: "00:00:00:00:00:01", IP: "10.0.0.1"},
{Interface: "eth1", MAC: "00:00:00:00:00:02", IP: "10.0.0.2"},
}},
},
}

baseHash, err := ComputeHash(base)
require.NoError(t, err)
reorderedHash, err := ComputeHash(reordered)
require.NoError(t, err)
require.Equal(t, baseHash, reorderedHash)

// Hashing must not reorder the snapshot that is later sent to the backend.
require.Equal(t, []string{"gpu", "cpu"}, base.AgentConfig.EnabledComponents)
require.Equal(t, "GPU-b", base.Resources.GPUInfo.GPUs[0].UUID)
require.Equal(t, "/dev/b", base.Resources.DiskInfo.BlockDevices[0].Name)
require.Equal(t, []string{"parent-b-1", "parent-b-2"}, base.Resources.DiskInfo.BlockDevices[0].Parents)
require.Equal(t, []string{"parent-a-2", "parent-a-1"}, reordered.Resources.DiskInfo.BlockDevices[0].Parents)
require.Equal(t, "eth1", base.Resources.NICInfo.PrivateIPInterfaces[0].Interface)
}

func TestComputeHashDetectsInventoryItemChanges(t *testing.T) {
base := &Snapshot{
Resources: Resources{
GPUInfo: GPUInfo{GPUs: []GPUDevice{{UUID: "GPU-a", BusID: "1"}}},
DiskInfo: DiskInfo{BlockDevices: []BlockDevice{{
Name: "/dev/a", Parents: []string{"immediate-parent", "root-parent"},
}}},
},
}

baseHash, err := ComputeHash(base)
require.NoError(t, err)

changedGPU := *base
changedGPU.Resources.GPUInfo.GPUs = append([]GPUDevice(nil), base.Resources.GPUInfo.GPUs...)
changedGPU.Resources.GPUInfo.GPUs[0].BusID = "2"
changedGPUHash, err := ComputeHash(&changedGPU)
require.NoError(t, err)
require.NotEqual(t, baseHash, changedGPUHash)

reorderedParents := *base
reorderedParents.Resources.DiskInfo.BlockDevices = append([]BlockDevice(nil), base.Resources.DiskInfo.BlockDevices...)
reorderedParents.Resources.DiskInfo.BlockDevices[0].Parents = []string{"root-parent", "immediate-parent"}
reorderedParentHash, err := ComputeHash(&reorderedParents)
require.NoError(t, err)
require.Equal(t, baseHash, reorderedParentHash)

changedParent := *base
changedParent.Resources.DiskInfo.BlockDevices = append([]BlockDevice(nil), base.Resources.DiskInfo.BlockDevices...)
changedParent.Resources.DiskInfo.BlockDevices[0].Parents = []string{"different-parent", "root-parent"}
changedParentHash, err := ComputeHash(&changedParent)
require.NoError(t, err)
require.NotEqual(t, baseHash, changedParentHash)
}

func TestComputeHashRejectsNilSnapshot(t *testing.T) {
_, err := ComputeHash(nil)
require.ErrorContains(t, err, "inventory snapshot is nil")
}
Loading