diff --git a/SPEC.md b/SPEC.md index ef0a452..d16248d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,3 +1,9 @@ + + # Service Specification: fru-tracker ## 1. System Overview diff --git a/apis/example.fabrica.dev/v1/device_types.go b/apis/example.fabrica.dev/v1/device_types.go index 76b8df6..8c838bd 100644 --- a/apis/example.fabrica.dev/v1/device_types.go +++ b/apis/example.fabrica.dev/v1/device_types.go @@ -24,7 +24,7 @@ type DeviceSpec struct { DeviceType string `json:"deviceType" validate:"required"` Manufacturer string `json:"manufacturer,omitempty"` PartNumber string `json:"partNumber,omitempty"` - SerialNumber string `json:"serialNumber" validate:"required"` + SerialNumber string `json:"serialNumber"` // ParentID holds the UID of the parent device. // This will be populated by the reconciler. diff --git a/internal/storage/device_reconciliation.go b/internal/storage/device_reconciliation.go new file mode 100644 index 0000000..5a354d2 --- /dev/null +++ b/internal/storage/device_reconciliation.go @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +// +// SPDX-License-Identifier: MIT + +package storage + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "time" + + v1 "github.com/example/fru-tracker/apis/example.fabrica.dev/v1" + "github.com/example/fru-tracker/internal/storage/ent" + entresource "github.com/example/fru-tracker/internal/storage/ent/resource" + "github.com/openchami/fabrica/pkg/resource" +) + +// LoadDevicesByIdentifiers loads Device resources whose names match any of the provided identifiers. +// The reconciler uses this to prefetch only the records that matter for a snapshot. +func LoadDevicesByIdentifiers(ctx context.Context, identifiers []string) ([]*v1.Device, error) { + if err := ensureBackendReady(); err != nil { + return nil, err + } + + lookup := uniqueStrings(identifiers) + if len(lookup) == 0 { + return nil, nil + } + + entResources, err := entClient.Resource.Query(). + Where( + entresource.KindEQ("Device"), + entresource.NameIn(lookup...), + ). + WithLabels(). + WithAnnotations(). + All(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load scoped Device resources: %w", err) + } + + devices := make([]*v1.Device, 0, len(entResources)) + for _, entResource := range entResources { + fabricaResource, err := FromEntResource(ctx, entResource) + if err != nil { + continue + } + devices = append(devices, fabricaResource.(*v1.Device)) + } + + return devices, nil +} + +// SaveDevicesBulk upserts a set of Device resources in a single transaction. +func SaveDevicesBulk(ctx context.Context, devices []*v1.Device) error { + if err := ensureBackendReady(); err != nil { + return err + } + + return WithTx(ctx, func(tx *ent.Tx) error { + for _, device := range devices { + if device == nil { + continue + } + + if device.Metadata.UID == "" { + uid, err := resource.GenerateUIDForResource("Device") + if err != nil { + return fmt.Errorf("failed to generate UID for Device: %w", err) + } + device.Metadata.UID = uid + } + if device.APIVersion == "" { + device.APIVersion = "example.fabrica.dev/v1" + } + if device.Kind == "" { + device.Kind = "Device" + } + if device.Metadata.Name == "" { + device.Metadata.Name = device.Metadata.UID + } + + spec, err := json.Marshal(device.Spec) + if err != nil { + return fmt.Errorf("failed to marshal Device spec: %w", err) + } + status, err := json.Marshal(device.Status) + if err != nil { + return fmt.Errorf("failed to marshal Device status: %w", err) + } + + existing, err := tx.Resource.Query(). + Where(entresource.UIDEQ(device.Metadata.UID), entresource.KindEQ("Device")). + Only(ctx) + if err != nil && !ent.IsNotFound(err) { + return fmt.Errorf("failed to look up Device %s: %w", device.Metadata.UID, err) + } + + now := time.Now() + if ent.IsNotFound(err) { + builder := tx.Resource.Create(). + SetUID(device.Metadata.UID). + SetName(device.Metadata.Name). + SetAPIVersion(device.APIVersion). + SetKind("Device"). + SetResourceType("Device"). + SetSpec(spec). + SetStatus(status). + SetCreatedAt(now). + SetUpdatedAt(now) + + if !device.Metadata.CreatedAt.IsZero() { + builder = builder.SetCreatedAt(device.Metadata.CreatedAt) + } + if !device.Metadata.UpdatedAt.IsZero() { + builder = builder.SetUpdatedAt(device.Metadata.UpdatedAt) + } + + if _, err := builder.Save(ctx); err != nil { + return fmt.Errorf("failed to create Device %s: %w", device.Metadata.UID, err) + } + continue + } + + builder := tx.Resource.UpdateOneID(existing.ID). + SetName(device.Metadata.Name). + SetAPIVersion(device.APIVersion). + SetKind("Device"). + SetResourceType("Device"). + SetSpec(spec). + SetStatus(status). + SetUpdatedAt(now) + + if _, err := builder.Save(ctx); err != nil { + return fmt.Errorf("failed to update Device %s: %w", device.Metadata.UID, err) + } + } + return nil + }) +} + +func uniqueStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if value == "" { + continue + } + seen[value] = struct{}{} + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} \ No newline at end of file diff --git a/pkg/reconcilers/discoverysnapshot_reconciler.go b/pkg/reconcilers/discoverysnapshot_reconciler.go index 5791aae..3d2e1d7 100644 --- a/pkg/reconcilers/discoverysnapshot_reconciler.go +++ b/pkg/reconcilers/discoverysnapshot_reconciler.go @@ -8,9 +8,11 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" v1 "github.com/example/fru-tracker/apis/example.fabrica.dev/v1" + "github.com/example/fru-tracker/internal/storage" "github.com/openchami/fabrica/pkg/fabrica" "github.com/openchami/fabrica/pkg/resource" ) @@ -30,126 +32,286 @@ func (r *DiscoverySnapshotReconciler) reconcileDiscoverySnapshot(ctx context.Con if err := json.Unmarshal(snapshot.Spec.RawData, &payloadSpecs); err != nil { snapshot.Status.Phase = "Error" snapshot.Status.Message = fmt.Sprintf("Failed to parse rawData: %v", err) - return nil + snapshot.Status.Ready = false + if updateErr := r.UpdateStatus(ctx, snapshot); updateErr != nil { + return fmt.Errorf("failed to persist error status: %w", updateErr) + } + return fmt.Errorf("failed to parse rawData: %w", err) } - deviceMapBySerial, err := r.buildDeviceMapBySerial(ctx) + lookupKeys := collectLookupKeys(payloadSpecs) + existingDevices, err := storage.LoadDevicesByIdentifiers(ctx, lookupKeys) if err != nil { - return fmt.Errorf("failed to build device map by Serial: %w", err) + snapshot.Status.Phase = "Error" + snapshot.Status.Message = fmt.Sprintf("Failed to prefetch devices: %v", err) + snapshot.Status.Ready = false + if updateErr := r.UpdateStatus(ctx, snapshot); updateErr != nil { + return fmt.Errorf("failed to persist error status: %w", updateErr) + } + return fmt.Errorf("failed to prefetch devices: %w", err) } - r.Logger.Infof("Reconciling %s: Loaded %d devices by Serial", snapshot.GetName(), len(deviceMapBySerial)) - snapshotDeviceMap := make(map[string]*v1.Device) - processedCount := 0 + bySerial := make(map[string]*v1.Device) + byURI := make(map[string]*v1.Device) + byUID := make(map[string]*v1.Device) + for _, device := range existingDevices { + indexDevice(device, bySerial, byURI, byUID) + } + + processedDevices := make([]*v1.Device, 0, len(payloadSpecs)) + createdCount := 0 + updatedCount := 0 for _, spec := range payloadSpecs { - serial := spec.SerialNumber - if serial == "" { - r.Logger.Errorf("Reconciling %s: Skipping device, missing serialNumber", snapshot.GetName()) + device := deviceFromSpec(spec) + if device == nil { + r.Logger.Warnf("Reconciling %s: Skipping invalid device spec", snapshot.GetName()) continue } - existingDevice, found := deviceMapBySerial[serial] - if !found { - r.Logger.Infof("Reconciling %s (Pass 1): Creating new device: %s", snapshot.GetName(), serial) - newDevice, err := r.createNewDevice(ctx, spec, serial) - if err != nil { - r.Logger.Errorf("Reconciling %s (Pass 1): Failed to create device %s: %v", snapshot.GetName(), serial, err) - continue - } - snapshotDeviceMap[serial] = newDevice - deviceMapBySerial[serial] = newDevice - - } else { - r.Logger.Infof("Reconciling %s (Pass 1): Updating existing device: %s (UID: %s)", snapshot.GetName(), serial, existingDevice.GetUID()) + if existing := matchDevice(spec, bySerial, byURI); existing != nil { + merged := mergeDevice(existing, spec) + processedDevices = append(processedDevices, merged) + indexDevice(merged, bySerial, byURI, byUID) + updatedCount++ + continue + } - spec.ParentID = existingDevice.Spec.ParentID - existingDevice.Spec = spec - existingDevice.Metadata.UpdatedAt = time.Now() + processedDevices = append(processedDevices, device) + indexDevice(device, bySerial, byURI, byUID) + createdCount++ + } - if err := r.Client.Update(ctx, existingDevice); err != nil { - r.Logger.Errorf("Reconciling %s (Pass 1): Failed to update device %s: %v", snapshot.GetName(), serial, err) - continue - } - snapshotDeviceMap[serial] = existingDevice + if err := storage.SaveDevicesBulk(ctx, processedDevices); err != nil { + snapshot.Status.Phase = "Error" + snapshot.Status.Message = fmt.Sprintf("Failed to persist device changes: %v", err) + snapshot.Status.Ready = false + if updateErr := r.UpdateStatus(ctx, snapshot); updateErr != nil { + return fmt.Errorf("failed to persist error status: %w", updateErr) } - processedCount++ + return fmt.Errorf("failed to persist device changes: %w", err) } r.Logger.Infof("Reconciling %s (Pass 2): Linking parent relationships...", snapshot.GetName()) linksUpdated := 0 - for _, dev := range snapshotDeviceMap { - parentSerial := dev.Spec.ParentSerialNumber - if parentSerial == "" { + cycleSkips := 0 + linkUpdates := make([]*v1.Device, 0, len(processedDevices)) + for _, dev := range processedDevices { + parentKey := dev.Spec.ParentSerialNumber + parentURI := propertyString(dev.Spec.Properties, "redfish_parent_uri") + if parentKey == "" { + parentKey = parentURI + } + if parentKey == "" { + continue + } + + parentDevice := bySerial[parentKey] + if parentDevice == nil { + parentDevice = byURI[parentKey] + } + if parentDevice == nil && parentURI != "" && parentURI != parentKey { + parentDevice = bySerial[parentURI] + if parentDevice == nil { + parentDevice = byURI[parentURI] + } + } + if parentDevice == nil { + r.Logger.Errorf("Reconciling %s (Pass 2): Parent device %s not found for child %s", snapshot.GetName(), parentKey, deviceLabel(dev)) continue } - parentDevice, found := deviceMapBySerial[parentSerial] - if !found { - r.Logger.Errorf("Reconciling %s (Pass 2): Parent device with serial %s not found for child %s", snapshot.GetName(), parentSerial, dev.Spec.SerialNumber) + if dev.GetUID() == parentDevice.GetUID() { + r.Logger.Warnf("Reconciling %s (Pass 2): Skipping self-parenting link for %s", snapshot.GetName(), deviceLabel(dev)) continue } if dev.Spec.ParentID == parentDevice.GetUID() { continue } - r.Logger.Infof("Reconciling %s (Pass 2): Linking %s (UID: %s) to parent %s (UID: %s)", - snapshot.GetName(), dev.GetName(), dev.GetUID(), parentDevice.GetName(), parentDevice.GetUID()) + if wouldCreateCycle(dev.GetUID(), parentDevice.GetUID(), byUID) { + cycleSkips++ + r.Logger.Warnf("Reconciling %s (Pass 2): Skipping cyclic parent link %s -> %s", snapshot.GetName(), deviceLabel(dev), deviceLabel(parentDevice)) + continue + } + r.Logger.Infof("Reconciling %s (Pass 2): Linking %s (UID: %s) to parent %s (UID: %s)", snapshot.GetName(), deviceLabel(dev), dev.GetUID(), deviceLabel(parentDevice), parentDevice.GetUID()) dev.Spec.ParentID = parentDevice.GetUID() dev.Metadata.UpdatedAt = time.Now() + linkUpdates = append(linkUpdates, dev) + linksUpdated++ + indexDevice(dev, bySerial, byURI, byUID) + } - if err := r.Client.Update(ctx, dev); err != nil { - r.Logger.Errorf("Reconciling %s (Pass 2): Failed to update parent link for %s: %v", snapshot.GetName(), dev.GetName(), err) - } else { - linksUpdated++ + if err := storage.SaveDevicesBulk(ctx, linkUpdates); err != nil { + snapshot.Status.Phase = "Error" + snapshot.Status.Message = fmt.Sprintf("Failed to persist parent links: %v", err) + snapshot.Status.Ready = false + if updateErr := r.UpdateStatus(ctx, snapshot); updateErr != nil { + return fmt.Errorf("failed to persist error status: %w", updateErr) } + return fmt.Errorf("failed to persist parent links: %w", err) } snapshot.Status.Phase = "Completed" - snapshot.Status.Message = fmt.Sprintf("Snapshot processed. %d devices created/updated. %d parent links updated.", processedCount, linksUpdated) + snapshot.Status.Message = fmt.Sprintf("Snapshot processed. %d devices created, %d updated, %d parent links established.", createdCount, updatedCount, linksUpdated) + if cycleSkips > 0 { + snapshot.Status.Message = fmt.Sprintf("%s %d cyclic links skipped.", snapshot.Status.Message, cycleSkips) + } snapshot.Status.Ready = true r.Logger.Infof("Reconciling %s: Successfully reconciled", snapshot.GetName()) return nil } -func (r *DiscoverySnapshotReconciler) createNewDevice(ctx context.Context, spec v1.DeviceSpec, serialNumber string) (*v1.Device, error) { +func collectLookupKeys(specs []v1.DeviceSpec) []string { + keys := make([]string, 0, len(specs)*4) + for _, spec := range specs { + keys = append(keys, spec.SerialNumber) + keys = append(keys, propertyString(spec.Properties, "redfish_uri")) + keys = append(keys, spec.ParentSerialNumber) + keys = append(keys, propertyString(spec.Properties, "redfish_parent_uri")) + } + return keys +} + +func deviceFromSpec(spec v1.DeviceSpec) *v1.Device { uid, err := resource.GenerateUIDForResource("Device") if err != nil { - return nil, fmt.Errorf("failed to generate UID for device: %w", err) + return nil } - newDevice := &v1.Device{ + device := &v1.Device{ APIVersion: "example.fabrica.dev/v1", Kind: "Device", Metadata: fabrica.Metadata{ - Name: serialNumber, + Name: chooseDeviceName(spec), UID: uid, }, Spec: spec, } - newDevice.Metadata.Initialize(newDevice.Metadata.Name, newDevice.Metadata.UID) + device.Metadata.Initialize(device.Metadata.Name, device.Metadata.UID) + return device +} + +func mergeDevice(existing *v1.Device, spec v1.DeviceSpec) *v1.Device { + merged := *existing + merged.Spec = existing.Spec + if spec.DeviceType != "" { + merged.Spec.DeviceType = spec.DeviceType + } + if spec.Manufacturer != "" { + merged.Spec.Manufacturer = spec.Manufacturer + } + if spec.PartNumber != "" { + merged.Spec.PartNumber = spec.PartNumber + } + if spec.SerialNumber != "" { + merged.Spec.SerialNumber = spec.SerialNumber + } + if spec.ParentSerialNumber != "" { + merged.Spec.ParentSerialNumber = spec.ParentSerialNumber + } + if len(spec.Properties) > 0 { + merged.Spec.Properties = mergeProperties(existing.Spec.Properties, spec.Properties) + } + merged.Metadata.Name = chooseDeviceName(merged.Spec) + merged.Metadata.UpdatedAt = time.Now() + return &merged +} - if err := r.Client.Create(ctx, newDevice); err != nil { - return nil, fmt.Errorf("failed to create device %s: %w", serialNumber, err) +func chooseDeviceName(spec v1.DeviceSpec) string { + if spec.SerialNumber != "" { + return spec.SerialNumber + } + if uri := propertyString(spec.Properties, "redfish_uri"); uri != "" { + return uri } - return newDevice, nil + if spec.DeviceType != "" { + return spec.DeviceType + } + return "device" } -func (r *DiscoverySnapshotReconciler) buildDeviceMapBySerial(ctx context.Context) (map[string]*v1.Device, error) { - resourceList, err := r.Client.List(ctx, "Device") - if err != nil { - return nil, err +func propertyString(properties map[string]json.RawMessage, key string) string { + if len(properties) == 0 { + return "" } - deviceMap := make(map[string]*v1.Device) - for _, item := range resourceList { - dev, ok := item.(*v1.Device) - if !ok { - r.Logger.Errorf("Reconciling: Found non-device item in storage, skipping.") - continue + raw, ok := properties[key] + if !ok || len(raw) == 0 || string(raw) == "null" { + return "" + } + var value string + if err := json.Unmarshal(raw, &value); err == nil { + return value + } + return strings.Trim(string(raw), `"`) +} + +func mergeProperties(existing map[string]json.RawMessage, incoming map[string]json.RawMessage) map[string]json.RawMessage { + merged := make(map[string]json.RawMessage, len(existing)+len(incoming)) + for key, value := range existing { + merged[key] = value + } + for key, value := range incoming { + merged[key] = value + } + return merged +} + +func indexDevice(device *v1.Device, bySerial, byURI, byUID map[string]*v1.Device) { + if device == nil { + return + } + if device.GetUID() != "" { + byUID[device.GetUID()] = device + } + if device.Spec.SerialNumber != "" { + bySerial[device.Spec.SerialNumber] = device + } + if uri := propertyString(device.Spec.Properties, "redfish_uri"); uri != "" { + byURI[uri] = device + } +} + +func matchDevice(spec v1.DeviceSpec, bySerial, byURI map[string]*v1.Device) *v1.Device { + if spec.SerialNumber != "" { + if device := bySerial[spec.SerialNumber]; device != nil { + return device + } + } + if uri := propertyString(spec.Properties, "redfish_uri"); uri != "" { + if device := byURI[uri]; device != nil { + return device + } + } + return nil +} + +func deviceLabel(device *v1.Device) string { + if device == nil { + return "" + } + if device.Spec.SerialNumber != "" { + return device.Spec.SerialNumber + } + if uri := propertyString(device.Spec.Properties, "redfish_uri"); uri != "" { + return uri + } + return device.GetName() +} + +func wouldCreateCycle(childUID, parentUID string, ancestry map[string]*v1.Device) bool { + seen := map[string]struct{}{childUID: {}} + current := parentUID + for current != "" { + if _, ok := seen[current]; ok { + return true } - if dev.Spec.SerialNumber != "" { - deviceMap[dev.Spec.SerialNumber] = dev + seen[current] = struct{}{} + parent := ancestry[current] + if parent == nil { + return false } + current = parent.Spec.ParentID } - return deviceMap, nil + return false } diff --git a/pkg/reconcilers/discoverysnapshot_reconciler_test.go b/pkg/reconcilers/discoverysnapshot_reconciler_test.go new file mode 100644 index 0000000..7bfd49d --- /dev/null +++ b/pkg/reconcilers/discoverysnapshot_reconciler_test.go @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +// +// SPDX-License-Identifier: MIT + +package reconcilers + +import ( + "context" + "encoding/json" + "testing" + + v1 "github.com/example/fru-tracker/apis/example.fabrica.dev/v1" + "github.com/example/fru-tracker/internal/storage" + "github.com/example/fru-tracker/internal/storage/ent/enttest" + _ "github.com/mattn/go-sqlite3" + "github.com/openchami/fabrica/pkg/events" + "github.com/openchami/fabrica/pkg/fabrica" + "github.com/openchami/fabrica/pkg/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscoverySnapshotReconciler(t *testing.T) { + resource.RegisterResourcePrefix("Device", "device") + resource.RegisterResourcePrefix("DiscoverySnapshot", "discoverysnapshot") + + tests := []struct { + name string + seedDevices []*v1.Device + payload []v1.DeviceSpec + expectedCount int + expectedSerials map[string]string + expectedParents map[string]string + expectedPhase string + expectedReady bool + expectedMessageHas []string + }{ + { + name: "uri merge promotes serial number", + seedDevices: []*v1.Device{ + newDevice(t, "", "/redfish/v1/Chassis/1", "Chassis", map[string]json.RawMessage{ + "redfish_uri": rawJSONString(t, "/redfish/v1/Chassis/1"), + }), + }, + payload: []v1.DeviceSpec{ + { + DeviceType: "Chassis", + SerialNumber: "CHASSIS-1", + Properties: map[string]json.RawMessage{ + "redfish_uri": rawJSONString(t, "/redfish/v1/Chassis/1"), + }, + }, + }, + expectedCount: 1, + expectedSerials: map[string]string{"CHASSIS-1": "/redfish/v1/Chassis/1"}, + expectedParents: map[string]string{}, + expectedPhase: "Completed", + expectedReady: true, + expectedMessageHas: []string{ + "0 devices created", + "1 updated", + "0 parent links established", + }, + }, + { + name: "parent uri fallback skips cycle", + payload: []v1.DeviceSpec{ + { + DeviceType: "Node", + SerialNumber: "NODE-A", + Properties: map[string]json.RawMessage{ + "redfish_uri": rawJSONString(t, "/redfish/v1/Systems/A"), + }, + ParentSerialNumber: "NODE-B", + }, + { + DeviceType: "Node", + SerialNumber: "NODE-B", + Properties: map[string]json.RawMessage{ + "redfish_uri": rawJSONString(t, "/redfish/v1/Systems/B"), + }, + ParentSerialNumber: "NODE-A", + }, + { + DeviceType: "DIMM", + SerialNumber: "DIMM-1", + Properties: map[string]json.RawMessage{ + "redfish_uri": rawJSONString(t, "/redfish/v1/Systems/A/Memory/1"), + "redfish_parent_uri": rawJSONString(t, "/redfish/v1/Systems/A"), + }, + }, + }, + expectedCount: 3, + expectedSerials: map[string]string{"NODE-A": "/redfish/v1/Systems/A", "NODE-B": "/redfish/v1/Systems/B", "DIMM-1": "/redfish/v1/Systems/A/Memory/1"}, + expectedParents: map[string]string{"DIMM-1": "NODE-A", "NODE-A": "NODE-B"}, + expectedPhase: "Completed", + expectedReady: true, + expectedMessageHas: []string{ + "3 devices created", + "2 parent links established", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + client := enttest.Open(t, "sqlite3", "file:reconcile?mode=memory&cache=shared&_fk=1") + t.Cleanup(func() { + require.NoError(t, client.Close()) + }) + storage.SetEntClient(client) + + bus := events.NewInMemoryEventBus(10, 10) + bus.Start() + t.Cleanup(func() { + _ = bus.Close() + }) + + reconciler := NewDefaultDiscoverySnapshotReconciler(storage.NewStorageClient(), bus) + seedDevices := make([]*v1.Device, 0, len(tt.seedDevices)) + seedDevices = append(seedDevices, tt.seedDevices...) + if len(seedDevices) > 0 { + require.NoError(t, storage.SaveDevicesBulk(ctx, seedDevices)) + } + + rawData, err := json.Marshal(tt.payload) + require.NoError(t, err) + + snapshot := &v1.DiscoverySnapshot{ + APIVersion: "example.fabrica.dev/v1", + Kind: "DiscoverySnapshot", + Metadata: fabrica.Metadata{ + Name: "snapshot-1", + UID: "snapshot-1", + }, + Spec: v1.DiscoverySnapshotSpec{RawData: rawData}, + } + snapshot.Metadata.Initialize(snapshot.Metadata.Name, snapshot.Metadata.UID) + + err = reconciler.reconcileDiscoverySnapshot(ctx, snapshot) + require.NoError(t, err) + assert.Equal(t, tt.expectedPhase, snapshot.Status.Phase) + assert.Equal(t, tt.expectedReady, snapshot.Status.Ready) + for _, fragment := range tt.expectedMessageHas { + assert.Contains(t, snapshot.Status.Message, fragment) + } + + devices, err := storage.LoadAllDevices(ctx) + require.NoError(t, err) + assert.Len(t, devices, tt.expectedCount) + + bySerial := make(map[string]*v1.Device) + for _, device := range devices { + if device.Spec.SerialNumber != "" { + bySerial[device.Spec.SerialNumber] = device + } + } + + for serial, uri := range tt.expectedSerials { + device, ok := bySerial[serial] + require.True(t, ok, "expected device %s", serial) + assert.Equal(t, uri, propertyString(device.Spec.Properties, "redfish_uri")) + } + + for childSerial, parentSerial := range tt.expectedParents { + child, ok := bySerial[childSerial] + require.True(t, ok, "expected child %s", childSerial) + parent, ok := bySerial[parentSerial] + require.True(t, ok, "expected parent %s", parentSerial) + assert.Equal(t, parent.GetUID(), child.Spec.ParentID) + } + + if tt.name == "uri merge promotes serial number" { + assert.Equal(t, "CHASSIS-1", devices[0].Spec.SerialNumber) + assert.Equal(t, "CHASSIS-1", devices[0].Metadata.Name) + } + }) + } +} + +func newDevice(t *testing.T, serialNumber, name, deviceType string, properties map[string]json.RawMessage) *v1.Device { + t.Helper() + device := &v1.Device{ + APIVersion: "example.fabrica.dev/v1", + Kind: "Device", + Metadata: fabrica.Metadata{Name: name}, + Spec: v1.DeviceSpec{ + DeviceType: deviceType, + SerialNumber: serialNumber, + Properties: properties, + }, + } + device.Metadata.Initialize(device.Metadata.Name, device.Metadata.UID) + return device +} + +func rawJSONString(t *testing.T, value string) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + require.NoError(t, err) + return raw +}