diff --git a/PROJECT b/PROJECT index e0e93cd16..c74ae3698 100644 --- a/PROJECT +++ b/PROJECT @@ -163,4 +163,13 @@ resources: kind: BMCSettingsSet path: github.com/ironcore-dev/metal-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: ironcore.dev + group: metal + kind: ServerMetadata + path: github.com/ironcore-dev/metal-operator/api/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/v1alpha1/constants.go b/api/v1alpha1/constants.go index a97431db1..a71181bf1 100644 --- a/api/v1alpha1/constants.go +++ b/api/v1alpha1/constants.go @@ -53,6 +53,34 @@ const ( OperationAnnotationRotateCredentials = "rotate-credentials" ) +// Well-known enrichment keys for ServerMetadata.Enrichment. +// External controllers SHOULD use these standardized keys for interoperability +// with visualizers and other tools that consume enrichment data. +// Keys follow a hierarchical naming convention: "domain.category/attribute" +const ( + // Datacenter location hierarchy + EnrichmentLocationSite = "datacenter.location/site" + EnrichmentLocationBuilding = "datacenter.location/building" + EnrichmentLocationRoom = "datacenter.location/room" + EnrichmentLocationRack = "datacenter.location/rack" + EnrichmentLocationPosition = "datacenter.location/position" + + // Network topology (upstream connectivity) + EnrichmentNetworkUpstreamSwitch = "network.topology/upstream-switch" + EnrichmentNetworkUpstreamPort = "network.topology/upstream-port" + + // Asset management + EnrichmentAssetTag = "asset.management/asset-tag" + EnrichmentAssetPurchaseDate = "asset.management/purchase-date" + EnrichmentAssetOwner = "asset.management/owner" + + // External system metadata (for linking back to source system) + EnrichmentExternalSystemID = "external.system/id" // Device ID in external system + EnrichmentExternalSystemName = "external.system/name" // System name (e.g., "netbox", "servicenow") + EnrichmentExternalSystemURL = "external.system/url" // Link to external system record + EnrichmentExternalSystemSyncAt = "external.system/sync-at" // Last sync timestamp (RFC3339) +) + const ( // GracefulShutdownServerPower indicates to gracefully restart the baremetal server power. GracefulRestartServerPower = "graceful-restart-server" diff --git a/api/v1alpha1/servermetadata_types.go b/api/v1alpha1/servermetadata_types.go new file mode 100644 index 000000000..a554412a0 --- /dev/null +++ b/api/v1alpha1/servermetadata_types.go @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster,shortName=smd +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// ServerMetadata is a flat data object (no spec/status) that persists the full +// probe agent discovery payload. Similar to how Endpoints or ConfigMap store +// data directly at the root level. The relationship to its Server is +// established by using the same name and an owner reference. +type ServerMetadata struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // SystemInfo contains BIOS, system, and board information from DMI/SMBIOS. + SystemInfo MetaDataSystemInfo `json:"systemInfo,omitempty"` + + // CPU is a list of CPUs discovered on the server. + CPU []MetaDataCPU `json:"cpu,omitempty"` + + // NetworkInterfaces is a list of network interfaces discovered on the server. + NetworkInterfaces []MetaDataNetworkInterface `json:"networkInterfaces,omitempty"` + + // LLDP contains LLDP neighbor information per interface. + LLDP []MetaDataLLDPInterface `json:"lldp,omitempty"` + + // Storage is a list of block devices discovered on the server. + Storage []MetaDataBlockDevice `json:"storage,omitempty"` + + // Memory is a list of memory devices discovered on the server. + Memory []MetaDataMemoryDevice `json:"memory,omitempty"` + + // NICs is a list of raw NIC details (PCI address, speed, firmware). + NICs []MetaDataNIC `json:"nics,omitempty"` + + // PCIDevices is a list of PCI devices discovered on the server. + PCIDevices []MetaDataPCIDevice `json:"pciDevices,omitempty"` + + // Enrichment stores additional metadata from external systems (CMDB, Netbox, ServiceNow, etc.). + // This is a cache populated by external controllers, NOT by hardware discovery. + // Keys follow a hierarchical naming convention: "system.category/attribute" + // + // Examples: + // "datacenter.location/site": "DC1" + // "datacenter.location/building": "Building-A" + // "datacenter.location/room": "Room-101" + // "datacenter.location/rack": "Rack-5" + // "network.topology/upstream-switch": "sw-tor-01" + // "network.topology/upstream-port": "Ethernet1/1" + // "asset.management/asset-tag": "12345" + // "asset.management/purchase-date": "2024-01-15" + // "external.system/id": "123" + // "external.system/name": "netbox" + // "external.system/url": "https://netbox.example.com/dcim/devices/123/" + // "external.system/sync-at": "2024-03-28T10:15:30Z" + // + // External controllers can use any keys, but standardized keys (see constants.go) + // are recommended for interoperability with visualizers and other tools. + // +optional + Enrichment map[string]string `json:"enrichment,omitempty"` +} + +// +kubebuilder:object:root=true + +// ServerMetadataList contains a list of ServerMetadata. +type ServerMetadataList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ServerMetadata `json:"items"` +} + +type MetaDataSystemInfo struct { + BIOSInformation MetaDataBIOSInformation `json:"biosInformation,omitempty"` + SystemInformation MetaDataServerInformation `json:"systemInformation,omitempty"` + BoardInformation MetaDataBoardInformation `json:"boardInformation,omitempty"` +} + +type MetaDataBIOSInformation struct { + Vendor string `json:"vendor,omitempty"` + Version string `json:"version,omitempty"` + Date string `json:"date,omitempty"` +} + +type MetaDataServerInformation struct { + Manufacturer string `json:"manufacturer,omitempty"` + ProductName string `json:"productName,omitempty"` + Version string `json:"version,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + UUID string `json:"uuid,omitempty"` + SKUNumber string `json:"skuNumber,omitempty"` + Family string `json:"family,omitempty"` +} + +type MetaDataBoardInformation struct { + Manufacturer string `json:"manufacturer,omitempty"` + Product string `json:"product,omitempty"` + Version string `json:"version,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + AssetTag string `json:"assetTag,omitempty"` +} + +type MetaDataCPU struct { + ID int `json:"id"` + TotalCores uint32 `json:"totalCores,omitempty"` + TotalHardwareThreads uint32 `json:"totalHardwareThreads,omitempty"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` +} + +type MetaDataNetworkInterface struct { + Name string `json:"name"` + IPAddresses []string `json:"ipAddresses,omitempty"` + MACAddress string `json:"macAddress"` + CarrierStatus string `json:"carrierStatus,omitempty"` +} + +type MetaDataLLDPInterface struct { + Name string `json:"name"` + Neighbors []MetaDataLLDPNeighbor `json:"neighbors,omitempty"` +} + +type MetaDataLLDPNeighbor struct { + ChassisID string `json:"chassisId,omitempty"` + PortID string `json:"portId,omitempty"` + PortDescription string `json:"portDescription,omitempty"` + SystemName string `json:"systemName,omitempty"` + SystemDescription string `json:"systemDescription,omitempty"` + MgmtIP string `json:"mgmtIp,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + VlanID string `json:"vlanId,omitempty"` +} + +type MetaDataBlockDevice struct { + Path string `json:"path,omitempty"` + Name string `json:"name,omitempty"` + Rotational bool `json:"rotational,omitempty"` + Removable bool `json:"removable,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + WWID string `json:"wwid,omitempty"` + PhysicalBlockSize uint64 `json:"physicalBlockSize,omitempty"` + LogicalBlockSize uint64 `json:"logicalBlockSize,omitempty"` + HWSectorSize uint64 `json:"hWSectorSize,omitempty"` + SizeBytes uint64 `json:"sizeBytes,omitempty"` + NUMANodeID int `json:"numaNodeID,omitempty"` +} + +type MetaDataMemoryDevice struct { + SizeBytes int64 `json:"size,omitempty"` + DeviceSet string `json:"deviceSet,omitempty"` + DeviceLocator string `json:"deviceLocator,omitempty"` + BankLocator string `json:"bankLocator,omitempty"` + MemoryType string `json:"memoryType,omitempty"` + Speed string `json:"speed,omitempty"` + Vendor string `json:"vendor,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + AssetTag string `json:"assetTag,omitempty"` + PartNumber string `json:"partNumber,omitempty"` + ConfiguredMemorySpeed string `json:"configuredMemorySpeed,omitempty"` + MinimumVoltage string `json:"minimumVoltage,omitempty"` + MaximumVoltage string `json:"maximumVoltage,omitempty"` + ConfiguredVoltage string `json:"configuredVoltage,omitempty"` +} + +type MetaDataNIC struct { + Name string `json:"name,omitempty"` + MAC string `json:"mac,omitempty"` + PCIAddress string `json:"pciAddress,omitempty"` + Speed string `json:"speed,omitempty"` + LinkModes []string `json:"linkModes,omitempty"` + SupportedPorts []string `json:"supportedPorts,omitempty"` + FirmwareVersion string `json:"firmwareVersion,omitempty"` +} + +type MetaDataPCIDevice struct { + Address string `json:"address,omitempty"` + Vendor string `json:"vendor,omitempty"` + VendorID string `json:"vendorID,omitempty"` + Product string `json:"product,omitempty"` + ProductID string `json:"productID,omitempty"` + NumaNodeID int `json:"numaNodeID,omitempty"` +} + +func init() { + SchemeBuilder.Register(&ServerMetadata{}, &ServerMetadataList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 00af3bf89..e4bc5863b 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1532,6 +1532,221 @@ func (in *LLDPNeighbor) DeepCopy() *LLDPNeighbor { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataBIOSInformation) DeepCopyInto(out *MetaDataBIOSInformation) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataBIOSInformation. +func (in *MetaDataBIOSInformation) DeepCopy() *MetaDataBIOSInformation { + if in == nil { + return nil + } + out := new(MetaDataBIOSInformation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataBlockDevice) DeepCopyInto(out *MetaDataBlockDevice) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataBlockDevice. +func (in *MetaDataBlockDevice) DeepCopy() *MetaDataBlockDevice { + if in == nil { + return nil + } + out := new(MetaDataBlockDevice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataBoardInformation) DeepCopyInto(out *MetaDataBoardInformation) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataBoardInformation. +func (in *MetaDataBoardInformation) DeepCopy() *MetaDataBoardInformation { + if in == nil { + return nil + } + out := new(MetaDataBoardInformation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataCPU) DeepCopyInto(out *MetaDataCPU) { + *out = *in + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataCPU. +func (in *MetaDataCPU) DeepCopy() *MetaDataCPU { + if in == nil { + return nil + } + out := new(MetaDataCPU) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataLLDPInterface) DeepCopyInto(out *MetaDataLLDPInterface) { + *out = *in + if in.Neighbors != nil { + in, out := &in.Neighbors, &out.Neighbors + *out = make([]MetaDataLLDPNeighbor, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataLLDPInterface. +func (in *MetaDataLLDPInterface) DeepCopy() *MetaDataLLDPInterface { + if in == nil { + return nil + } + out := new(MetaDataLLDPInterface) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataLLDPNeighbor) DeepCopyInto(out *MetaDataLLDPNeighbor) { + *out = *in + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataLLDPNeighbor. +func (in *MetaDataLLDPNeighbor) DeepCopy() *MetaDataLLDPNeighbor { + if in == nil { + return nil + } + out := new(MetaDataLLDPNeighbor) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataMemoryDevice) DeepCopyInto(out *MetaDataMemoryDevice) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataMemoryDevice. +func (in *MetaDataMemoryDevice) DeepCopy() *MetaDataMemoryDevice { + if in == nil { + return nil + } + out := new(MetaDataMemoryDevice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataNIC) DeepCopyInto(out *MetaDataNIC) { + *out = *in + if in.LinkModes != nil { + in, out := &in.LinkModes, &out.LinkModes + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SupportedPorts != nil { + in, out := &in.SupportedPorts, &out.SupportedPorts + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataNIC. +func (in *MetaDataNIC) DeepCopy() *MetaDataNIC { + if in == nil { + return nil + } + out := new(MetaDataNIC) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataNetworkInterface) DeepCopyInto(out *MetaDataNetworkInterface) { + *out = *in + if in.IPAddresses != nil { + in, out := &in.IPAddresses, &out.IPAddresses + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataNetworkInterface. +func (in *MetaDataNetworkInterface) DeepCopy() *MetaDataNetworkInterface { + if in == nil { + return nil + } + out := new(MetaDataNetworkInterface) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataPCIDevice) DeepCopyInto(out *MetaDataPCIDevice) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataPCIDevice. +func (in *MetaDataPCIDevice) DeepCopy() *MetaDataPCIDevice { + if in == nil { + return nil + } + out := new(MetaDataPCIDevice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataServerInformation) DeepCopyInto(out *MetaDataServerInformation) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataServerInformation. +func (in *MetaDataServerInformation) DeepCopy() *MetaDataServerInformation { + if in == nil { + return nil + } + out := new(MetaDataServerInformation) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MetaDataSystemInfo) DeepCopyInto(out *MetaDataSystemInfo) { + *out = *in + out.BIOSInformation = in.BIOSInformation + out.SystemInformation = in.SystemInformation + out.BoardInformation = in.BoardInformation +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetaDataSystemInfo. +func (in *MetaDataSystemInfo) DeepCopy() *MetaDataSystemInfo { + if in == nil { + return nil + } + out := new(MetaDataSystemInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespacedKeySelector) DeepCopyInto(out *NamespacedKeySelector) { *out = *in @@ -2023,6 +2238,114 @@ func (in *ServerMaintenanceStatus) DeepCopy() *ServerMaintenanceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerMetadata) DeepCopyInto(out *ServerMetadata) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.SystemInfo = in.SystemInfo + if in.CPU != nil { + in, out := &in.CPU, &out.CPU + *out = make([]MetaDataCPU, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.NetworkInterfaces != nil { + in, out := &in.NetworkInterfaces, &out.NetworkInterfaces + *out = make([]MetaDataNetworkInterface, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LLDP != nil { + in, out := &in.LLDP, &out.LLDP + *out = make([]MetaDataLLDPInterface, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Storage != nil { + in, out := &in.Storage, &out.Storage + *out = make([]MetaDataBlockDevice, len(*in)) + copy(*out, *in) + } + if in.Memory != nil { + in, out := &in.Memory, &out.Memory + *out = make([]MetaDataMemoryDevice, len(*in)) + copy(*out, *in) + } + if in.NICs != nil { + in, out := &in.NICs, &out.NICs + *out = make([]MetaDataNIC, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.PCIDevices != nil { + in, out := &in.PCIDevices, &out.PCIDevices + *out = make([]MetaDataPCIDevice, len(*in)) + copy(*out, *in) + } + if in.Enrichment != nil { + in, out := &in.Enrichment, &out.Enrichment + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerMetadata. +func (in *ServerMetadata) DeepCopy() *ServerMetadata { + if in == nil { + return nil + } + out := new(ServerMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServerMetadata) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerMetadataList) DeepCopyInto(out *ServerMetadataList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ServerMetadata, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerMetadataList. +func (in *ServerMetadataList) DeepCopy() *ServerMetadataList { + if in == nil { + return nil + } + out := new(ServerMetadataList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ServerMetadataList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServerSpec) DeepCopyInto(out *ServerSpec) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 3a8bf5b38..ea52ff0d2 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -13,10 +13,11 @@ import ( "time" "github.com/ironcore-dev/controller-utils/conditionutils" + "sigs.k8s.io/controller-runtime/pkg/manager" + "github.com/ironcore-dev/metal-operator/internal/cmd/dns" "github.com/ironcore-dev/metal-operator/internal/serverevents" webhookv1alpha1 "github.com/ironcore-dev/metal-operator/internal/webhook/v1alpha1" - "sigs.k8s.io/controller-runtime/pkg/manager" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -612,6 +613,13 @@ func main() { // nolint: gocyclo os.Exit(1) } } + if err := (&controller.ServerMetadataReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ServerMetadata") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/metal.ironcore.dev_servermetadata.yaml b/config/crd/bases/metal.ironcore.dev_servermetadata.yaml new file mode 100644 index 000000000..4b0bec9a6 --- /dev/null +++ b/config/crd/bases/metal.ironcore.dev_servermetadata.yaml @@ -0,0 +1,313 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: servermetadata.metal.ironcore.dev +spec: + group: metal.ironcore.dev + names: + kind: ServerMetadata + listKind: ServerMetadataList + plural: servermetadata + shortNames: + - smd + singular: servermetadata + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ServerMetadata is a flat data object (no spec/status) that persists the full + probe agent discovery payload. Similar to how Endpoints or ConfigMap store + data directly at the root level. The relationship to its Server is + established by using the same name and an owner reference. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + cpu: + description: CPU is a list of CPUs discovered on the server. + items: + properties: + capabilities: + items: + type: string + type: array + id: + type: integer + model: + type: string + totalCores: + format: int32 + type: integer + totalHardwareThreads: + format: int32 + type: integer + vendor: + type: string + required: + - id + type: object + type: array + enrichment: + additionalProperties: + type: string + description: |- + Enrichment stores additional metadata from external systems (CMDB, Netbox, ServiceNow, etc.). + This is a cache populated by external controllers, NOT by hardware discovery. + Keys follow a hierarchical naming convention: "system.category/attribute" + + Examples: + "datacenter.location/site": "DC1" + "datacenter.location/building": "Building-A" + "datacenter.location/room": "Room-101" + "datacenter.location/rack": "Rack-5" + "network.topology/upstream-switch": "sw-tor-01" + "network.topology/upstream-port": "Ethernet1/1" + "asset.management/asset-tag": "12345" + "asset.management/purchase-date": "2024-01-15" + "external.system/id": "123" + "external.system/name": "netbox" + "external.system/url": "https://netbox.example.com/dcim/devices/123/" + "external.system/sync-at": "2024-03-28T10:15:30Z" + + External controllers can use any keys, but standardized keys (see constants.go) + are recommended for interoperability with visualizers and other tools. + type: object + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + lldp: + description: LLDP contains LLDP neighbor information per interface. + items: + properties: + name: + type: string + neighbors: + items: + properties: + capabilities: + items: + type: string + type: array + chassisId: + type: string + mgmtIp: + type: string + portDescription: + type: string + portId: + type: string + systemDescription: + type: string + systemName: + type: string + vlanId: + type: string + type: object + type: array + required: + - name + type: object + type: array + memory: + description: Memory is a list of memory devices discovered on the server. + items: + properties: + assetTag: + type: string + bankLocator: + type: string + configuredMemorySpeed: + type: string + configuredVoltage: + type: string + deviceLocator: + type: string + deviceSet: + type: string + maximumVoltage: + type: string + memoryType: + type: string + minimumVoltage: + type: string + partNumber: + type: string + serialNumber: + type: string + size: + format: int64 + type: integer + speed: + type: string + vendor: + type: string + type: object + type: array + metadata: + type: object + networkInterfaces: + description: NetworkInterfaces is a list of network interfaces discovered + on the server. + items: + properties: + carrierStatus: + type: string + ipAddresses: + items: + type: string + type: array + macAddress: + type: string + name: + type: string + required: + - macAddress + - name + type: object + type: array + nics: + description: NICs is a list of raw NIC details (PCI address, speed, firmware). + items: + properties: + firmwareVersion: + type: string + linkModes: + items: + type: string + type: array + mac: + type: string + name: + type: string + pciAddress: + type: string + speed: + type: string + supportedPorts: + items: + type: string + type: array + type: object + type: array + pciDevices: + description: PCIDevices is a list of PCI devices discovered on the server. + items: + properties: + address: + type: string + numaNodeID: + type: integer + product: + type: string + productID: + type: string + vendor: + type: string + vendorID: + type: string + type: object + type: array + storage: + description: Storage is a list of block devices discovered on the server. + items: + properties: + hWSectorSize: + format: int64 + type: integer + logicalBlockSize: + format: int64 + type: integer + model: + type: string + name: + type: string + numaNodeID: + type: integer + path: + type: string + physicalBlockSize: + format: int64 + type: integer + readOnly: + type: boolean + removable: + type: boolean + rotational: + type: boolean + serial: + type: string + sizeBytes: + format: int64 + type: integer + vendor: + type: string + wwid: + type: string + type: object + type: array + systemInfo: + description: SystemInfo contains BIOS, system, and board information from + DMI/SMBIOS. + properties: + biosInformation: + properties: + date: + type: string + vendor: + type: string + version: + type: string + type: object + boardInformation: + properties: + assetTag: + type: string + manufacturer: + type: string + product: + type: string + serialNumber: + type: string + version: + type: string + type: object + systemInformation: + properties: + family: + type: string + manufacturer: + type: string + productName: + type: string + serialNumber: + type: string + skuNumber: + type: string + uuid: + type: string + version: + type: string + type: object + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index d6d14202e..a35ea5eb8 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -18,6 +18,7 @@ resources: - bases/metal.ironcore.dev_biossettingssets.yaml - bases/metal.ironcore.dev_bmcusers.yaml - bases/metal.ironcore.dev_bmcsettingssets.yaml +- bases/metal.ironcore.dev_servermetadata.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 03a6590cf..e827a8e26 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -70,3 +70,11 @@ resources: - servermaintenance_admin_role.yaml - servermaintenance_editor_role.yaml - servermaintenance_viewer_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the metal-operator itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. +- servermetadata_admin_role.yaml +- servermetadata_editor_role.yaml +- servermetadata_viewer_role.yaml + diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 7b33068c8..3b290304a 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -56,6 +56,7 @@ rules: - serverclaims - serverconfigurations - servermaintenances + - servermetadata - servers verbs: - create @@ -83,6 +84,7 @@ rules: - serverbootconfigurations/finalizers - serverclaims/finalizers - servermaintenances/finalizers + - servermetadata/finalizers - servers/finalizers verbs: - update @@ -104,6 +106,7 @@ rules: - serverbootconfigurations/status - serverclaims/status - servermaintenances/status + - servermetadata/status - servers/status verbs: - get diff --git a/config/rbac/servermetadata_admin_role.yaml b/config/rbac/servermetadata_admin_role.yaml new file mode 100644 index 000000000..967497f17 --- /dev/null +++ b/config/rbac/servermetadata_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project metal-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: metal-operator + app.kubernetes.io/managed-by: kustomize + name: servermetadata-admin-role +rules: +- apiGroups: + - metal.ironcore.dev + resources: + - servermetadata + verbs: + - '*' +- apiGroups: + - metal.ironcore.dev + resources: + - servermetadata/status + verbs: + - get diff --git a/config/rbac/servermetadata_editor_role.yaml b/config/rbac/servermetadata_editor_role.yaml new file mode 100644 index 000000000..33b4c3e2d --- /dev/null +++ b/config/rbac/servermetadata_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project metal-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: metal-operator + app.kubernetes.io/managed-by: kustomize + name: servermetadata-editor-role +rules: +- apiGroups: + - metal.ironcore.dev + resources: + - servermetadata + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - metal.ironcore.dev + resources: + - servermetadata/status + verbs: + - get diff --git a/config/rbac/servermetadata_viewer_role.yaml b/config/rbac/servermetadata_viewer_role.yaml new file mode 100644 index 000000000..25fba29c2 --- /dev/null +++ b/config/rbac/servermetadata_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project metal-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: metal-operator + app.kubernetes.io/managed-by: kustomize + name: servermetadata-viewer-role +rules: +- apiGroups: + - metal.ironcore.dev + resources: + - servermetadata + verbs: + - get + - list + - watch +- apiGroups: + - metal.ironcore.dev + resources: + - servermetadata/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index b760d9c0d..263158973 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -16,4 +16,5 @@ resources: - metal_v1alpha1_biossettingsset.yaml - metal_v1alpha1_bmcuser.yaml - metal_v1alpha1_bmcsettingsset.yaml +- metal_v1alpha1_servermetadata.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/metal_v1alpha1_servermetadata.yaml b/config/samples/metal_v1alpha1_servermetadata.yaml new file mode 100644 index 000000000..7df72841d --- /dev/null +++ b/config/samples/metal_v1alpha1_servermetadata.yaml @@ -0,0 +1,9 @@ +apiVersion: metal.ironcore.dev/v1alpha1 +kind: ServerMetadata +metadata: + labels: + app.kubernetes.io/name: metal-operator + app.kubernetes.io/managed-by: kustomize + name: servermetadata-sample +spec: + # TODO(user): Add fields here diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 55311df4a..571d82ad8 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -16,4 +16,5 @@ their relationships. Each concept is linked to its respective documentation for - [**BIOSVersionSet**](/concepts/biosversionset): Handles creation of multiple `BIOSVersion` by selecting physical server's through labels. - [**BMCSettings**](/concepts/bmcsettings): Handles updating the BMC setting on the physical server's Manager. - [**BMCVersion**](/concepts/bmcversion): Handles upgrading the BMC Version on the physical server's Manager. -- [**BMCVersionSet**](/concepts/bmcversionset): Handles creation of multiple `BMCVersion` by selecting BMC's through labels. \ No newline at end of file +- [**BMCVersionSet**](/concepts/bmcversionset): Handles creation of multiple `BMCVersion` by selecting BMC's through labels. +- [**Server Enrichment**](/concepts/server-enrichment): Allows external systems to cache additional metadata (location, asset, network topology) on `ServerMetadata` resources. \ No newline at end of file diff --git a/docs/concepts/server-enrichment.md b/docs/concepts/server-enrichment.md new file mode 100644 index 000000000..e54882295 --- /dev/null +++ b/docs/concepts/server-enrichment.md @@ -0,0 +1,334 @@ +# Server Enrichment + +Server enrichment allows external systems to cache additional metadata on `ServerMetadata` resources. This enables +operators of tools like [Netbox](https://netbox.dev/), ServiceNow, or custom CMDBs to augment bare metal servers +with location, asset management, and network topology information — without modifying the metal-operator itself. + +## Architecture + +The metal-operator provides the structure; user-supplied controllers provide the data. + +``` +┌──────────────────────┐ ┌───────────────────────────┐ +│ External Systems │ │ metal-operator │ +│ │ │ │ +│ ┌────────────────┐ │ │ ┌──────────────────────┐ │ +│ │ Netbox │ │ │ │ Server │ │ +│ └────────┬───────┘ │ │ └──────────┬───────────┘ │ +│ │ │ │ │ same name │ +│ ┌────────┴───────┐ │ │ ┌──────────▼───────────┐ │ +│ │ ServiceNow │ │ │ │ ServerMetadata │ │ +│ └────────┬───────┘ │ │ │ │ │ +│ │ │ │ │ systemInfo: ... │ │ +│ ┌────────┴───────┐ │ │ │ cpu: [...] │ │ +│ │ Custom CMDB │ │ │ │ memory: [...] │ │ +│ └────────┬───────┘ │ │ │ enrichment: │ │ +│ │ │ │ │ "location/site": │ │ +└───────────┼──────────┘ │ │ "DC1" │ │ + │ │ └──────────┬───────────┘ │ + ┌────────▼───────────┐ │ │ │ + │ Your Enrichment │ │ ┌──────────▼───────────┐ │ + │ Controller ├────┤► │ Visualizer │ │ + │ (watches Server, │ │ │ (reads enrichment, │ │ + │ patches metadata) │ │ │ filters by site, │ │ + └────────────────────┘ │ │ shows hardware) │ │ + │ └──────────────────────┘ │ + └───────────────────────────┘ +``` + +Key points: + +- **metal-operator** discovers hardware and populates `ServerMetadata` fields like `systemInfo`, `cpu`, `memory`, etc. +- **Enrichment controllers** (user-provided) watch `Server` resources and write to `ServerMetadata.Enrichment`. +- **Visualizer** reads both hardware and enrichment data to display a rich server topology. +- The `Server` and its `ServerMetadata` share the same name and are linked by an owner reference. + +## Enrichment Field + +The `Enrichment` field on `ServerMetadata` is a `map[string]string`: + +```yaml +apiVersion: metal.ironcore.dev/v1alpha1 +kind: ServerMetadata +metadata: + name: my-server # matches Server name +enrichment: + datacenter.location/site: "DC1" + datacenter.location/building: "Building-A" + datacenter.location/room: "Room-101" + datacenter.location/rack: "Rack-5" + datacenter.location/position: "U12" + network.topology/upstream-switch: "sw-tor-01" + network.topology/upstream-port: "Ethernet1/1" + asset.management/asset-tag: "ASSET-12345" + external.system/name: "netbox" + external.system/url: "https://netbox.example.com/dcim/devices/42/" + external.system/sync-at: "2025-03-28T10:15:30Z" +``` + +Enrichment is optional. Servers without enrichment data continue to work normally — the visualizer gracefully +degrades to showing only hardware discovery data. + +## Well-Known Enrichment Keys + +Keys follow a hierarchical naming convention: `domain.category/attribute`. The metal-operator defines standard +keys in `api/v1alpha1/constants.go` for interoperability with the visualizer and other tools. + +### Datacenter Location + +| Constant | Key | Example | Visualizer | +|---|---|---|---| +| `EnrichmentLocationSite` | `datacenter.location/site` | `"DC1"` | Site filter dropdown | +| `EnrichmentLocationBuilding` | `datacenter.location/building` | `"Building-A"` | Building filter dropdown | +| `EnrichmentLocationRoom` | `datacenter.location/room` | `"Room-101"` | Room filter dropdown | +| `EnrichmentLocationRack` | `datacenter.location/rack` | `"Rack-5"` | Details panel | +| `EnrichmentLocationPosition` | `datacenter.location/position` | `"U12"` | Details panel | + +### Network Topology + +| Constant | Key | Example | Visualizer | +|---|---|---|---| +| `EnrichmentNetworkUpstreamSwitch` | `network.topology/upstream-switch` | `"sw-tor-01"` | Tooltip, details panel | +| `EnrichmentNetworkUpstreamPort` | `network.topology/upstream-port` | `"Ethernet1/1"` | Tooltip, details panel | + +### Asset Management + +| Constant | Key | Example | Visualizer | +|---|---|---|---| +| `EnrichmentAssetTag` | `asset.management/asset-tag` | `"ASSET-12345"` | Tooltip, details panel | +| `EnrichmentAssetPurchaseDate` | `asset.management/purchase-date` | `"2024-01-15"` | Details panel | +| `EnrichmentAssetOwner` | `asset.management/owner` | `"team-infra"` | Details panel | + +### External System Metadata + +| Constant | Key | Example | Visualizer | +|---|---|---|---| +| `EnrichmentExternalSystemID` | `external.system/id` | `"42"` | Details panel | +| `EnrichmentExternalSystemName` | `external.system/name` | `"netbox"` | Details panel | +| `EnrichmentExternalSystemURL` | `external.system/url` | `"https://netbox.example.com/..."` | Details panel (link) | +| `EnrichmentExternalSystemSyncAt` | `external.system/sync-at` | `"2025-03-28T10:15:30Z"` | Details panel | + +## Creating an Enrichment Controller + +An enrichment controller watches `Server` resources, fetches metadata from an external system, and patches the +corresponding `ServerMetadata.Enrichment` field. Below is a minimal example: + +```go +package controller + +import ( + "context" + "fmt" + "time" + + metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type NetboxEnrichmentReconciler struct { + client.Client + NetboxURL string + NetboxToken string +} + +// +kubebuilder:rbac:groups=metal.ironcore.dev,resources=servers,verbs=get;list;watch +// +kubebuilder:rbac:groups=metal.ironcore.dev,resources=servermetadata,verbs=get;list;watch;update;patch + +func (r *NetboxEnrichmentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + + // Fetch the Server + server := &metalv1alpha1.Server{} + if err := r.Get(ctx, req.NamespacedName, server); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Only enrich servers that have completed discovery + if server.Status.State != metalv1alpha1.ServerStateAvailable && + server.Status.State != metalv1alpha1.ServerStateReserved { + return ctrl.Result{}, nil + } + + // Fetch corresponding ServerMetadata + metadata := &metalv1alpha1.ServerMetadata{} + if err := r.Get(ctx, types.NamespacedName{Name: server.Name}, metadata); err != nil { + if apierrors.IsNotFound(err) { + // ServerMetadata not yet created — requeue + return ctrl.Result{RequeueAfter: 30 * time.Second}, nil + } + return ctrl.Result{}, fmt.Errorf("failed to get ServerMetadata: %w", err) + } + + // Look up the server in Netbox using the serial number + serial := metadata.SystemInfo.SystemInformation.SerialNumber + netboxDevice, err := lookupDeviceBySerial(ctx, r.NetboxURL, r.NetboxToken, serial) + if err != nil { + log.Error(err, "Failed to look up device in Netbox", "serial", serial) + return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil + } + + // Build the enrichment map + enrichment := map[string]string{ + metalv1alpha1.EnrichmentLocationSite: netboxDevice.Site, + metalv1alpha1.EnrichmentLocationBuilding: netboxDevice.Building, + metalv1alpha1.EnrichmentLocationRoom: netboxDevice.Room, + metalv1alpha1.EnrichmentLocationRack: netboxDevice.Rack, + metalv1alpha1.EnrichmentLocationPosition: netboxDevice.Position, + metalv1alpha1.EnrichmentAssetTag: netboxDevice.AssetTag, + metalv1alpha1.EnrichmentExternalSystemID: fmt.Sprintf("%d", netboxDevice.ID), + metalv1alpha1.EnrichmentExternalSystemName: "netbox", + metalv1alpha1.EnrichmentExternalSystemURL: fmt.Sprintf("%s/dcim/devices/%d/", r.NetboxURL, netboxDevice.ID), + metalv1alpha1.EnrichmentExternalSystemSyncAt: time.Now().UTC().Format(time.RFC3339), + } + + // Patch the enrichment field + patch := client.MergeFrom(metadata.DeepCopy()) + if metadata.Enrichment == nil { + metadata.Enrichment = make(map[string]string) + } + for k, v := range enrichment { + if v != "" { + metadata.Enrichment[k] = v + } + } + + if err := r.Patch(ctx, metadata, patch); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to patch ServerMetadata: %w", err) + } + + log.Info("Enriched server metadata from Netbox", "server", server.Name) + return ctrl.Result{RequeueAfter: 1 * time.Hour}, nil +} + +func (r *NetboxEnrichmentReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&metalv1alpha1.Server{}). + Complete(r) +} +``` + +Replace `lookupDeviceBySerial` with your actual Netbox (or other CMDB) client logic. The pattern is the same +for any external data source. + +## RBAC Requirements + +An enrichment controller needs read access to `Server` resources and read/write access to `ServerMetadata`: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: enrichment-controller +rules: + - apiGroups: ["metal.ironcore.dev"] + resources: ["servers"] + verbs: ["get", "list", "watch"] + - apiGroups: ["metal.ironcore.dev"] + resources: ["servermetadata"] + verbs: ["get", "list", "watch", "update", "patch"] +``` + +`ServerMetadata` is a cluster-scoped resource, so use a `ClusterRole` and `ClusterRoleBinding`. + +## Visualizer Integration + +The [metalctl visualizer](../usage/metalctl.md) automatically reads enrichment data from `ServerMetadata` resources. +No configuration is needed — if enrichment data exists, the visualizer displays it. + +**Location filters**: When servers have `datacenter.location/*` enrichment keys, the visualizer shows Site, Building, +and Room dropdown filters in the top-right panel. Selecting a location filters the 3D view to show only servers in +that location. Servers without location data are always visible. + +**Hardware details panel**: Click any server in the 3D view to open a details panel. When `ServerMetadata` exists, the +panel shows system information (manufacturer, model, serial), BIOS details, CPU/memory/storage summaries, network +interface counts, and all enrichment key-value pairs. + +**Enhanced tooltips**: Hover over a server to see a compact summary. When enrichment data is available, tooltips include +hardware specs, the location breadcrumb (e.g., `DC1 > Building-A > Room-101`), upstream switch/port information, and +the asset tag. + +## Testing Without a Controller + +You can manually patch enrichment data to test the visualizer integration: + +```bash +kubectl patch servermetadata my-server --type=merge -p '{ + "enrichment": { + "datacenter.location/site": "DC1", + "datacenter.location/building": "Building-A", + "datacenter.location/room": "Room-101", + "datacenter.location/rack": "Rack-5", + "datacenter.location/position": "U12", + "network.topology/upstream-switch": "sw-tor-01", + "network.topology/upstream-port": "Ethernet1/1", + "asset.management/asset-tag": "ASSET-12345", + "external.system/name": "netbox", + "external.system/url": "https://netbox.example.com/dcim/devices/42/" + } +}' +``` + +Then open the visualizer (`metalctl viz --port 8080`) and verify the location filters, details panel, and tooltips +reflect the enrichment data. + +To remove enrichment: + +```bash +kubectl patch servermetadata my-server --type=merge -p '{"enrichment": null}' +``` + +## Best Practices + +1. **Use well-known keys** for data that the visualizer or other standard tools consume. Custom keys are supported + but won't be recognized by built-in UI features like location filtering. + +2. **Namespace custom keys** using a domain you own: `mycompany.example.com/custom-field`. This avoids collisions + with future well-known keys. + +3. **Handle errors gracefully**. If the external system is unreachable, requeue with a backoff rather than failing + the reconciliation loop. Set `external.system/sync-at` so operators can detect stale data. + +4. **Respect rate limits** of external APIs. Use `RequeueAfter` with reasonable intervals (e.g., 1 hour) rather + than reconciling on every event. + +5. **Skip empty values**. Only write enrichment keys that have meaningful data. The visualizer ignores empty strings. + +6. **Use `MergeFrom` patches** to update only the enrichment field without overwriting hardware metadata populated + by the metal-operator. + +## FAQ + +**Can multiple controllers enrich the same `ServerMetadata`?** + +Yes. Each controller should use `MergeFrom` patches so that updates to different keys don't overwrite each other. +Use distinct key prefixes per controller (e.g., one controller writes `datacenter.location/*` keys, another writes +`asset.management/*` keys). + +**What happens if `ServerMetadata` doesn't exist yet?** + +`ServerMetadata` is created by the metal-operator during server discovery. If your enrichment controller runs +before discovery completes, it should handle `NotFound` errors and requeue. + +**Which servers should I enrich?** + +Typically only servers in the `Available` or `Reserved` state. Servers in `Initial` or `Discovery` may not have a +`ServerMetadata` resource yet. + +**How do I remove enrichment data?** + +Patch the `enrichment` field to `null` or remove individual keys. The visualizer gracefully degrades when enrichment +data is absent. + +**Is there a size limit on enrichment data?** + +The enrichment map is stored as part of the `ServerMetadata` resource. Kubernetes has a default 1.5 MB size limit +for etcd objects. In practice, keep enrichment data concise — a few dozen key-value pairs per server is typical. + +**Is the Netbox integration built into metal-operator?** + +No. The metal-operator provides the `Enrichment` field and well-known key constants. Integrations with Netbox, +ServiceNow, or any other system are implemented as separate controllers that you deploy alongside the metal-operator. diff --git a/docs/usage/metalctl.md b/docs/usage/metalctl.md index b1ebf9e16..a697565a3 100644 --- a/docs/usage/metalctl.md +++ b/docs/usage/metalctl.md @@ -12,7 +12,7 @@ go install https://github.com/ironcore-dev/metal-operator/cmd/metalctl@latest ### visualizer, vis -The `metalctl visualalizer` (or `metalctl vis`) command allows you to visualize the topology of your bare metal `Server`s. +The `metalctl visualizer` (or `metalctl viz`) command allows you to visualize the topology of your bare metal `Server`s. To run the visualizer run @@ -24,6 +24,60 @@ In order to access the 3D visualization, open your browser and navigate to `http You can configure the port by setting the `--port` flag. +#### Visualizer with Enrichment Data + +When `ServerMetadata` resources contain enrichment data (populated by external controllers), the visualizer +automatically displays additional information. Enrichment is optional — servers without it are displayed +normally with only their base topology data. + +**Location drill-down**: Use the Site, Building, and Room dropdowns in the top-right filter panel to +narrow the 3D view to a specific datacenter location. For example, select `DC1 > Building-A > Room-101` +to see only the servers in that room. Servers without location enrichment are always visible regardless +of the selected filters. + +**Hardware details panel**: Click any server in the 3D view to open a side panel with detailed information: +- System info: manufacturer, model, serial number, UUID +- BIOS: vendor and version +- CPU: model, total sockets, cores, and threads +- Memory: total capacity and module count +- Storage: total capacity and device count +- Network: interface count, upstream switch and port (from enrichment) +- Location: full hierarchy path (site, building, room, rack, position) +- Asset info: asset tag, owner, purchase date +- External system: link back to the source system (e.g., Netbox device page) + +**Enhanced tooltips**: Hover over any server to see a compact summary including hardware specs +(CPU model, memory, storage), the location breadcrumb (e.g., `DC1 > Building-A > Room-101`), +upstream network connectivity, and asset tag. + +**Custom enrichment keys**: Any additional keys written to `ServerMetadata.Enrichment` beyond the +well-known keys are displayed in the details panel under a raw key-value listing. This allows +custom metadata to be visible without any visualizer changes. + +Enrichment data is read from the `ServerMetadata.Enrichment` field using well-known keys defined in +`api/v1alpha1/constants.go`. See the [Server Enrichment](../concepts/server-enrichment.md) documentation +for details on populating enrichment data and building enrichment controllers. + +**Example**: To test with manual enrichment data: + +```bash +kubectl patch servermetadata my-server --type=merge -p '{ + "enrichment": { + "datacenter.location/site": "DC1", + "datacenter.location/building": "Building-A", + "datacenter.location/room": "Room-101", + "datacenter.location/rack": "Rack-5", + "datacenter.location/position": "U12", + "network.topology/upstream-switch": "sw-tor-01", + "network.topology/upstream-port": "Ethernet1/1", + "asset.management/asset-tag": "ASSET-12345" + } +}' +``` + +After patching, refresh the visualizer at `http://localhost:8080`. The patched server should now +show location filters, enriched tooltips, and a full hardware details panel when clicked. + ### console The `metalctl console` command allows you to access the serial console of a `Server`. diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index 57e390cef..104f70642 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -11,4 +11,48 @@ type ServerInfo struct { Power string `json:"power"` IndicatorLED string `json:"indicatorLED"` State string `json:"state"` + + // Enrichment data from ServerMetadata (optional, for graceful degradation). + Enrichment map[string]string `json:"enrichment,omitempty"` + + // Location info derived from well-known enrichment keys. + Location *LocationInfo `json:"location,omitempty"` + + // Hardware metadata from ServerMetadata. + Hardware *HardwareInfo `json:"hardware,omitempty"` +} + +// LocationInfo represents parsed location hierarchy from enrichment data. +type LocationInfo struct { + Site string `json:"site,omitempty"` + Building string `json:"building,omitempty"` + Room string `json:"room,omitempty"` + RackName string `json:"rackName,omitempty"` + Position string `json:"position,omitempty"` + HierarchyPath []string `json:"hierarchyPath,omitempty"` +} + +// HardwareInfo represents aggregated hardware metadata from ServerMetadata. +type HardwareInfo struct { + Manufacturer string `json:"manufacturer,omitempty"` + Model string `json:"model,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + UUID string `json:"uuid,omitempty"` + + BIOSVersion string `json:"biosVersion,omitempty"` + BIOSVendor string `json:"biosVendor,omitempty"` + + TotalCPUs int `json:"totalCpus,omitempty"` + CPUModel string `json:"cpuModel,omitempty"` + TotalCores uint32 `json:"totalCores,omitempty"` + TotalThreads uint32 `json:"totalThreads,omitempty"` + + TotalMemoryGB int `json:"totalMemoryGb,omitempty"` + MemoryModules int `json:"memoryModules,omitempty"` + + TotalStorageGB int `json:"totalStorageGb,omitempty"` + StorageDevices int `json:"storageDevices,omitempty"` + + NetworkInterfaces int `json:"networkInterfaces,omitempty"` + PCIDeviceCount int `json:"pciDeviceCount,omitempty"` } diff --git a/internal/cmd/visualizer/index.html b/internal/cmd/visualizer/index.html index d25194b23..e602df474 100644 --- a/internal/cmd/visualizer/index.html +++ b/internal/cmd/visualizer/index.html @@ -32,6 +32,39 @@
+ + +
+

Location Filter

+
+ + +
+
+ + +
+
+ + +
+ +
+ + +