From ef434c755f985e15caa8fe2d588b7c9ffdd9940f Mon Sep 17 00:00:00 2001 From: xuefengh Date: Wed, 22 Jul 2026 11:52:53 -0700 Subject: [PATCH 1/2] fix(inventory): make change hash order invariant Signed-off-by: xuefengh --- internal/inventory/hash.go | 57 +++++++++++++++++++++ internal/inventory/hash_test.go | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/internal/inventory/hash.go b/internal/inventory/hash.go index d0284811..a1cc073f 100644 --- a/internal/inventory/hash.go +++ b/internal/inventory/hash.go @@ -20,6 +20,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "sort" "time" ) @@ -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 = sortedByCanonicalJSON(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 { @@ -39,3 +56,43 @@ func ComputeHash(snap *Snapshot) (string, error) { sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:]), nil } + +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 +} diff --git a/internal/inventory/hash_test.go b/internal/inventory/hash_test.go index 49b5a9c9..3eef7f6c 100644 --- a/internal/inventory/hash_test.go +++ b/internal/inventory/hash_test.go @@ -47,3 +47,93 @@ 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-1", "parent-a-2"}}, + {Name: "/dev/b", WWN: "wwn-b", Parents: []string{"parent-b-1", "parent-b-2"}}, + }}, + 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, "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) + + changedParentOrder := *base + changedParentOrder.Resources.DiskInfo.BlockDevices = append([]BlockDevice(nil), base.Resources.DiskInfo.BlockDevices...) + changedParentOrder.Resources.DiskInfo.BlockDevices[0].Parents = []string{"root-parent", "immediate-parent"} + changedParentHash, err := ComputeHash(&changedParentOrder) + 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") +} From 374734bfdfb749b68f7fea43b880d0496bbcc189 Mon Sep 17 00:00:00 2001 From: xuefengh Date: Wed, 22 Jul 2026 13:15:21 -0700 Subject: [PATCH 2/2] fix(inventory): ignore block parent ordering in hash Signed-off-by: xuefengh --- internal/inventory/hash.go | 14 +++++++++++++- internal/inventory/hash_test.go | 21 +++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/internal/inventory/hash.go b/internal/inventory/hash.go index a1cc073f..0d6acef6 100644 --- a/internal/inventory/hash.go +++ b/internal/inventory/hash.go @@ -40,7 +40,7 @@ func ComputeHash(snap *Snapshot) (string, error) { if err != nil { return "", fmt.Errorf("canonicalize GPU inventory: %w", err) } - normalized.Resources.DiskInfo.BlockDevices, err = sortedByCanonicalJSON(snap.Resources.DiskInfo.BlockDevices) + normalized.Resources.DiskInfo.BlockDevices, err = normalizedBlockDevices(snap.Resources.DiskInfo.BlockDevices) if err != nil { return "", fmt.Errorf("canonicalize disk inventory: %w", err) } @@ -57,6 +57,18 @@ func ComputeHash(snap *Snapshot) (string, error) { 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 diff --git a/internal/inventory/hash_test.go b/internal/inventory/hash_test.go index 3eef7f6c..b7f059f7 100644 --- a/internal/inventory/hash_test.go +++ b/internal/inventory/hash_test.go @@ -82,8 +82,8 @@ func TestComputeHashIgnoresSetLikeInventoryListOrder(t *testing.T) { {UUID: "GPU-b", BusID: "2"}, }}, DiskInfo: DiskInfo{BlockDevices: []BlockDevice{ - {Name: "/dev/a", WWN: "wwn-a", Parents: []string{"parent-a-1", "parent-a-2"}}, - {Name: "/dev/b", WWN: "wwn-b", Parents: []string{"parent-b-1", "parent-b-2"}}, + {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"}, @@ -102,6 +102,8 @@ func TestComputeHashIgnoresSetLikeInventoryListOrder(t *testing.T) { 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) } @@ -125,10 +127,17 @@ func TestComputeHashDetectsInventoryItemChanges(t *testing.T) { require.NoError(t, err) require.NotEqual(t, baseHash, changedGPUHash) - changedParentOrder := *base - changedParentOrder.Resources.DiskInfo.BlockDevices = append([]BlockDevice(nil), base.Resources.DiskInfo.BlockDevices...) - changedParentOrder.Resources.DiskInfo.BlockDevices[0].Parents = []string{"root-parent", "immediate-parent"} - changedParentHash, err := ComputeHash(&changedParentOrder) + 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) }