From 723d7c3eb1eae751e8150b069d3f0dee34c6fd57 Mon Sep 17 00:00:00 2001 From: Francesco Giudici Date: Fri, 13 Sep 2024 08:45:45 +0200 Subject: [PATCH] Label Templates: rework collection of 'hostinfo' data and collected variables (#843) * Label Templates: rework collection of 'System Data' info Allow automatic conversion of data to map[string]interface{} (Template Labels format) using reflection where all data can be useful. Still do manual collection of data in the map[string]interface{} when dropping of part of the data is needed. Use the original hostinfo field names, drop spaces in the keys. Import also the SMBIOS data from hostinfo, since are there (so also without dmidecode tool some SMBIOS info will be available). Keep support of old HARDWARE variable (to be deprecated sooner or later). * register/dumpdata: rework initial version adding more options notably the "label" output format, which is now the default * register: send both legacy and new Label Templates variables we don't want to break possible setups using the older Label Templates variables: send both. * tests: check new hostinfo Label Templates format --------- Signed-off-by: Francesco Giudici --- cmd/register/showdata.go | 110 ++++++++++--- pkg/hostinfo/hostinfo.go | 247 +++++++++++++++++++++++++++- pkg/register/register.go | 6 +- pkg/server/api_registration_test.go | 46 +++++- 4 files changed, 378 insertions(+), 31 deletions(-) diff --git a/cmd/register/showdata.go b/cmd/register/showdata.go index 99ceed1c..661bd2ab 100644 --- a/cmd/register/showdata.go +++ b/cmd/register/showdata.go @@ -19,21 +19,29 @@ package main import ( "encoding/json" "fmt" + "sort" "github.com/rancher/elemental-operator/pkg/dmidecode" "github.com/rancher/elemental-operator/pkg/hostinfo" "github.com/rancher/elemental-operator/pkg/log" "github.com/spf13/cobra" "github.com/spf13/viper" + "sigs.k8s.io/yaml" ) const ( - DUMPHW = "hardware" - DUMPSMBIOS = "smbios" + DUMPLEGACY = "legacy" + DUMPSMBIOS = "smbios" + DUMPHOSTINFO = "hostinfo" + FORMATLABELS = "labels" + FORMATJSON = "json" + FORMATJSONCOMPACT = "json-compact" + FORMATYAML = "yaml" ) func newDumpDataCommand() *cobra.Command { var raw bool + var format string cmd := &cobra.Command{ Use: "dumpdata", @@ -41,64 +49,120 @@ func newDumpDataCommand() *cobra.Command { Short: "Show host data sent during the registration phase", Long: "Prints to stdout the data sent by the registering client " + "to the Elemental Operator.\nTakes the type of host data to dump " + - "as argument, be it '" + DUMPHW + "' or '" + DUMPSMBIOS + "'.", + "as argument, be it '" + DUMPHOSTINFO + "' (default), " + DUMPLEGACY + + "' or '" + DUMPSMBIOS + "'.", Args: cobra.MatchAll(cobra.MaximumNArgs(1), cobra.OnlyValidArgs), - ValidArgs: []string{DUMPHW, DUMPSMBIOS}, + ValidArgs: []string{DUMPHOSTINFO, DUMPLEGACY, DUMPSMBIOS}, RunE: func(_ *cobra.Command, args []string) error { - return dumpdata(args, raw) + return dumpdata(args, format, raw) }, } viper.AutomaticEnv() - cmd.Flags().BoolVarP(&raw, "raw", "r", false, "dump raw data before conversion to label templates' variables") + cmd.Flags().BoolVarP(&raw, "raw", "r", false, "dump all collected raw data before postprocessing to refine available label templates variables") _ = viper.BindPFlag("raw", cmd.Flags().Lookup("raw")) - + cmd.Flags().StringVarP(&format, "format", "f", FORMATLABELS, "ouput format ['"+FORMATLABELS+"', '"+FORMATYAML+"', '"+FORMATJSON+"', '"+FORMATJSONCOMPACT+"']") + _ = viper.BindPFlag("format", cmd.Flags().Lookup("format")) return cmd } -func dumpdata(args []string, raw bool) error { - dataType := "hardware" +func dumpdata(args []string, format string, raw bool) error { + dataType := "hostinfo" if len(args) > 0 { dataType = args[0] } - var hostData interface{} + var mapData map[string]interface{} switch dataType { - case DUMPHW: - hwData, err := hostinfo.Host() + case DUMPHOSTINFO: + hostData, err := hostinfo.Host() if err != nil { log.Fatalf("Cannot retrieve host data: %s", err) } if raw { - hostData = hwData + mapData = hostinfo.ExtractFullData(hostData) } else { - dataMap, err := hostinfo.ExtractLabels(hwData) - if err != nil { - log.Fatalf("Cannot convert host data to labels: %s", err) - } - hostData = dataMap + mapData = hostinfo.ExtractLabels(hostData) + } + case DUMPLEGACY: + hwData, err := hostinfo.Host() + if err != nil { + log.Fatalf("Cannot retrieve host data: %s", err) } + if raw { + mapData = hostinfo.ExtractFullData(hwData) + } else { + mapData = hostinfo.ExtractLabelsLegacy(hwData) + } case DUMPSMBIOS: smbiosData, err := dmidecode.Decode() if err != nil { log.Fatalf("Cannot retrieve SMBIOS data: %s", err) } - hostData = smbiosData - + mapData = smbiosData default: // Should never happen but manage it anyway log.Fatalf("Unsupported data type: %s", dataType) } - jsonData, err := json.MarshalIndent(hostData, "", " ") + var serializedData []byte + var err error + + switch format { + case FORMATLABELS: + labels := map2Labels("", mapData) + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + fmt.Printf("%-52s: %q\n", k, labels[k]) + } + return nil + case FORMATJSON: + serializedData, err = json.MarshalIndent(mapData, "", " ") + case FORMATJSONCOMPACT: + serializedData, err = json.Marshal(mapData) + case FORMATYAML: + serializedData, err = yaml.Marshal(mapData) + default: + // Should never happen but manage it anyway + log.Fatalf("Unsupported output type: %s", format) + } + if err != nil { - log.Fatalf("Cannot convert host data to json: %s", err) + log.Fatalf("Cannot convert host data to %s: %s", format, err) } - fmt.Printf("%s\n", string(jsonData)) + fmt.Printf("%s\n", string(serializedData)) return nil } + +func map2Labels(rootKey string, data map[string]interface{}) map[string]string { + ret := map[string]string{} + + for key, val := range data { + lbl := key + if len(rootKey) > 0 { + lbl = rootKey + "/" + lbl + } + if _, ok := val.(string); ok { + lbl = "${" + lbl + "}" + ret[lbl] = fmt.Sprintf("%s", val) + continue + } + if _, ok := val.(map[string]interface{}); !ok { + continue + } + for k, v := range map2Labels(lbl, val.(map[string]interface{})) { + ret[k] = v + } + } + return ret +} diff --git a/pkg/hostinfo/hostinfo.go b/pkg/hostinfo/hostinfo.go index 9d3dd16a..905bb216 100644 --- a/pkg/hostinfo/hostinfo.go +++ b/pkg/hostinfo/hostinfo.go @@ -20,7 +20,9 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strconv" + "strings" "github.com/jaypipes/ghw" "github.com/jaypipes/ghw/pkg/baseboard" @@ -148,10 +150,111 @@ func FillData(data []byte) (map[string]interface{}, error) { if err := json.Unmarshal(data, &systemData); err != nil { return nil, fmt.Errorf("unmarshalling system data payload: %w", err) } - return ExtractLabels(*systemData) + return ExtractLabelsLegacy(*systemData), nil } -func ExtractLabels(systemData HostInfo) (map[string]interface{}, error) { +func isBaseReflectKind(k reflect.Kind) bool { + switch k { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Bool, reflect.String, reflect.Float32, reflect.Float64: + return true + } + return false +} + +// reflectValToInterface() converts the passed value to either a string or to a map[string]interface{}, +// where the interface{} part could be either a string or an embedded map[string]interface{}. +// This is the core function to translate hostdata (passed as a reflect.Value) to map[string]interface{} +// containing the Template Labels values. +func reflectValToInterface(v reflect.Value) interface{} { + mapData := map[string]interface{}{} + + switch v.Kind() { + case reflect.Struct: + for i := 0; i < v.NumField(); i++ { + // Skip exported fields + if !v.Type().Field(i).IsExported() { + continue + } + // Skip also disabled fields from json serialization. + // This is needed to avoid loops, like the one in systemData.Block (Disks and Partitions refer to + // each other)... :-/ + if v.Type().Field(i).Tag == "json:\"-\"" { + continue + } + + fieldName := v.Type().Field(i).Name + fieldVal := v.Field(i) + + if !fieldVal.IsValid() { + continue + } + if v.Type().Field(i).Anonymous { + return reflectValToInterface(fieldVal) + } + + mapData[fieldName] = reflectValToInterface(fieldVal) + } + + case reflect.Pointer: + if v.IsNil() { + return "" + } + return reflectValToInterface(v.Elem()) + + case reflect.Slice: + if v.Len() == 0 { + return "" + } + + if isBaseReflectKind(v.Index(0).Kind()) { + txt := fmt.Sprintf("%v", v.Index(0)) + for j := 1; j < v.Len(); j++ { + txt += fmt.Sprintf(", %v", v.Index(j)) + } + return txt + } + + for k := 0; k < v.Len(); k++ { + mapData[strconv.Itoa(k)] = reflectValToInterface(v.Index(k)) + } + + default: + return fmt.Sprintf("%v", v) + } + return mapData +} + +// dataToLabelMap ensures the value returned by reflectValToInterface() is a map, +// otherwise returns an empty map (mainly to filter out empty vals returned as empty +// strings) +func dataToLabelMap(val interface{}) map[string]interface{} { + refVal := reflectValToInterface(reflect.ValueOf(val)) + if labelMap, ok := refVal.(map[string]interface{}); ok { + return labelMap + } + return map[string]interface{}{} +} + +// ExtractFullData returns all the available hostinfo data without any check +// or post elaboration as a map[string]interface{}, where the interface{} is +// either a string or an embedded map[string]interface{} +func ExtractFullData(systemData HostInfo) map[string]interface{} { + labels := dataToLabelMap(systemData) + + return labels +} + +// Some data from HostInfo are invalid: drop them +func sanitizeHostInfoVal(data string) string { + if strings.HasPrefix(data, "Unknown!") { + return "" + } + return data +} + +func ExtractLabelsLegacy(systemData HostInfo) map[string]interface{} { memory := map[string]interface{}{} if systemData.Memory != nil { memory["Total Physical Bytes"] = strconv.Itoa(int(systemData.Memory.TotalPhysicalBytes)) @@ -238,7 +341,145 @@ func ExtractLabels(systemData HostInfo) (map[string]interface{}, error) { // systemData.Chassis -> asset, serial, vendor,version,product, type. Maybe be useful depending on the provider. // systemData.Topology -> CPU/memory and cache topology. No idea if useful. - return labels, nil + return labels +} + +func ExtractLabels(systemData HostInfo) map[string]interface{} { + labels := map[string]interface{}{} + + // SMBIOS DATA + bios := dataToLabelMap(systemData.BIOS) + baseboard := dataToLabelMap(systemData.Baseboard) + chassis := dataToLabelMap(systemData.Chassis) + product := dataToLabelMap(systemData.Product) + memory := dataToLabelMap(systemData.Memory) + + // CPU raw data includes extended Cores info, pick all the other values + cpu := map[string]interface{}{} + if systemData.CPU != nil { + if len(systemData.CPU.Processors) > 0 { + cpuProcsMap := map[string]interface{}{} + cpu["Processors"] = cpuProcsMap + for i, processor := range systemData.CPU.Processors { + cpuProcsMap[strconv.Itoa(i)] = map[string]interface{}{ + "Capabilities": strings.Join(processor.Capabilities, ","), + "ID": strconv.Itoa(processor.ID), + "Model": processor.Model, + "NumCores": strconv.Itoa((int(processor.NumCores))), + "NumThreads": strconv.Itoa((int(processor.NumThreads))), + "Vendor": processor.Vendor, + } + } + // Handy label for single processor machines (e.g., ${CPU/Processor/Model} vs ${CPU/Processors/0/Model}) + cpu["Processor"] = cpuProcsMap["0"] + } + } + cpu["TotalCores"] = strconv.Itoa(int(systemData.CPU.TotalCores)) + cpu["TotalThreads"] = strconv.Itoa(int(systemData.CPU.TotalThreads)) + cpu["TotalProcessors"] = strconv.Itoa(len(systemData.CPU.Processors)) + + // GPU raw data could be huge, just pick few interesting values. + gpu := map[string]interface{}{} + if systemData.GPU != nil { + if len(systemData.GPU.GraphicsCards) > 0 { + cardNum := 0 + graphCrdsMap := map[string]interface{}{} + gpu["GraphicsCards"] = graphCrdsMap + for _, card := range systemData.GPU.GraphicsCards { + if card.DeviceInfo != nil { + cardNumMap := map[string]interface{}{ + "Driver": card.DeviceInfo.Driver, + } + graphCrdsMap[strconv.Itoa(cardNum)] = cardNumMap + + if card.DeviceInfo.Product != nil { + cardNumMap["ProductName"] = card.DeviceInfo.Product.Name + } + if card.DeviceInfo.Vendor != nil { + cardNumMap["VendorName"] = card.DeviceInfo.Vendor.Name + } + // Handy label for single GPU machines + // (e.g., ${GPU/Driver} vs ${GPU/GraphicsCards/0/Driver}) + if _, ok := graphCrdsMap["Driver"]; !ok { + for key, val := range cardNumMap { + graphCrdsMap[key] = val + } + } + cardNum++ + } + } + gpu["TotalCards"] = strconv.Itoa(cardNum) + } + } + + // NICs data have capabilities as array items, we don't want them, just pick few values. + network := map[string]interface{}{} + if systemData.Network != nil { + network["TotalNICs"] = strconv.Itoa(len(systemData.Network.NICs)) + + nicsMap := map[string]interface{}{} + network["NICs"] = nicsMap + for i, iface := range systemData.Network.NICs { + ifaceNum := strconv.Itoa(i) + nicsMap[ifaceNum] = map[string]interface{}{ + "AdvertisedLinkModes": strings.Join(iface.AdvertisedLinkModes, ","), + "Duplex": sanitizeHostInfoVal(iface.Duplex), + "IsVirtual": strconv.FormatBool(iface.IsVirtual), + "MacAddress": iface.MacAddress, + "Name": iface.Name, + "PCIAddress": iface.PCIAddress, + "Speed": sanitizeHostInfoVal(iface.Speed), + "SupportedLinkModes": strings.Join(iface.SupportedLinkModes, ","), + "SupportedPorts": strings.Join(iface.SupportedPorts, ","), + } + // handy reference by interface name + // (e.g., "${Network/NICs/eth0/MacAddress} vs "${Network/NICs/0/MacAddress}) + nicsMap[iface.Name] = nicsMap[ifaceNum] + } + } + + // Block data carry extended partitions information for each Disk we don't want. + // Manually pick the other items. + block := map[string]interface{}{} + if systemData.Block != nil { + block["TotalDisks"] = strconv.Itoa(len(systemData.Block.Disks)) // This includes removable devices like cdrom/usb + + disksMap := map[string]interface{}{} + block["Disks"] = disksMap + for i, disk := range systemData.Block.Disks { + blockNum := strconv.Itoa(i) + disksMap[blockNum] = map[string]interface{}{ + "Size": strconv.Itoa(int(disk.SizeBytes)), + "Name": disk.Name, + "Drive Type": disk.DriveType.String(), + "Storage Controller": disk.StorageController.String(), + "Removable": strconv.FormatBool(disk.IsRemovable), + "Model": disk.Model, + // "Partitions": reflectValToInterface(reflect.ValueOf(disk.Partitions)), + } + // handy reference by disk name + // (e.g., "${Block/Disks/1/Model} vs "${Block/Disks/sda/Model}"") + disksMap[disk.Name] = disksMap[blockNum] + } + } + + runtime := dataToLabelMap(systemData.Runtime) + + labels["Product"] = product + labels["BIOS"] = bios + labels["BaseBoard"] = baseboard + labels["Chassis"] = chassis + labels["Memory"] = memory + labels["CPU"] = cpu + labels["GPU"] = gpu + labels["Network"] = network + labels["Storage"] = block + labels["Runtime"] = runtime + + // Also available but not used: + // systemData.Topology -> CPU/memory and cache topology. No idea if useful. + + return labels } // Deprecated. Remove me together with 'MsgSystemData' type. diff --git a/pkg/register/register.go b/pkg/register/register.go index 30b08bf1..2b5c8da4 100644 --- a/pkg/register/register.go +++ b/pkg/register/register.go @@ -273,7 +273,11 @@ func sendSystemData(conn *websocket.Conn, protoVersion MessageType) error { if protoVersion >= MsgSystemDataV2 { log.Info("Sending System Data") - labels, err := hostinfo.ExtractLabels(data) + labels := hostinfo.ExtractLabels(data) + // Add legacy labels too (to be deprecated and removed sooner or later) + for k, v := range hostinfo.ExtractLabelsLegacy(data) { + labels[k] = v + } if err != nil { return fmt.Errorf("extracting labels from system data: %w", err) } diff --git a/pkg/server/api_registration_test.go b/pkg/server/api_registration_test.go index 632d7113..2b8257bd 100644 --- a/pkg/server/api_registration_test.go +++ b/pkg/server/api_registration_test.go @@ -77,7 +77,29 @@ var ( }, }, } - + hostinfoDataLabelsRegistrationFixture = &elementalv1.MachineRegistration{ + Spec: elementalv1.MachineRegistrationSpec{ + MachineInventoryLabels: map[string]string{ + "elemental.cattle.io/Hostname": "${Runtime/Hostname}", + "elemental.cattle.io/TotalMemory": "${Memory/TotalPhysicalBytes}", + "elemental.cattle.io/AvailableMemory": "${Memory/TotalUsableBytes}", + "elemental.cattle.io/CpuTotalCores": "${CPU/TotalCores}", + "elemental.cattle.io/CpuTotalThreads": "${CPU/TotalThreads}", + "elemental.cattle.io/NetIfacesNumber": "${Network/TotalNICs}", + "elemental.cattle.io/NetIface0-Name": "${Network/NICs/myNic1/Name}", + "elemental.cattle.io/NetIface0-MAC": "${Network/NICs/myNic1/MacAddress}", + "elemental.cattle.io/NetIface0-IsVirtual": "${Network/NICs/myNic1/IsVirtual}", + "elemental.cattle.io/NetIface1-Name": "${Network/NICs/myNic2/Name}", + "elemental.cattle.io/BlockDevicesNumber": "${Storage/TotalDisks}", + "elemental.cattle.io/BlockDevice0-Name": "${Storage/Disks/testdisk1/Name}", + "elemental.cattle.io/BlockDevice1-Name": "${Storage/Disks/testdisk2/Name}", + "elemental.cattle.io/BlockDevice0-Size": "${Storage/Disks/testdisk1/Size}", + "elemental.cattle.io/BlockDevice1-Size": "${Storage/Disks/testdisk2/Size}", + "elemental.cattle.io/BlockDevice0-Removable": "${Storage/Disks/testdisk1/Removable}", + "elemental.cattle.io/BlockDevice1-Removable": "${Storage/Disks/testdisk2/Removable}", + }, + }, + } hostInfoFixture = hostinfo.HostInfo{ Block: &block.Info{ Disks: []*block.Disk{ @@ -436,9 +458,7 @@ func TestUpdateInventoryFromSystemDataNG(t *testing.T) { inventory := &elementalv1.MachineInventory{} tmpl := templater.NewTemplater() - data, err := hostinfo.ExtractLabels(hostInfoFixture) - assert.NilError(t, err) - + data := hostinfo.ExtractLabelsLegacy(hostInfoFixture) encodedData, err := json.Marshal(data) assert.NilError(t, err) @@ -452,6 +472,24 @@ func TestUpdateInventoryFromSystemDataNG(t *testing.T) { assertSystemDataLabels(t, inventory) } +func TestUpdateInventoryFromHostinfoData(t *testing.T) { + inventory := &elementalv1.MachineInventory{} + tmpl := templater.NewTemplater() + + data := hostinfo.ExtractLabels(hostInfoFixture) + encodedData, err := json.Marshal(data) + assert.NilError(t, err) + + hostinfoData := map[string]interface{}{} + err = json.Unmarshal(encodedData, &hostinfoData) + assert.NilError(t, err) + fmt.Printf("%+v\n", hostinfoData) + tmpl.Fill(hostinfoData) + err = updateInventoryLabels(tmpl, inventory, hostinfoDataLabelsRegistrationFixture) + assert.NilError(t, err) + assertSystemDataLabels(t, inventory) +} + // Check that the labels we properly added to the inventory func assertSystemDataLabels(t *testing.T, inventory *elementalv1.MachineInventory) { t.Helper()