diff --git a/charts/kvm-node-agent/Chart.yaml b/charts/kvm-node-agent/Chart.yaml index 838c05a..399036e 100644 --- a/charts/kvm-node-agent/Chart.yaml +++ b/charts/kvm-node-agent/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: kvm-node-agent description: A Helm chart for Kubernetes appVersion: 0.1.0 -version: 0.1.7 +version: 0.1.6 type: application diff --git a/charts/kvm-node-agent/crds/hypervisor-crd.yaml b/charts/kvm-node-agent/crds/hypervisor-crd.yaml index 311ba15..dad29e5 100644 --- a/charts/kvm-node-agent/crds/hypervisor-crd.yaml +++ b/charts/kvm-node-agent/crds/hypervisor-crd.yaml @@ -110,6 +110,15 @@ spec: items: type: string type: array + allowedProjects: + default: [] + description: |- + AllowedProjects defines which openstack projects are allowed to schedule + instances on this hypervisor. The values of this list should be project + uuids. If left empty, all projects are allowed. + items: + type: string + type: array createCertManagerCertificate: default: false description: |- @@ -167,6 +176,7 @@ spec: type: string required: - aggregates + - allowedProjects - createCertManagerCertificate - customTraits - evacuateOnReboot @@ -184,8 +194,17 @@ spec: items: type: string type: array + allocation: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered resource allocation of all hosted VMs. + type: object capabilities: - description: The capabilities of the hypervisors as reported by libvirt. + description: Auto-discovered capabilities as reported by libvirt. properties: cpuArch: default: unknown @@ -208,6 +227,47 @@ spec: pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true type: object + capacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered capacity of the hypervisor. + type: object + cells: + description: Auto-discovered cells on this hypervisor. + items: + description: Cell represents a NUMA cell on the hypervisor. + properties: + allocation: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered resource allocation of all hosted + VMs in this cell. + type: object + capacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered capacity of this cell. + type: object + cellID: + description: Cell ID. + format: int64 + type: integer + required: + - cellID + type: object + type: array conditions: description: Represents the Hypervisor node conditions. items: @@ -265,6 +325,72 @@ spec: - type type: object type: array + domainCapabilities: + description: |- + Auto-discovered domain capabilities relevant to check if a VM + can be scheduled on the hypervisor. + properties: + arch: + default: unknown + description: The available domain cpu architecture. + type: string + hypervisorType: + default: unknown + description: The supported type of virtualization for domains, + such as "ch". + type: string + supportedCpuModes: + default: [] + description: |- + Supported cpu modes for domains. + + The format of this list is cpu mode, and if specified, a specific + submode. For example, the take the following xml domain cpu definition: + + + + + + The corresponding entries in this list would be "host-passthrough" and + "host-passthrough/migratable". + items: + type: string + type: array + supportedDevices: + default: [] + description: |- + Supported devices for domains. + + The format of this list is the device type, and if specified, a specific + model. For example, the take the following xml domain device definition: + + + + The corresponding entries in this list would be "video" and "video/nvidia". + items: + type: string + type: array + supportedFeatures: + default: [] + description: |- + Supported features for domains, such as "sev" or "sgx". + + This is a flat list of supported features, meaning the following xml: + + + + + + + Would correspond to the entries "sev" and "sgx" in this list. + items: + type: string + type: array + type: object evicted: description: Evicted indicates whether the hypervisor is evicted. (no instances left with active maintenance mode) diff --git a/config/crd/bases/kvm.cloud.sap_hypervisors.yaml b/config/crd/bases/kvm.cloud.sap_hypervisors.yaml index ac050ff..dad29e5 100644 --- a/config/crd/bases/kvm.cloud.sap_hypervisors.yaml +++ b/config/crd/bases/kvm.cloud.sap_hypervisors.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.1 + controller-gen.kubebuilder.io/version: v0.19.0 name: hypervisors.kvm.cloud.sap spec: group: kvm.cloud.sap @@ -35,6 +35,9 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].reason name: State type: string + - jsonPath: .status.conditions[?(@.type=="Tainted")].message + name: Taint + type: string - jsonPath: .spec.lifecycleEnabled name: Lifecycle type: boolean @@ -107,6 +110,15 @@ spec: items: type: string type: array + allowedProjects: + default: [] + description: |- + AllowedProjects defines which openstack projects are allowed to schedule + instances on this hypervisor. The values of this list should be project + uuids. If left empty, all projects are allowed. + items: + type: string + type: array createCertManagerCertificate: default: false description: |- @@ -139,6 +151,16 @@ spec: description: LifecycleEnabled enables the lifecycle management of the hypervisor via hypervisor-operator. type: boolean + maintenance: + description: Maintenance indicates whether the hypervisor is in maintenance + mode. + enum: + - "" + - manual + - auto + - ha + - termination + type: string reboot: default: false description: Reboot request an reboot after successful installation @@ -154,6 +176,7 @@ spec: type: string required: - aggregates + - allowedProjects - createCertManagerCertificate - customTraits - evacuateOnReboot @@ -171,8 +194,17 @@ spec: items: type: string type: array + allocation: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered resource allocation of all hosted VMs. + type: object capabilities: - description: The capabilities of the hypervisors as reported by libvirt. + description: Auto-discovered capabilities as reported by libvirt. properties: cpuArch: default: unknown @@ -195,6 +227,47 @@ spec: pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ x-kubernetes-int-or-string: true type: object + capacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered capacity of the hypervisor. + type: object + cells: + description: Auto-discovered cells on this hypervisor. + items: + description: Cell represents a NUMA cell on the hypervisor. + properties: + allocation: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered resource allocation of all hosted + VMs in this cell. + type: object + capacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: Auto-discovered capacity of this cell. + type: object + cellID: + description: Cell ID. + format: int64 + type: integer + required: + - cellID + type: object + type: array conditions: description: Represents the Hypervisor node conditions. items: @@ -252,6 +325,76 @@ spec: - type type: object type: array + domainCapabilities: + description: |- + Auto-discovered domain capabilities relevant to check if a VM + can be scheduled on the hypervisor. + properties: + arch: + default: unknown + description: The available domain cpu architecture. + type: string + hypervisorType: + default: unknown + description: The supported type of virtualization for domains, + such as "ch". + type: string + supportedCpuModes: + default: [] + description: |- + Supported cpu modes for domains. + + The format of this list is cpu mode, and if specified, a specific + submode. For example, the take the following xml domain cpu definition: + + + + + + The corresponding entries in this list would be "host-passthrough" and + "host-passthrough/migratable". + items: + type: string + type: array + supportedDevices: + default: [] + description: |- + Supported devices for domains. + + The format of this list is the device type, and if specified, a specific + model. For example, the take the following xml domain device definition: + + + + The corresponding entries in this list would be "video" and "video/nvidia". + items: + type: string + type: array + supportedFeatures: + default: [] + description: |- + Supported features for domains, such as "sev" or "sgx". + + This is a flat list of supported features, meaning the following xml: + + + + + + + Would correspond to the entries "sev" and "sgx" in this list. + items: + type: string + type: array + type: object + evicted: + description: Evicted indicates whether the hypervisor is evicted. + (no instances left with active maintenance mode) + type: boolean hypervisorId: description: HypervisorID is the unique identifier of the hypervisor in OpenStack. @@ -299,6 +442,14 @@ spec: firmwareVersion: description: FirmwareVersion type: string + gardenLinuxCommitID: + description: Represents the Garden Linux build commit id + type: string + gardenLinuxFeatures: + description: Represents the Garden Linux Feature Set + items: + type: string + type: array hardwareModel: description: HardwareModel type: string @@ -320,20 +471,13 @@ spec: prettyVersion: description: PrettyVersion type: string + variantID: + description: Identifying a specific variant or edition of the + operating system + type: string version: description: Represents the Operating System version. type: string - gardenLinuxCommitID: - description: GardenLinuxCommitID - type: string - gardenLinuxFeatures: - description: GardenLinuxFeatures - items: - type: string - type: array - variantID: - description: VariantID - type: string type: object serviceId: description: ServiceID is the unique identifier of the compute service diff --git a/go.mod b/go.mod index 913b064..a84e21b 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ go 1.25.0 require ( github.com/cert-manager/cert-manager v1.19.2 - github.com/cobaltcore-dev/openstack-hypervisor-operator v0.0.0-20251219152336-768f63171244 + github.com/cobaltcore-dev/openstack-hypervisor-operator v0.0.0-20260107080351-e998a1af4394 github.com/coreos/go-systemd/v22 v22.6.0 github.com/digitalocean/go-libvirt v0.0.0-20260105165635-a0e369cfdc9f github.com/godbus/dbus/v5 v5.2.2 diff --git a/go.sum b/go.sum index 5ff9f31..b9a8634 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/cert-manager/cert-manager v1.19.2 h1:jSprN1h5pgNDSl7HClAmIzXuTxic/5FX github.com/cert-manager/cert-manager v1.19.2/go.mod h1:e9NzLtOKxTw7y99qLyWGmPo6mrC1Nh0EKKcMkRfK+GE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cobaltcore-dev/openstack-hypervisor-operator v0.0.0-20251219152336-768f63171244 h1:HedVhcR2smWlJqthYHYT5kL3Hhqjvg3lETz3pWiDprc= -github.com/cobaltcore-dev/openstack-hypervisor-operator v0.0.0-20251219152336-768f63171244/go.mod h1:i/YQm59sAvilkgTFpKc+elMIf/KzkdimnXMd13P3V9s= +github.com/cobaltcore-dev/openstack-hypervisor-operator v0.0.0-20260107080351-e998a1af4394 h1:GWyHLNTwXp4m+D7OGuQwtIWGRPUBCEjVSowK4CgrdQU= +github.com/cobaltcore-dev/openstack-hypervisor-operator v0.0.0-20260107080351-e998a1af4394/go.mod h1:i/YQm59sAvilkgTFpKc+elMIf/KzkdimnXMd13P3V9s= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= diff --git a/internal/controller/hypervisor_controller.go b/internal/controller/hypervisor_controller.go index 9e0bf5c..5c21fdb 100644 --- a/internal/controller/hypervisor_controller.go +++ b/internal/controller/hypervisor_controller.go @@ -51,9 +51,8 @@ type HypervisorReconciler struct { } const ( - OSUpdateType = "OperatingSystemUpdate" - LibVirtType = "LibVirtConnection" - CapabilitiesClientType = "CapabilitiesClientConnection" + OSUpdateType = "OperatingSystemUpdate" + LibVirtType = "LibVirtConnection" ) // +kubebuilder:rbac:groups=kvm.cloud.sap,resources=hypervisors,verbs=get;list;watch;update;patch;delete @@ -182,35 +181,24 @@ func (r *HypervisorReconciler) Reconcile(ctx context.Context, req ctrl.Request) Reason: "ConnectFailed", }) } else { - hypervisor.Status.LibVirtVersion = r.Libvirt.GetVersion() + // We're connected. meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{ Type: LibVirtType, Status: metav1.ConditionTrue, Reason: "Connected", }) - // Update hypervisor instances - hypervisor.Status.NumInstances = r.Libvirt.GetNumInstances() - if hypervisor.Status.Instances, err = r.Libvirt.GetInstances(); err != nil { - log.Error(err, "failed to get instances") - } - - // Update capabilities status. - if capabilities, err := r.Libvirt.GetCapabilities(); err == nil { - hypervisor.Status.Capabilities = capabilities - meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{ - Type: CapabilitiesClientType, - Status: metav1.ConditionTrue, - Reason: "CapabilitiesClientGetSucceeded", - }) - } else { - log.Error(err, "failed to get capabilities") + var err error + hypervisor, err = r.Libvirt.Process(hypervisor) + if err != nil { + log.Error(err, "unable to process hypervisor via libvirt") meta.SetStatusCondition(&hypervisor.Status.Conditions, metav1.Condition{ - Type: CapabilitiesClientType, + Type: LibVirtType, Status: metav1.ConditionFalse, - Message: err.Error(), - Reason: "CapabilitiesClientGetFailed", + Message: fmt.Sprintf("unable to process hypervisor via libvirt: %v", err), + Reason: "ProcessFailed", }) + return ctrl.Result{}, err } } diff --git a/internal/controller/hypervisor_controller_test.go b/internal/controller/hypervisor_controller_test.go index 61f5ba8..69ea729 100644 --- a/internal/controller/hypervisor_controller_test.go +++ b/internal/controller/hypervisor_controller_test.go @@ -22,7 +22,6 @@ import ( kvmv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "github.com/coreos/go-systemd/v22/dbus" - golibvirt "github.com/digitalocean/go-libvirt" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" @@ -87,33 +86,27 @@ var _ = Describe("Hypervisor Controller", func() { ConnectFunc: func() error { return nil }, - GetDomainsActiveFunc: func() ([]golibvirt.Domain, error) { - return []golibvirt.Domain{}, nil - }, - GetInstancesFunc: func() ([]kvmv1.Instance, error) { - return []kvmv1.Instance{ + ProcessFunc: func(hv kvmv1.Hypervisor) (kvmv1.Hypervisor, error) { + hv.Status.Instances = []kvmv1.Instance{ { ID: "25e2ea06-f6be-4bac-856d-8c2d0bdbcdee", Name: "test-instance", Active: false, }, - }, nil - }, - IsConnectedFunc: func() bool { - return true - }, - GetVersionFunc: func() string { - return "10.9.0" - }, - GetNumInstancesFunc: func() int { - return 1 - }, - GetCapabilitiesFunc: func() (kvmv1.CapabilitiesStatus, error) { - return kvmv1.CapabilitiesStatus{ + } + hv.Status.LibVirtVersion = "10.9.0" + hv.Status.NumInstances = 1 + hv.Status.Capabilities = kvmv1.Capabilities{ HostCpuArch: "x86_64", HostCpus: *resource.NewQuantity(4, resource.DecimalSI), HostMemory: *resource.NewQuantity(8192, resource.DecimalSI), - }, nil + } + hv.Status.DomainCapabilities = kvmv1.DomainCapabilities{ + Arch: "x86_64", + HypervisorType: "kvm", + SupportedCpuModes: []string{"mode/example", "mode/example/1"}, + } + return hv, nil }, }, Systemd: &systemd.InterfaceMock{ @@ -155,7 +148,7 @@ var _ = Describe("Hypervisor Controller", func() { Expect(hypervisor.Status.Instances).To(HaveLen(1)) Expect(hypervisor.Status.Instances[0].ID).To(Equal("25e2ea06-f6be-4bac-856d-8c2d0bdbcdee")) - Expect(hypervisor.Status.Conditions).To(HaveLen(3)) + Expect(hypervisor.Status.Conditions).To(HaveLen(2)) Expect(hypervisor.Status.Conditions[0].Type).To(Equal("test-unit")) Expect(hypervisor.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) @@ -165,10 +158,6 @@ var _ = Describe("Hypervisor Controller", func() { Expect(hypervisor.Status.Conditions[1].Status).To(Equal(metav1.ConditionTrue)) Expect(hypervisor.Status.Conditions[1].Reason).To(Equal("Connected")) - Expect(hypervisor.Status.Conditions[2].Type).To(Equal("CapabilitiesClientConnection")) - Expect(hypervisor.Status.Conditions[2].Status).To(Equal(metav1.ConditionTrue)) - Expect(hypervisor.Status.Conditions[2].Reason).To(Equal("CapabilitiesClientGetSucceeded")) - Expect(hypervisor.Status.Capabilities.HostCpuArch).To(Equal("x86_64")) Expect(hypervisor.Status.Capabilities.HostCpus.AsDec().UnscaledBig()). To(Equal(resource.NewQuantity(4, resource.DecimalSI).AsDec().UnscaledBig())) diff --git a/internal/emulator/libvirt.go b/internal/emulator/libvirt.go index 5dbaaad..3bbdbc9 100644 --- a/internal/emulator/libvirt.go +++ b/internal/emulator/libvirt.go @@ -21,11 +21,9 @@ import ( "context" v1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" - golibvirt "github.com/digitalocean/go-libvirt" logger "sigs.k8s.io/controller-runtime/pkg/log" "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt" - "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt/capabilities" ) func NewLibVirtEmulator(ctx context.Context) *libvirt.InterfaceMock { @@ -39,24 +37,9 @@ func NewLibVirtEmulator(ctx context.Context) *libvirt.InterfaceMock { log.Info("Connect Func called") return nil }, - GetDomainsActiveFunc: func() ([]golibvirt.Domain, error) { - log.Info("GetDomainsActiveFunc Func called") - return []golibvirt.Domain{}, nil - }, - GetInstancesFunc: func() ([]v1.Instance, error) { - log.Info("GetInstancesFunc Func called") - return nil, nil - }, - GetVersionFunc: func() string { - log.Info("GetVersionFunc Func called") - return "10.9.0" - }, - IsConnectedFunc: func() bool { - log.Info("IsConnectedFunc Func called") - return true - }, - GetCapabilitiesFunc: func() (v1.CapabilitiesStatus, error) { - return capabilities.NewClientEmulator().Get(nil) + ProcessFunc: func(hv v1.Hypervisor) (v1.Hypervisor, error) { + log.Info("Process Func called") + return hv, nil }, } return mockedInterface diff --git a/internal/libvirt/capabilities/client.go b/internal/libvirt/capabilities/client.go index 3180c9e..554400f 100644 --- a/internal/libvirt/capabilities/client.go +++ b/internal/libvirt/capabilities/client.go @@ -19,18 +19,15 @@ package capabilities import ( "encoding/xml" - "fmt" - v1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" libvirt "github.com/digitalocean/go-libvirt" - "k8s.io/apimachinery/pkg/api/resource" "sigs.k8s.io/controller-runtime/pkg/log" ) // Client that returns the capabilities of the host we are mounted on. type Client interface { // Return the capabilities status of the host we are mounted on. - Get(virt *libvirt.Libvirt) (v1.CapabilitiesStatus, error) + Get(virt *libvirt.Libvirt) (Capabilities, error) } // Implementation of the CapabilitiesClient interface. @@ -42,18 +39,18 @@ func NewClient() Client { } // Return the capabilities of the host we are mounted on. -func (m *client) Get(virt *libvirt.Libvirt) (v1.CapabilitiesStatus, error) { +func (m *client) Get(virt *libvirt.Libvirt) (Capabilities, error) { capabilitiesXMLBytes, err := virt.Capabilities() if err != nil { log.Log.Error(err, "failed to get libvirt capabilities") - return v1.CapabilitiesStatus{}, err + return Capabilities{}, err } var capabilities Capabilities if err := xml.Unmarshal(capabilitiesXMLBytes, &capabilities); err != nil { log.Log.Error(err, "failed to unmarshal libvirt capabilities") - return v1.CapabilitiesStatus{}, err + return Capabilities{}, err } - return convert(capabilities) + return capabilities, nil } // Emulated capabilities client returning an embedded capabilities xml. @@ -65,35 +62,11 @@ func NewClientEmulator() Client { } // Get the capabilities of the host we are mounted on. -func (c *clientEmulator) Get(virt *libvirt.Libvirt) (v1.CapabilitiesStatus, error) { +func (c *clientEmulator) Get(virt *libvirt.Libvirt) (Capabilities, error) { var capabilities Capabilities if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { log.Log.Error(err, "failed to unmarshal example capabilities") - return v1.CapabilitiesStatus{}, err + return Capabilities{}, err } - return convert(capabilities) -} - -// Convert the libvirt capabilities to the API format. -func convert(in Capabilities) (out v1.CapabilitiesStatus, err error) { - out.HostCpuArch = in.Host.CPU.Arch - // Loop over all numa cells to get the total memory + vcpus. - totalMemory := resource.NewQuantity(0, resource.BinarySI) - totalCpus := resource.NewQuantity(0, resource.DecimalSI) - for _, cell := range in.Host.Topology.CellSpec.Cells { - mem, err := cell.Memory.AsQuantity() - if err != nil { - return v1.CapabilitiesStatus{}, err - } - totalMemory.Add(mem) - cpu := resource.NewQuantity(cell.CPUs.Num, resource.DecimalSI) - if cpu == nil { - return v1.CapabilitiesStatus{}, - fmt.Errorf("invalid CPU count for cell %d", cell.ID) - } - totalCpus.Add(*cpu) - } - out.HostMemory = *totalMemory - out.HostCpus = *totalCpus - return out, nil + return capabilities, nil } diff --git a/internal/libvirt/capabilities/client_test.go b/internal/libvirt/capabilities/client_test.go index d96df98..932e74b 100644 --- a/internal/libvirt/capabilities/client_test.go +++ b/internal/libvirt/capabilities/client_test.go @@ -20,27 +20,26 @@ package capabilities import ( "encoding/xml" "testing" - - "k8s.io/apimachinery/pkg/api/resource" ) func TestNewClient(t *testing.T) { - // Test with default socket path client := NewClient() if client == nil { t.Fatal("NewClient() returned nil") } -} -func TestNewClientWithCustomSocket(t *testing.T) { - // Set custom socket path using t.Setenv for automatic cleanup - customSocket := "/custom/libvirt-sock" - t.Setenv("LIBVIRT_SOCKET", customSocket) + // Verify it implements the Client interface + var _ = client +} +func TestNewClient_ReturnsCorrectType(t *testing.T) { client := NewClient() + // Verify it returns a non-nil implementation if client == nil { - t.Fatal("NewClient() returned nil with custom socket") + t.Fatal("NewClient() returned nil") } + // Verify it's not the emulator type by checking behavior + // (we can't check the unexported type directly) } func TestNewClientEmulator(t *testing.T) { @@ -48,415 +47,332 @@ func TestNewClientEmulator(t *testing.T) { if client == nil { t.Fatal("NewClientEmulator() returned nil") } + + // Verify it implements the Client interface + var _ = client } -func TestClientEmulatorGet(t *testing.T) { +func TestNewClientEmulator_ReturnsCorrectType(t *testing.T) { client := NewClientEmulator() + // Verify it returns a non-nil implementation + if client == nil { + t.Fatal("NewClientEmulator() returned nil") + } + // Verify it works without a libvirt connection (emulator behavior) + _, err := client.Get(nil) + if err != nil { + t.Errorf("Emulator should work with nil libvirt connection, got error: %v", err) + } +} + +func TestClientEmulator_Get_Success(t *testing.T) { + client := NewClientEmulator() + + // The emulator doesn't actually use the libvirt connection, + // so we pass nil to test it doesn't panic + capabilities, err := client.Get(nil) - status, err := client.Get(nil) if err != nil { - t.Fatalf("clientEmulator.Get() returned error: %v", err) + t.Fatalf("Get() returned unexpected error: %v", err) } - // Verify the returned status has expected values based on example XML - if status.HostCpuArch != "x86_64" { - t.Errorf("Expected HostCpuArch to be 'x86_64', got '%s'", status.HostCpuArch) + // Verify the returned capabilities has expected structure + if capabilities.Host.CPU.Arch == "" { + t.Error("Expected capabilities to have Host CPU architecture") } +} - // Verify memory is calculated correctly (sum of all NUMA cells) - // From the example XML, we have 4 cells with different memory values: - // Cell 0: 1056462864 KiB, Cell 1: 1056946772 KiB, Cell 2: 1056946772 KiB, Cell 3: 1056932756 KiB - expectedMemoryKiB := int64(1056462864 + 1056946772 + 1056946772 + 1056932756) - expectedMemoryBytes := expectedMemoryKiB * 1024 // Convert KiB to bytes - expectedMemory := resource.NewQuantity(expectedMemoryBytes, resource.BinarySI) +func TestClientEmulator_Get_ValidXML(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) - if !status.HostMemory.Equal(*expectedMemory) { - t.Errorf("Expected HostMemory to be %s, got %s", expectedMemory.String(), status.HostMemory.String()) + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) } - // Verify CPUs are calculated correctly (sum of all NUMA cells) - // From the example XML, we have 4 cells with 64 CPUs each - expectedCpus := resource.NewQuantity(4*64, resource.DecimalSI) + // Verify the embedded XML can be parsed correctly + var testCaps Capabilities + if err := xml.Unmarshal(exampleXML, &testCaps); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } - if !status.HostCpus.Equal(*expectedCpus) { - t.Errorf("Expected HostCpus to be %s, got %s", expectedCpus.String(), status.HostCpus.String()) + // The emulator should return the same data + if capabilities.Host.CPU.Arch != testCaps.Host.CPU.Arch { + t.Errorf("Expected host CPU arch '%s', got '%s'", + testCaps.Host.CPU.Arch, capabilities.Host.CPU.Arch) } } -func TestConvert(t *testing.T) { - // Create test capabilities data - capabilities := Capabilities{ - Host: CapabilitiesHost{ - CPU: CapabilitiesHostCPU{ - Arch: "x86_64", - }, - Topology: CapabilitiesHostTopology{ - CellSpec: CapabilitiesHostTopologyCells{ - Num: 2, - Cells: []CapabilitiesHostTopologyCell{ - { - ID: 0, - Memory: CapabilitiesHostTopologyCellMemory{ - Unit: "KiB", - Value: 1024000, // 1GB in KiB - }, - CPUs: CapabilitiesHostTopologyCellCPUs{ - Num: 4, - }, - }, - { - ID: 1, - Memory: CapabilitiesHostTopologyCellMemory{ - Unit: "KiB", - Value: 2048000, // 2GB in KiB - }, - CPUs: CapabilitiesHostTopologyCellCPUs{ - Num: 8, - }, - }, - }, - }, - }, - }, +func TestClientEmulator_Get_Consistency(t *testing.T) { + client := NewClientEmulator() + + // Call Get multiple times and verify consistent results + result1, err1 := client.Get(nil) + if err1 != nil { + t.Fatalf("First Get() call failed: %v", err1) } - status, err := convert(capabilities) + result2, err2 := client.Get(nil) + if err2 != nil { + t.Fatalf("Second Get() call failed: %v", err2) + } + + // Compare Host CPU architecture + if result1.Host.CPU.Arch != result2.Host.CPU.Arch { + t.Errorf("Inconsistent host CPU arch: '%s' vs '%s'", + result1.Host.CPU.Arch, result2.Host.CPU.Arch) + } +} + +func TestClientEmulator_Get_HostInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + if err != nil { - t.Fatalf("convert() returned error: %v", err) + t.Fatalf("Get() returned unexpected error: %v", err) } - // Verify CPU architecture - if status.HostCpuArch != "x86_64" { - t.Errorf("Expected HostCpuArch to be 'x86_64', got '%s'", status.HostCpuArch) + // Verify basic host fields + if capabilities.Host.CPU.Arch == "" { + t.Error("Expected host CPU architecture to be set") } +} - // Verify total memory (1GB + 2GB = 3GB) - expectedMemoryBytes := int64((1024000 + 2048000) * 1024) // Convert KiB to bytes - expectedMemory := resource.NewQuantity(expectedMemoryBytes, resource.BinarySI) +func TestClientEmulator_Get_CPUInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) - if !status.HostMemory.Equal(*expectedMemory) { - t.Errorf("Expected HostMemory to be %s, got %s", expectedMemory.String(), status.HostMemory.String()) + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) } - // Verify total CPUs (4 + 8 = 12) - expectedCpus := resource.NewQuantity(12, resource.DecimalSI) + cpu := capabilities.Host.CPU - if !status.HostCpus.Equal(*expectedCpus) { - t.Errorf("Expected HostCpus to be %s, got %s", expectedCpus.String(), status.HostCpus.String()) + // Verify CPU information + if cpu.Arch == "" { + t.Error("Expected CPU architecture to be set") } } -func TestConvertWithDifferentMemoryUnits(t *testing.T) { - testCases := []struct { - name string - unit string - value int64 - expectedBytes int64 - }{ - { - name: "KiB unit", - unit: "KiB", - value: 1024, - expectedBytes: 1024 * 1024, - }, - { - name: "MiB unit", - unit: "MiB", - value: 1, - expectedBytes: 1024 * 1024, - }, - { - name: "GiB unit", - unit: "GiB", - value: 1, - expectedBytes: 1024 * 1024 * 1024, - }, +func TestClientEmulator_Get_TopologyInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - capabilities := Capabilities{ - Host: CapabilitiesHost{ - CPU: CapabilitiesHostCPU{ - Arch: "x86_64", - }, - Topology: CapabilitiesHostTopology{ - CellSpec: CapabilitiesHostTopologyCells{ - Num: 1, - Cells: []CapabilitiesHostTopologyCell{ - { - ID: 0, - Memory: CapabilitiesHostTopologyCellMemory{ - Unit: tc.unit, - Value: tc.value, - }, - CPUs: CapabilitiesHostTopologyCellCPUs{ - Num: 1, - }, - }, - }, - }, - }, - }, - } + topology := capabilities.Host.Topology - status, err := convert(capabilities) - if err != nil { - t.Fatalf("convert() returned error: %v", err) - } + // Verify topology information is accessible + if topology.CellSpec.Num <= 0 { + t.Skip("No NUMA cells found in example XML") + } - expectedMemory := resource.NewQuantity(tc.expectedBytes, resource.BinarySI) - if !status.HostMemory.Equal(*expectedMemory) { - t.Errorf("Expected HostMemory to be %s, got %s", expectedMemory.String(), status.HostMemory.String()) - } - }) + if len(topology.CellSpec.Cells) == 0 { + t.Error("Expected at least one NUMA cell when num > 0") } } -func TestConvertWithInvalidMemoryUnit(t *testing.T) { - capabilities := Capabilities{ - Host: CapabilitiesHost{ - CPU: CapabilitiesHostCPU{ - Arch: "x86_64", - }, - Topology: CapabilitiesHostTopology{ - CellSpec: CapabilitiesHostTopologyCells{ - Num: 1, - Cells: []CapabilitiesHostTopologyCell{ - { - ID: 0, - Memory: CapabilitiesHostTopologyCellMemory{ - Unit: "InvalidUnit", - Value: 1024, - }, - CPUs: CapabilitiesHostTopologyCellCPUs{ - Num: 1, - }, - }, - }, - }, - }, - }, +func TestClientEmulator_Get_GuestInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) } - _, err := convert(capabilities) - if err == nil { - t.Error("Expected convert() to return error for invalid memory unit, but got nil") + guest := capabilities.Guest + + // Verify guest fields are accessible + if guest.OSType == "" { + t.Error("Expected guest OS type to be set") } - expectedError := "unknown memory unit InvalidUnit" - if err.Error() != expectedError { - t.Errorf("Expected error message '%s', got '%s'", expectedError, err.Error()) + if guest.Arch.Name == "" { + t.Error("Expected guest architecture name to be set") } } -func TestConvertWithZeroCPUs(t *testing.T) { - capabilities := Capabilities{ - Host: CapabilitiesHost{ - CPU: CapabilitiesHostCPU{ - Arch: "x86_64", - }, - Topology: CapabilitiesHostTopology{ - CellSpec: CapabilitiesHostTopologyCells{ - Num: 1, - Cells: []CapabilitiesHostTopologyCell{ - { - ID: 0, - Memory: CapabilitiesHostTopologyCellMemory{ - Unit: "KiB", - Value: 1024, - }, - CPUs: CapabilitiesHostTopologyCellCPUs{ - Num: 0, - }, - }, - }, - }, - }, - }, - } +func TestClient_InterfaceCompliance(t *testing.T) { + // Ensure both implementations satisfy the Client interface + var _ = NewClient() + var _ = NewClientEmulator() +} - status, err := convert(capabilities) - if err != nil { - t.Fatalf("convert() returned unexpected error: %v", err) +func TestExampleXML_IsValid(t *testing.T) { + // Verify that the embedded example XML can be parsed + var capabilities Capabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) } - expectedCpus := resource.NewQuantity(0, resource.DecimalSI) - if !status.HostCpus.Equal(*expectedCpus) { - t.Errorf("Expected HostCpus to be %s, got %s", expectedCpus.String(), status.HostCpus.String()) + // Basic validation that we got some data + if capabilities.Host.CPU.Arch == "" { + t.Error("Expected CPU architecture to be present in example XML") } } -func TestConvertWithEmptyCells(t *testing.T) { - capabilities := Capabilities{ - Host: CapabilitiesHost{ - CPU: CapabilitiesHostCPU{ - Arch: "aarch64", - }, - Topology: CapabilitiesHostTopology{ - CellSpec: CapabilitiesHostTopologyCells{ - Num: 0, - Cells: []CapabilitiesHostTopologyCell{}, - }, - }, +func TestExampleXML_Structure(t *testing.T) { + var capabilities Capabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // Test that the example XML has reasonable structure + tests := []struct { + name string + checkFunc func() bool + errMsg string + }{ + { + name: "Has CPU Arch", + checkFunc: func() bool { return capabilities.Host.CPU.Arch != "" }, + errMsg: "Expected Host CPU Arch to be populated", + }, + { + name: "Has Guest OSType", + checkFunc: func() bool { return capabilities.Guest.OSType != "" }, + errMsg: "Expected Guest OSType to be populated", + }, + { + name: "Has Guest Arch Name", + checkFunc: func() bool { return capabilities.Guest.Arch.Name != "" }, + errMsg: "Expected Guest Arch Name to be populated", }, } - status, err := convert(capabilities) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.checkFunc() { + t.Error(tt.errMsg) + } + }) + } +} + +func TestClientEmulator_Get_ReturnsStruct(t *testing.T) { + client := NewClientEmulator() + result, err := client.Get(nil) + if err != nil { - t.Fatalf("convert() returned unexpected error: %v", err) + t.Fatalf("Get() returned unexpected error: %v", err) } - // Verify CPU architecture is preserved - if status.HostCpuArch != "aarch64" { - t.Errorf("Expected HostCpuArch to be 'aarch64', got '%s'", status.HostCpuArch) + // Verify the result has expected data + if result.Host.CPU.Arch == "" && result.Guest.OSType == "" { + t.Error("Expected result to have either Host or Guest populated") } +} + +func TestClientTypes_AreDistinct(t *testing.T) { + client1 := NewClient() + client2 := NewClientEmulator() - // Verify zero memory and CPUs - expectedMemory := resource.NewQuantity(0, resource.BinarySI) - expectedCpus := resource.NewQuantity(0, resource.DecimalSI) + // Verify they are different types + type1 := any(client1) + type2 := any(client2) - if !status.HostMemory.Equal(*expectedMemory) { - t.Errorf("Expected HostMemory to be %s, got %s", expectedMemory.String(), status.HostMemory.String()) + if type1 == type2 { + t.Error("Expected NewClient() and NewClientEmulator() to return different types") } +} - if !status.HostCpus.Equal(*expectedCpus) { - t.Errorf("Expected HostCpus to be %s, got %s", expectedCpus.String(), status.HostCpus.String()) +func TestExampleXML_IOMMUSupport(t *testing.T) { + var capabilities Capabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) } + + // IOMMU support should be accessible + // This test just verifies we can access it without panic + _ = capabilities.Host.IOMMU.Support } -func TestConvertWithRealExampleData(t *testing.T) { - // Use the embedded example XML to test conversion +func TestExampleXML_CacheInfo(t *testing.T) { var capabilities Capabilities - err := xml.Unmarshal(exampleXML, &capabilities) - if err != nil { + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { t.Fatalf("Failed to unmarshal example XML: %v", err) } - status, err := convert(capabilities) + // Cache banks should be accessible + // This test just verifies we can access them without panic + _ = capabilities.Host.Cache.Banks +} + +func TestClientEmulator_Get_NoPanic(t *testing.T) { + client := NewClientEmulator() + + // Ensure calling Get doesn't panic even with nil libvirt + defer func() { + if r := recover(); r != nil { + t.Errorf("Get() panicked with nil libvirt: %v", r) + } + }() + + _, err := client.Get(nil) if err != nil { - t.Fatalf("convert() returned error with real example data: %v", err) + t.Fatalf("Get() returned unexpected error: %v", err) } +} - // Verify the status is valid - if status.HostCpuArch == "" { - t.Error("HostCpuArch should not be empty") +func TestExampleXML_TopologyCells(t *testing.T) { + var capabilities Capabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) } - // Memory and CPU quantities should be positive - if status.HostMemory.IsZero() { - t.Error("HostMemory should not be zero") - } + // If we have NUMA cells, verify their structure + if capabilities.Host.Topology.CellSpec.Num > 0 { + if len(capabilities.Host.Topology.CellSpec.Cells) == 0 { + t.Error("Expected cells to be present when num > 0") + } - if status.HostCpus.IsZero() { - t.Error("HostCpus should not be zero") - } + // Verify we can access cell properties + for i, cell := range capabilities.Host.Topology.CellSpec.Cells { + _ = cell.ID + _ = cell.Memory.Value + _ = cell.CPUs.Num - // Verify specific values from example XML - if status.HostCpuArch != "x86_64" { - t.Errorf("Expected HostCpuArch to be 'x86_64', got '%s'", status.HostCpuArch) + // Just basic validation on first cell + if i == 0 && cell.Memory.Value <= 0 { + t.Error("Expected positive memory value for first cell") + } + } } } -// Test helper function to create a mock capabilities structure -func createMockCapabilities(arch string, cells []mockCell) Capabilities { - capabilitiesCells := make([]CapabilitiesHostTopologyCell, len(cells)) - for i, cell := range cells { - capabilitiesCells[i] = CapabilitiesHostTopologyCell{ - ID: cell.ID, - Memory: CapabilitiesHostTopologyCellMemory{ - Unit: cell.MemoryUnit, - Value: cell.MemoryValue, - }, - CPUs: CapabilitiesHostTopologyCellCPUs{ - Num: cell.CPUCount, - }, - } +func TestExampleXML_GuestArchDomain(t *testing.T) { + var capabilities Capabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) } - return Capabilities{ - Host: CapabilitiesHost{ - CPU: CapabilitiesHostCPU{ - Arch: arch, - }, - Topology: CapabilitiesHostTopology{ - CellSpec: CapabilitiesHostTopologyCells{ - Num: len(cells), - Cells: capabilitiesCells, - }, - }, - }, + guest := capabilities.Guest + + // Verify guest arch domain is accessible + if guest.Arch.Domain.Type == "" { + t.Skip("Guest arch domain type not set in example XML") } -} -type mockCell struct { - ID int - MemoryUnit string - MemoryValue int64 - CPUCount int64 + // Domain type should be something like "kvm" or "qemu" + _ = guest.Arch.Domain.Type } -func TestConvertWithMultipleCellsAndArchitectures(t *testing.T) { - testCases := []struct { - name string - arch string - cells []mockCell - expectedMemory int64 // in bytes - expectedCPUs int64 - }{ - { - name: "Single cell x86_64", - arch: "x86_64", - cells: []mockCell{ - {ID: 0, MemoryUnit: "KiB", MemoryValue: 1024, CPUCount: 2}, - }, - expectedMemory: 1024 * 1024, - expectedCPUs: 2, - }, - { - name: "Multiple cells aarch64", - arch: "aarch64", - cells: []mockCell{ - {ID: 0, MemoryUnit: "MiB", MemoryValue: 512, CPUCount: 4}, - {ID: 1, MemoryUnit: "MiB", MemoryValue: 1024, CPUCount: 8}, - }, - expectedMemory: (512 + 1024) * 1024 * 1024, - expectedCPUs: 12, - }, - { - name: "Mixed memory units", - arch: "ppc64le", - cells: []mockCell{ - {ID: 0, MemoryUnit: "KiB", MemoryValue: 1048576, CPUCount: 1}, // 1GB in KiB - {ID: 1, MemoryUnit: "MiB", MemoryValue: 1024, CPUCount: 2}, // 1GB in MiB - }, - expectedMemory: 2 * 1024 * 1024 * 1024, // 2GB total - expectedCPUs: 3, - }, +func TestExampleXML_WordSize(t *testing.T) { + var capabilities Capabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - capabilities := createMockCapabilities(tc.arch, tc.cells) - - status, err := convert(capabilities) - if err != nil { - t.Fatalf("convert() returned error: %v", err) - } - - if status.HostCpuArch != tc.arch { - t.Errorf("Expected HostCpuArch to be '%s', got '%s'", tc.arch, status.HostCpuArch) - } - - expectedMemory := resource.NewQuantity(tc.expectedMemory, resource.BinarySI) - if !status.HostMemory.Equal(*expectedMemory) { - t.Errorf("Expected HostMemory to be %s, got %s", expectedMemory.String(), status.HostMemory.String()) - } + // Word size should be present (typically 32 or 64) + if capabilities.Guest.Arch.WordSize <= 0 { + t.Error("Expected positive word size value") + } - expectedCpus := resource.NewQuantity(tc.expectedCPUs, resource.DecimalSI) - if !status.HostCpus.Equal(*expectedCpus) { - t.Errorf("Expected HostCpus to be %s, got %s", expectedCpus.String(), status.HostCpus.String()) - } - }) + // Typical word sizes are 32 or 64 + wordSize := capabilities.Guest.Arch.WordSize + if wordSize != 32 && wordSize != 64 { + t.Logf("Note: Unusual word size %d (expected 32 or 64)", wordSize) } } diff --git a/internal/libvirt/capabilities/schema.go b/internal/libvirt/capabilities/schema.go index a911bf6..bc7897b 100644 --- a/internal/libvirt/capabilities/schema.go +++ b/internal/libvirt/capabilities/schema.go @@ -17,12 +17,6 @@ limitations under the License. package capabilities -import ( - "fmt" - - "k8s.io/apimachinery/pkg/api/resource" -) - // Capabilities as returned from the libvirt driver capabilities api. // // The format is the same as returned when executing `virsh capabilities`. See: @@ -59,7 +53,7 @@ type CapabilitiesHostTopologyCells struct { } type CapabilitiesHostTopologyCell struct { - ID int `xml:"id,attr"` + ID uint64 `xml:"id,attr"` Memory CapabilitiesHostTopologyCellMemory `xml:"memory"` Pages []CapabilitiesHostTopologyCellPages `xml:"pages"` Distances CapabilitiesHostTopologyCellDistances `xml:"distances"` @@ -71,27 +65,6 @@ type CapabilitiesHostTopologyCellMemory struct { Value int64 `xml:",chardata"` } -// Get the cell memory as resource.Quantity. -func (c CapabilitiesHostTopologyCellMemory) AsQuantity() (resource.Quantity, error) { - var quantity *resource.Quantity - // Check the unit - switch c.Unit { - case "KiB": - quantity = resource.NewQuantity(c.Value*1024, resource.BinarySI) - case "MiB": - quantity = resource.NewQuantity(c.Value*1024*1024, resource.BinarySI) - case "GiB": - quantity = resource.NewQuantity(c.Value*1024*1024*1024, resource.BinarySI) - case "TiB": - quantity = resource.NewQuantity(c.Value*1024*1024*1024*1024, resource.BinarySI) - } - if quantity == nil { - return resource.Quantity{}, fmt.Errorf("unknown memory unit %s", c.Unit) - } - // Set the value - return *quantity, nil -} - type CapabilitiesHostTopologyCellPages struct { Unit string `xml:"unit,attr"` Size int `xml:"size,attr"` diff --git a/internal/libvirt/domcapabilities/client.go b/internal/libvirt/domcapabilities/client.go new file mode 100644 index 0000000..9add217 --- /dev/null +++ b/internal/libvirt/domcapabilities/client.go @@ -0,0 +1,74 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, LibVirtVersion 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package domcapabilities + +import ( + "encoding/xml" + + libvirt "github.com/digitalocean/go-libvirt" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// Client that returns the domain capabilities of the host we are mounted on. +type Client interface { + // Return the capabilities status of the host we are mounted on. + Get(virt *libvirt.Libvirt) (DomainCapabilities, error) +} + +// Implementation of the Client interface. +type client struct{} + +// Create a new domain capabilities client. +func NewClient() Client { + return &client{} +} + +// Return the domain capabilities of the host we are mounted on. +func (m *client) Get(virt *libvirt.Libvirt) (DomainCapabilities, error) { + // Same as running `virsh domcapabilities` without any arguments. + capabilitiesXMLStr, err := virt. + ConnectGetDomainCapabilities(nil, nil, nil, nil, 0) + if err != nil { + log.Log.Error(err, "failed to get libvirt capabilities") + return DomainCapabilities{}, err + } + var capabilities DomainCapabilities + if err := xml.Unmarshal([]byte(capabilitiesXMLStr), &capabilities); err != nil { + log.Log.Error(err, "failed to unmarshal libvirt capabilities") + return DomainCapabilities{}, err + } + return capabilities, nil +} + +// Emulated domain capabilities client returning an embedded capabilities xml. +type clientEmulator struct{} + +// Create a new emulated domain capabilities client. +func NewClientEmulator() Client { + return &clientEmulator{} +} + +// Get the domain capabilities of the host we are mounted on. +func (c *clientEmulator) Get(virt *libvirt.Libvirt) (DomainCapabilities, error) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + log.Log.Error(err, "failed to unmarshal example capabilities") + return DomainCapabilities{}, err + } + return capabilities, nil +} diff --git a/internal/libvirt/domcapabilities/client_test.go b/internal/libvirt/domcapabilities/client_test.go new file mode 100644 index 0000000..fc11881 --- /dev/null +++ b/internal/libvirt/domcapabilities/client_test.go @@ -0,0 +1,485 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package domcapabilities + +import ( + "encoding/xml" + "testing" +) + +func TestNewClient(t *testing.T) { + client := NewClient() + if client == nil { + t.Fatal("NewClient() returned nil") + } + + // Verify it implements the Client interface + var _ = client +} + +func TestNewClient_ReturnsCorrectType(t *testing.T) { + client := NewClient() + // Verify it returns a non-nil implementation + if client == nil { + t.Fatal("NewClient() returned nil") + } + // Verify it's not the emulator type by checking behavior + // (we can't check the unexported type directly) +} + +func TestNewClientEmulator(t *testing.T) { + client := NewClientEmulator() + if client == nil { + t.Fatal("NewClientEmulator() returned nil") + } + + // Verify it implements the Client interface + var _ = client +} + +func TestNewClientEmulator_ReturnsCorrectType(t *testing.T) { + client := NewClientEmulator() + // Verify it returns a non-nil implementation + if client == nil { + t.Fatal("NewClientEmulator() returned nil") + } + // Verify it works without a libvirt connection (emulator behavior) + _, err := client.Get(nil) + if err != nil { + t.Errorf("Emulator should work with nil libvirt connection, got error: %v", err) + } +} + +func TestClientEmulator_Get_Success(t *testing.T) { + client := NewClientEmulator() + + // The emulator doesn't actually use the libvirt connection, + // so we pass nil to test it doesn't panic + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify the returned capabilities has expected structure + if capabilities.Domain == "" { + t.Error("Expected capabilities to have Domain type") + } + + if capabilities.Arch == "" { + t.Error("Expected capabilities to have Arch") + } +} + +func TestClientEmulator_Get_ValidXML(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify the embedded XML can be parsed correctly + var testCaps DomainCapabilities + if err := xml.Unmarshal(exampleXML, &testCaps); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // The emulator should return the same data + if capabilities.Domain != testCaps.Domain { + t.Errorf("Expected domain type '%s', got '%s'", testCaps.Domain, capabilities.Domain) + } + + if capabilities.Arch != testCaps.Arch { + t.Errorf("Expected arch '%s', got '%s'", testCaps.Arch, capabilities.Arch) + } +} + +func TestClientEmulator_Get_Consistency(t *testing.T) { + client := NewClientEmulator() + + // Call Get multiple times and verify consistent results + result1, err1 := client.Get(nil) + if err1 != nil { + t.Fatalf("First Get() call failed: %v", err1) + } + + result2, err2 := client.Get(nil) + if err2 != nil { + t.Fatalf("Second Get() call failed: %v", err2) + } + + // Compare domain types + if result1.Domain != result2.Domain { + t.Errorf("Inconsistent domain types: '%s' vs '%s'", result1.Domain, result2.Domain) + } + + // Compare architectures + if result1.Arch != result2.Arch { + t.Errorf("Inconsistent architectures: '%s' vs '%s'", result1.Arch, result2.Arch) + } +} + +func TestClientEmulator_Get_OSInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify OS capabilities are accessible + if capabilities.OS.Supported == "" { + t.Skip("OS supported attribute not set in example XML") + } + + // Loader information should be accessible + _ = capabilities.OS.Loader.Supported +} + +func TestClientEmulator_Get_CPUInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify CPU modes are accessible + if len(capabilities.CPU.Modes) == 0 { + t.Skip("No CPU modes found in example XML") + } + + // Verify we can access mode properties + mode := capabilities.CPU.Modes[0] + if mode.Name == "" { + t.Error("Expected CPU mode to have a name") + } +} + +func TestClientEmulator_Get_DevicesInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify devices are accessible + if len(capabilities.Devices.Devices) == 0 { + t.Skip("No devices found in example XML") + } + + // Verify we can access device properties + for _, device := range capabilities.Devices.Devices { + _ = device.Supported + _ = device.Enums + } +} + +func TestClientEmulator_Get_FeaturesInfo(t *testing.T) { + client := NewClientEmulator() + capabilities, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify features are accessible + if len(capabilities.Features.Features) == 0 { + t.Skip("No features found in example XML") + } + + // Verify we can access feature properties + for _, feature := range capabilities.Features.Features { + _ = feature.Supported + } +} + +func TestClient_InterfaceCompliance(t *testing.T) { + // Ensure both implementations satisfy the Client interface + var _ = NewClient() + var _ = NewClientEmulator() +} + +func TestExampleXML_IsValid(t *testing.T) { + // Verify that the embedded example XML can be parsed + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // Basic validation that we got some data + if capabilities.Domain == "" { + t.Error("Expected domain type to be present in example XML") + } + + if capabilities.Arch == "" { + t.Error("Expected architecture to be present in example XML") + } +} + +func TestExampleXML_Structure(t *testing.T) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // Test that the example XML has reasonable structure + tests := []struct { + name string + checkFunc func() bool + errMsg string + }{ + { + name: "Has Domain Type", + checkFunc: func() bool { return capabilities.Domain != "" }, + errMsg: "Expected Domain type to be populated", + }, + { + name: "Has Architecture", + checkFunc: func() bool { return capabilities.Arch != "" }, + errMsg: "Expected Architecture to be populated", + }, + { + name: "Has CPU Modes", + checkFunc: func() bool { return len(capabilities.CPU.Modes) > 0 }, + errMsg: "Expected at least one CPU mode to be populated", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.checkFunc() { + t.Error(tt.errMsg) + } + }) + } +} + +func TestClientEmulator_Get_ReturnsStruct(t *testing.T) { + client := NewClientEmulator() + result, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify the result has expected data + if result.Domain == "" && result.Arch == "" { + t.Error("Expected result to have either Domain or Arch populated") + } +} + +func TestClientTypes_AreDistinct(t *testing.T) { + client1 := NewClient() + client2 := NewClientEmulator() + + // Verify they are different types + type1 := any(client1) + type2 := any(client2) + + if type1 == type2 { + t.Error("Expected NewClient() and NewClientEmulator() to return different types") + } +} + +func TestExampleXML_CPUModes(t *testing.T) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + if len(capabilities.CPU.Modes) == 0 { + t.Skip("No CPU modes in example XML") + } + + // Verify CPU mode properties + for _, mode := range capabilities.CPU.Modes { + if mode.Name == "" { + t.Error("Expected CPU mode to have a name") + } + + // Supported attribute should be present + if mode.Supported == "" { + t.Logf("Note: CPU mode '%s' has no supported attribute", mode.Name) + } + + // Enums should be accessible + _ = mode.Enums + } +} + +func TestExampleXML_OSLoader(t *testing.T) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // OS loader should be accessible + loader := capabilities.OS.Loader + _ = loader.Supported + + // Enums should be accessible + if len(loader.Enums) > 0 { + for _, enum := range loader.Enums { + if enum.Name == "" { + t.Error("Expected enum to have a name") + } + // Values should be accessible + _ = enum.Values + } + } +} + +func TestClientEmulator_Get_NoPanic(t *testing.T) { + client := NewClientEmulator() + + // Ensure calling Get doesn't panic even with nil libvirt + defer func() { + if r := recover(); r != nil { + t.Errorf("Get() panicked with nil libvirt: %v", r) + } + }() + + _, err := client.Get(nil) + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } +} + +func TestExampleXML_DomainType(t *testing.T) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // Domain type should typically be "kvm", "qemu", etc. + if capabilities.Domain == "" { + t.Error("Expected domain type to be set") + } + + // Common domain types + validTypes := map[string]bool{ + "kvm": true, + "qemu": true, + "xen": true, + "lxc": true, + } + + if !validTypes[capabilities.Domain] { + t.Logf("Note: Unusual domain type '%s' (expected kvm, qemu, xen, or lxc)", capabilities.Domain) + } +} + +func TestExampleXML_Architecture(t *testing.T) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // Architecture should be set + if capabilities.Arch == "" { + t.Error("Expected architecture to be set") + } + + // Common architectures + validArchs := map[string]bool{ + "x86_64": true, + "i686": true, + "aarch64": true, + "ppc64": true, + "ppc64le": true, + "s390x": true, + } + + if !validArchs[capabilities.Arch] { + t.Logf("Note: Unusual architecture '%s'", capabilities.Arch) + } +} + +func TestExampleXML_DeviceEnums(t *testing.T) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + if len(capabilities.Devices.Devices) == 0 { + t.Skip("No devices in example XML") + } + + // Verify device enum structure + for _, device := range capabilities.Devices.Devices { + for _, enum := range device.Enums { + if enum.Name == "" { + t.Error("Expected device enum to have a name") + } + if len(enum.Values) == 0 { + t.Logf("Note: Device enum '%s' has no values", enum.Name) + } + } + } +} + +func TestExampleXML_FeaturesList(t *testing.T) { + var capabilities DomainCapabilities + if err := xml.Unmarshal(exampleXML, &capabilities); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + if len(capabilities.Features.Features) == 0 { + t.Skip("No features in example XML") + } + + // Verify we can iterate through features + for _, feature := range capabilities.Features.Features { + // Supported attribute should be accessible + _ = feature.Supported + } +} + +func TestClientEmulator_Get_MultipleFields(t *testing.T) { + client := NewClientEmulator() + result, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Count how many main fields are populated + fieldsSet := 0 + if result.Domain != "" { + fieldsSet++ + } + if result.Arch != "" { + fieldsSet++ + } + if len(result.CPU.Modes) > 0 { + fieldsSet++ + } + if len(result.Devices.Devices) > 0 { + fieldsSet++ + } + if len(result.Features.Features) > 0 { + fieldsSet++ + } + + if fieldsSet == 0 { + t.Error("Expected at least one field to be populated in domain capabilities") + } +} diff --git a/internal/libvirt/domcapabilities/example.go b/internal/libvirt/domcapabilities/example.go new file mode 100644 index 0000000..328a7db --- /dev/null +++ b/internal/libvirt/domcapabilities/example.go @@ -0,0 +1,25 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, LibVirtVersion 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package domcapabilities + +import ( + _ "embed" +) + +//go:embed example.xml +var exampleXML []byte diff --git a/internal/libvirt/domcapabilities/example.xml b/internal/libvirt/domcapabilities/example.xml new file mode 100644 index 0000000..723bf89 --- /dev/null +++ b/internal/libvirt/domcapabilities/example.xml @@ -0,0 +1,33 @@ + + + + + ch + x86_64 + + + + no + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/libvirt/domcapabilities/schema.go b/internal/libvirt/domcapabilities/schema.go new file mode 100644 index 0000000..16fe572 --- /dev/null +++ b/internal/libvirt/domcapabilities/schema.go @@ -0,0 +1,87 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, LibVirtVersion 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package domcapabilities + +import "encoding/xml" + +// DomainCapabilities as returned from the libvirt domain capabilities api. +// +// The format is the same as returned when executing `virsh domcapabilities`. +// See: https://www.libvirt.org/manpages/virsh.html#domcapabilities +// For another reference see: https://gitlab.com/libvirt/libvirt-go-xml-module/-/blob/v1.11010.0/domain_capabilities.go +type DomainCapabilities struct { + Domain string `xml:"domain"` + Arch string `xml:"arch"` + OS DomainCapabilitiesOS `xml:"os"` + CPU DomainCapabilitiesCPU `xml:"cpu"` + Devices DomainCapabilitiesDevices `xml:"devices"` + Features DomainCapabilitiesFeatures `xml:"features"` +} + +// DomainCapabilitiesOS represents the OS capabilities section. +type DomainCapabilitiesOS struct { + Supported string `xml:"supported,attr"` + Loader DomainCapabilitiesOSLoader `xml:"loader"` +} + +// DomainCapabilitiesOSLoader represents the loader capabilities. +type DomainCapabilitiesOSLoader struct { + Supported string `xml:"supported,attr"` + Enums []DomainCapabilitiesEnum `xml:"enum"` +} + +// DomainCapabilitiesEnum represents an enumeration of possible values. +type DomainCapabilitiesEnum struct { + Name string `xml:"name,attr"` + Values []string `xml:"value"` +} + +// DomainCapabilitiesCPU represents the CPU capabilities section. +type DomainCapabilitiesCPU struct { + Modes []DomainCapabilitiesCPUMode `xml:"mode"` +} + +// DomainCapabilitiesCPUMode represents a CPU mode with its capabilities. +type DomainCapabilitiesCPUMode struct { + Name string `xml:"name,attr"` + Supported string `xml:"supported,attr"` + Enums []DomainCapabilitiesEnum `xml:"enum"` +} + +// DomainCapabilitiesDevice represents the devices capabilities section. +type DomainCapabilitiesDevice struct { + XMLName xml.Name `xml:""` + Supported string `xml:"supported,attr"` + Enums []DomainCapabilitiesEnum `xml:"enum"` +} + +// DomainCapabilitiesDevices represents the devices capabilities section. +type DomainCapabilitiesDevices struct { + Devices []DomainCapabilitiesDevice `xml:",any"` +} + +// DomainCapabilitiesFeature represents a feature with supported attribute. +type DomainCapabilitiesFeature struct { + XMLName xml.Name `xml:""` + Supported string `xml:"supported,attr"` +} + +// DomainCapabilitiesFeatures represents the features capabilities section. +type DomainCapabilitiesFeatures struct { + Features []DomainCapabilitiesFeature `xml:",any"` +} diff --git a/internal/libvirt/domcapabilities/schema_test.go b/internal/libvirt/domcapabilities/schema_test.go new file mode 100644 index 0000000..0dc370b --- /dev/null +++ b/internal/libvirt/domcapabilities/schema_test.go @@ -0,0 +1,206 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package domcapabilities + +import ( + "encoding/xml" + "testing" +) + +func TestDomainCapabilitiesDeserialization(t *testing.T) { + // Unmarshal the XML into our DomainCapabilities struct + var domainCapabilities DomainCapabilities + err := xml.Unmarshal(exampleXML, &domainCapabilities) + if err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + // Verify domain and arch + if domainCapabilities.Domain != "ch" { + t.Errorf("Expected domain to be 'ch', got '%s'", domainCapabilities.Domain) + } + if domainCapabilities.Arch != "x86_64" { + t.Errorf("Expected arch to be 'x86_64', got '%s'", domainCapabilities.Arch) + } + + // Verify OS section + if domainCapabilities.OS.Supported != "yes" { + t.Errorf("Expected OS supported to be 'yes', got '%s'", domainCapabilities.OS.Supported) + } + if domainCapabilities.OS.Loader.Supported != "yes" { + t.Errorf("Expected OS loader supported to be 'yes', got '%s'", domainCapabilities.OS.Loader.Supported) + } + if len(domainCapabilities.OS.Loader.Enums) != 1 { + t.Errorf("Expected 1 loader enum, got %d", len(domainCapabilities.OS.Loader.Enums)) + } + if domainCapabilities.OS.Loader.Enums[0].Name != "secure" { + t.Errorf("Expected loader enum name to be 'secure', got '%s'", domainCapabilities.OS.Loader.Enums[0].Name) + } + if len(domainCapabilities.OS.Loader.Enums[0].Values) != 1 { + t.Errorf("Expected 1 value in secure enum, got %d", len(domainCapabilities.OS.Loader.Enums[0].Values)) + } + if domainCapabilities.OS.Loader.Enums[0].Values[0] != "no" { + t.Errorf("Expected secure enum value to be 'no', got '%s'", domainCapabilities.OS.Loader.Enums[0].Values[0]) + } + + // Verify CPU section + if len(domainCapabilities.CPU.Modes) != 4 { + t.Errorf("Expected 4 CPU modes, got %d", len(domainCapabilities.CPU.Modes)) + } + + // Verify host-passthrough mode + hostPassthroughMode := domainCapabilities.CPU.Modes[0] + if hostPassthroughMode.Name != "host-passthrough" { + t.Errorf("Expected first CPU mode name to be 'host-passthrough', got '%s'", hostPassthroughMode.Name) + } + if hostPassthroughMode.Supported != "yes" { + t.Errorf("Expected host-passthrough mode supported to be 'yes', got '%s'", hostPassthroughMode.Supported) + } + if len(hostPassthroughMode.Enums) != 1 { + t.Errorf("Expected 1 enum for host-passthrough mode, got %d", len(hostPassthroughMode.Enums)) + } + if hostPassthroughMode.Enums[0].Name != "hostPassthroughMigratable" { + t.Errorf("Expected enum name to be 'hostPassthroughMigratable', got '%s'", hostPassthroughMode.Enums[0].Name) + } + + // Verify maximum mode + maximumMode := domainCapabilities.CPU.Modes[1] + if maximumMode.Name != "maximum" { + t.Errorf("Expected second CPU mode name to be 'maximum', got '%s'", maximumMode.Name) + } + if maximumMode.Supported != "no" { + t.Errorf("Expected maximum mode supported to be 'no', got '%s'", maximumMode.Supported) + } + + // Verify host-model mode + hostModelMode := domainCapabilities.CPU.Modes[2] + if hostModelMode.Name != "host-model" { + t.Errorf("Expected third CPU mode name to be 'host-model', got '%s'", hostModelMode.Name) + } + if hostModelMode.Supported != "no" { + t.Errorf("Expected host-model mode supported to be 'no', got '%s'", hostModelMode.Supported) + } + + // Verify custom mode + customMode := domainCapabilities.CPU.Modes[3] + if customMode.Name != "custom" { + t.Errorf("Expected fourth CPU mode name to be 'custom', got '%s'", customMode.Name) + } + if customMode.Supported != "no" { + t.Errorf("Expected custom mode supported to be 'no', got '%s'", customMode.Supported) + } + + // Verify devices section + if len(domainCapabilities.Devices.Devices) != 1 { + t.Errorf("Expected 1 device, got %d", len(domainCapabilities.Devices.Devices)) + } + videoDevice := domainCapabilities.Devices.Devices[0] + if videoDevice.XMLName.Local != "video" { + t.Errorf("Expected device name to be 'video', got '%s'", videoDevice.XMLName.Local) + } + if videoDevice.Supported != "yes" { + t.Errorf("Expected video device supported to be 'yes', got '%s'", videoDevice.Supported) + } + if len(videoDevice.Enums) != 1 { + t.Errorf("Expected 1 enum for video device, got %d", len(videoDevice.Enums)) + } + if videoDevice.Enums[0].Name != "modelType" { + t.Errorf("Expected video enum name to be 'modelType', got '%s'", videoDevice.Enums[0].Name) + } + if len(videoDevice.Enums[0].Values) != 1 { + t.Errorf("Expected 1 value in modelType enum, got %d", len(videoDevice.Enums[0].Values)) + } + if videoDevice.Enums[0].Values[0] != "none" { + t.Errorf("Expected modelType value to be 'none', got '%s'", videoDevice.Enums[0].Values[0]) + } + + // Verify features section + if len(domainCapabilities.Features.Features) != 2 { + t.Errorf("Expected 2 features, got %d", len(domainCapabilities.Features.Features)) + } + + // Verify sev feature + sevFeature := domainCapabilities.Features.Features[0] + if sevFeature.XMLName.Local != "sev" { + t.Errorf("Expected first feature name to be 'sev', got '%s'", sevFeature.XMLName.Local) + } + if sevFeature.Supported != "no" { + t.Errorf("Expected sev feature supported to be 'no', got '%s'", sevFeature.Supported) + } + + // Verify sgx feature + sgxFeature := domainCapabilities.Features.Features[1] + if sgxFeature.XMLName.Local != "sgx" { + t.Errorf("Expected second feature name to be 'sgx', got '%s'", sgxFeature.XMLName.Local) + } + if sgxFeature.Supported != "no" { + t.Errorf("Expected sgx feature supported to be 'no', got '%s'", sgxFeature.Supported) + } +} + +func TestDomainCapabilitiesRoundTrip(t *testing.T) { + // Unmarshal into struct + var domainCapabilities DomainCapabilities + err := xml.Unmarshal(exampleXML, &domainCapabilities) + if err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + // Marshal back to XML + marshaledXML, err := xml.MarshalIndent(domainCapabilities, "", " ") + if err != nil { + t.Fatalf("Failed to marshal back to XML: %v", err) + } + + // Unmarshal the marshaled XML to verify it's still valid + var roundTripDomainCapabilities DomainCapabilities + err = xml.Unmarshal(marshaledXML, &roundTripDomainCapabilities) + if err != nil { + t.Fatalf("Failed to unmarshal round-trip XML: %v", err) + } + + // Verify key fields are preserved + if domainCapabilities.Domain != roundTripDomainCapabilities.Domain { + t.Errorf("Domain mismatch after round trip: expected '%s', got '%s'", + domainCapabilities.Domain, roundTripDomainCapabilities.Domain) + } + if domainCapabilities.Arch != roundTripDomainCapabilities.Arch { + t.Errorf("Arch mismatch after round trip: expected '%s', got '%s'", + domainCapabilities.Arch, roundTripDomainCapabilities.Arch) + } + if domainCapabilities.OS.Supported != roundTripDomainCapabilities.OS.Supported { + t.Errorf("OS supported mismatch after round trip: expected '%s', got '%s'", + domainCapabilities.OS.Supported, roundTripDomainCapabilities.OS.Supported) + } + if domainCapabilities.OS.Loader.Supported != roundTripDomainCapabilities.OS.Loader.Supported { + t.Errorf("OS loader supported mismatch after round trip: expected '%s', got '%s'", + domainCapabilities.OS.Loader.Supported, roundTripDomainCapabilities.OS.Loader.Supported) + } + if len(domainCapabilities.CPU.Modes) != len(roundTripDomainCapabilities.CPU.Modes) { + t.Errorf("CPU modes count mismatch after round trip: expected %d, got %d", + len(domainCapabilities.CPU.Modes), len(roundTripDomainCapabilities.CPU.Modes)) + } + if len(domainCapabilities.Devices.Devices) != len(roundTripDomainCapabilities.Devices.Devices) { + t.Errorf("Devices count mismatch after round trip: expected %d, got %d", + len(domainCapabilities.Devices.Devices), len(roundTripDomainCapabilities.Devices.Devices)) + } + if len(domainCapabilities.Features.Features) != len(roundTripDomainCapabilities.Features.Features) { + t.Errorf("Features count mismatch after round trip: expected %d, got %d", + len(domainCapabilities.Features.Features), len(roundTripDomainCapabilities.Features.Features)) + } +} diff --git a/internal/libvirt/dominfo/client.go b/internal/libvirt/dominfo/client.go new file mode 100644 index 0000000..af88c23 --- /dev/null +++ b/internal/libvirt/dominfo/client.go @@ -0,0 +1,82 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, LibVirtVersion 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dominfo + +import ( + "encoding/xml" + + libvirt "github.com/digitalocean/go-libvirt" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// Client that returns information for all domains on our host. +type Client interface { + // Return information for all domains on our host. + Get(virt *libvirt.Libvirt) ([]DomainInfo, error) +} + +// Implementation of the Client interface. +type client struct{} + +// Create a new domain info client. +func NewClient() Client { + return &client{} +} + +// Return information for all domains on our host. +func (m *client) Get(virt *libvirt.Libvirt) ([]DomainInfo, error) { + domains, _, err := virt.ConnectListAllDomains(1, + libvirt.ConnectListDomainsActive|libvirt.ConnectListDomainsInactive) + if err != nil { + log.Log.Error(err, "failed to list all domains") + return nil, err + } + var domainInfos []DomainInfo + for _, domain := range domains { + domainXML, err := virt.DomainGetXMLDesc(domain, 0) + if err != nil { + log.Log.Error(err, "failed to get domain xml", "domain", domain.Name) + return nil, err + } + var domainInfo DomainInfo + if err := xml.Unmarshal([]byte(domainXML), &domainInfo); err != nil { + log.Log.Error(err, "failed to unmarshal domain xml", "domain", domain.Name) + return nil, err + } + domainInfos = append(domainInfos, domainInfo) + } + return domainInfos, nil +} + +// Emulated domain info client returning an embedded domain xml. +type clientEmulator struct{} + +// Create a new emulated domain info client. +func NewClientEmulator() Client { + return &clientEmulator{} +} + +// Get the domain infos of the host we are mounted on. +func (c *clientEmulator) Get(virt *libvirt.Libvirt) ([]DomainInfo, error) { + var info DomainInfo + if err := xml.Unmarshal(exampleXML, &info); err != nil { + log.Log.Error(err, "failed to unmarshal example capabilities") + return nil, err + } + return []DomainInfo{info}, nil +} diff --git a/internal/libvirt/dominfo/client_test.go b/internal/libvirt/dominfo/client_test.go new file mode 100644 index 0000000..58e810d --- /dev/null +++ b/internal/libvirt/dominfo/client_test.go @@ -0,0 +1,349 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dominfo + +import ( + "encoding/xml" + "testing" +) + +func TestNewClient(t *testing.T) { + client := NewClient() + if client == nil { + t.Fatal("NewClient() returned nil") + } + + // Verify it implements the Client interface + var _ = client +} + +func TestNewClient_ReturnsCorrectType(t *testing.T) { + client := NewClient() + // Verify it returns a non-nil implementation + if client == nil { + t.Fatal("NewClient() returned nil") + } + // Verify it's not the emulator type by checking behavior + // (we can't check the unexported type directly) +} + +func TestNewClientEmulator(t *testing.T) { + client := NewClientEmulator() + if client == nil { + t.Fatal("NewClientEmulator() returned nil") + } + + // Verify it implements the Client interface + var _ = client +} + +func TestNewClientEmulator_ReturnsCorrectType(t *testing.T) { + client := NewClientEmulator() + // Verify it returns a non-nil implementation + if client == nil { + t.Fatal("NewClientEmulator() returned nil") + } + // Verify it works without a libvirt connection (emulator behavior) + _, err := client.Get(nil) + if err != nil { + t.Errorf("Emulator should work with nil libvirt connection, got error: %v", err) + } +} + +func TestClientEmulator_Get_Success(t *testing.T) { + client := NewClientEmulator() + + // The emulator doesn't actually use the libvirt connection, + // so we pass nil to test it doesn't panic + domainInfos, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + if len(domainInfos) != 1 { + t.Fatalf("Expected 1 domain info from emulator, got %d", len(domainInfos)) + } + + // Verify the returned domain info has expected structure + if domainInfos[0].Name == "" { + t.Error("Expected domain to have a name") + } + + if domainInfos[0].UUID == "" { + t.Error("Expected domain to have a UUID") + } +} + +func TestClientEmulator_Get_ValidXML(t *testing.T) { + client := NewClientEmulator() + domainInfos, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify the embedded XML can be parsed correctly + var testInfo DomainInfo + if err := xml.Unmarshal(exampleXML, &testInfo); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // The emulator should return the same data + if domainInfos[0].Name != testInfo.Name { + t.Errorf("Expected domain name '%s', got '%s'", testInfo.Name, domainInfos[0].Name) + } + + if domainInfos[0].UUID != testInfo.UUID { + t.Errorf("Expected domain UUID '%s', got '%s'", testInfo.UUID, domainInfos[0].UUID) + } + + if domainInfos[0].Type != testInfo.Type { + t.Errorf("Expected domain type '%s', got '%s'", testInfo.Type, domainInfos[0].Type) + } +} + +func TestClientEmulator_Get_Consistency(t *testing.T) { + client := NewClientEmulator() + + // Call Get multiple times and verify consistent results + results1, err1 := client.Get(nil) + if err1 != nil { + t.Fatalf("First Get() call failed: %v", err1) + } + + results2, err2 := client.Get(nil) + if err2 != nil { + t.Fatalf("Second Get() call failed: %v", err2) + } + + if len(results1) != len(results2) { + t.Errorf("Inconsistent results: first call returned %d domains, second returned %d", + len(results1), len(results2)) + } + + if len(results1) > 0 && len(results2) > 0 { + if results1[0].Name != results2[0].Name { + t.Errorf("Inconsistent domain names: '%s' vs '%s'", + results1[0].Name, results2[0].Name) + } + if results1[0].UUID != results2[0].UUID { + t.Errorf("Inconsistent domain UUIDs: '%s' vs '%s'", + results1[0].UUID, results2[0].UUID) + } + } +} + +func TestClientEmulator_Get_MemoryInfo(t *testing.T) { + client := NewClientEmulator() + domainInfos, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + if len(domainInfos) == 0 { + t.Fatal("Expected at least one domain info") + } + + domain := domainInfos[0] + + // Check that memory information is present + if domain.Memory != nil { + if domain.Memory.Value <= 0 { + t.Error("Expected memory value to be positive") + } + if domain.Memory.Unit == "" { + t.Error("Expected memory unit to be set") + } + } + + if domain.CurrentMemory != nil { + if domain.CurrentMemory.Value <= 0 { + t.Error("Expected current memory value to be positive") + } + } +} + +func TestClientEmulator_Get_VCPUInfo(t *testing.T) { + client := NewClientEmulator() + domainInfos, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + if len(domainInfos) == 0 { + t.Fatal("Expected at least one domain info") + } + + domain := domainInfos[0] + + // Check that VCPU information is present + if domain.VCPU != nil { + if domain.VCPU.Value <= 0 { + t.Error("Expected VCPU count to be positive") + } + } +} + +func TestClientEmulator_Get_MetadataInfo(t *testing.T) { + client := NewClientEmulator() + domainInfos, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + if len(domainInfos) == 0 { + t.Fatal("Expected at least one domain info") + } + + domain := domainInfos[0] + + // Check that metadata can be accessed (if present) + // This is optional, so we just verify it doesn't cause issues + if domain.Metadata != nil && domain.Metadata.NovaInstance != nil { + // Just verify we can access these fields without panic + _ = domain.Metadata.NovaInstance.Name + _ = domain.Metadata.NovaInstance.CreationTime + } +} + +func TestClientEmulator_Get_DevicesInfo(t *testing.T) { + client := NewClientEmulator() + domainInfos, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + if len(domainInfos) == 0 { + t.Fatal("Expected at least one domain info") + } + + domain := domainInfos[0] + + // Check that devices information can be accessed (if present) + if domain.Devices != nil { + // Just verify we can access these fields without panic + _ = domain.Devices.Emulator + _ = domain.Devices.Disks + _ = domain.Devices.Interfaces + } +} + +func TestClient_InterfaceCompliance(t *testing.T) { + // Ensure both implementations satisfy the Client interface + var _ = NewClient() + var _ = NewClientEmulator() +} + +func TestExampleXML_IsValid(t *testing.T) { + // Verify that the embedded example XML can be parsed + var info DomainInfo + if err := xml.Unmarshal(exampleXML, &info); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // Basic validation that we got some data + if info.Name == "" { + t.Error("Expected domain name to be present in example XML") + } + if info.UUID == "" { + t.Error("Expected domain UUID to be present in example XML") + } + if info.Type == "" { + t.Error("Expected domain type to be present in example XML") + } +} + +func TestExampleXML_Structure(t *testing.T) { + var info DomainInfo + if err := xml.Unmarshal(exampleXML, &info); err != nil { + t.Fatalf("Failed to unmarshal example XML: %v", err) + } + + // Test that the example XML has reasonable structure + tests := []struct { + name string + checkFunc func() bool + errMsg string + }{ + { + name: "Has Memory Info", + checkFunc: func() bool { return info.Memory != nil }, + errMsg: "Expected Memory field to be populated", + }, + { + name: "Has VCPU Info", + checkFunc: func() bool { return info.VCPU != nil }, + errMsg: "Expected VCPU field to be populated", + }, + { + name: "Has OS Info", + checkFunc: func() bool { return info.OS != nil }, + errMsg: "Expected OS field to be populated", + }, + { + name: "Has Devices Info", + checkFunc: func() bool { return info.Devices != nil }, + errMsg: "Expected Devices field to be populated", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.checkFunc() { + t.Error(tt.errMsg) + } + }) + } +} + +func TestClientEmulator_Get_ReturnsSlice(t *testing.T) { + client := NewClientEmulator() + result, err := client.Get(nil) + + if err != nil { + t.Fatalf("Get() returned unexpected error: %v", err) + } + + // Verify the result is a slice + if result == nil { + t.Fatal("Expected non-nil slice, got nil") + } + + // Verify the slice has elements + if len(result) == 0 { + t.Error("Expected at least one element in the result slice") + } +} + +func TestClientTypes_AreDistinct(t *testing.T) { + client1 := NewClient() + client2 := NewClientEmulator() + + // Verify they are different types + type1 := any(client1) + type2 := any(client2) + + if type1 == type2 { + t.Error("Expected NewClient() and NewClientEmulator() to return different types") + } +} diff --git a/internal/libvirt/dominfo/example.go b/internal/libvirt/dominfo/example.go new file mode 100644 index 0000000..a0fc413 --- /dev/null +++ b/internal/libvirt/dominfo/example.go @@ -0,0 +1,25 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, LibVirtVersion 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dominfo + +import ( + _ "embed" +) + +//go:embed example.xml +var exampleXML []byte diff --git a/internal/libvirt/dominfo/example.xml b/internal/libvirt/dominfo/example.xml new file mode 100644 index 0000000..61b4292 --- /dev/null +++ b/internal/libvirt/dominfo/example.xml @@ -0,0 +1,95 @@ + + + + + instance-12345-abc + 12345-abc + + + + example-12345-abc + 2025-12-18 00:49:23 + + 24560 + 0 + 0 + 0 + 6 + + + example-user + example-project + + + + + + + + + + 25149440 + 25149440 + + + + + + 6 + + + + + + + + + + + + + + + /machine + + + hvm + /usr/share/cloud-hypervisor/CLOUDHV_EFI.fd + + + + + + + + + + destroy + restart + destroy + + /usr/bin/cloud-hypervisor + + + + + + + + + + + + + + +
+ + + + + + + + + \ No newline at end of file diff --git a/internal/libvirt/dominfo/schema.go b/internal/libvirt/dominfo/schema.go new file mode 100644 index 0000000..d06e0f7 --- /dev/null +++ b/internal/libvirt/dominfo/schema.go @@ -0,0 +1,370 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, LibVirtVersion 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dominfo + +import "encoding/xml" + +// DomainInfo as returned from the libvirt dumpxml api. +// +// The format is the same as returned when executing `virsh dumpxml`. +// See: https://www.libvirt.org/manpages/virsh.html#dumpxml +// For another reference see: https://gitlab.com/libvirt/libvirt-go-xml-module/-/blob/v1.11010.0/domain.go#L3237 +type DomainInfo struct { + Type string `xml:"type,attr"` + ID string `xml:"id,attr,omitempty"` + Name string `xml:"name"` + UUID string `xml:"uuid"` + Metadata *DomainMetadata `xml:"metadata,omitempty"` + Memory *DomainMemory `xml:"memory,omitempty"` + CurrentMemory *DomainMemory `xml:"currentMemory,omitempty"` + MemoryBacking *DomainMemoryBacking `xml:"memoryBacking,omitempty"` + VCPU *DomainVCPU `xml:"vcpu,omitempty"` + CPUTune *DomainCPUTune `xml:"cputune,omitempty"` + NumaTune *DomainNumaTune `xml:"numatune,omitempty"` + Resource *DomainResource `xml:"resource,omitempty"` + OS *DomainOS `xml:"os,omitempty"` + CPU *DomainCPU `xml:"cpu,omitempty"` + Clock *DomainClock `xml:"clock,omitempty"` + OnPoweroff string `xml:"on_poweroff,omitempty"` + OnReboot string `xml:"on_reboot,omitempty"` + OnCrash string `xml:"on_crash,omitempty"` + Devices *DomainDevices `xml:"devices,omitempty"` +} + +// DomainMetadata represents the metadata section containing OpenStack Nova information. +type DomainMetadata struct { + NovaInstance *NovaInstance `xml:"instance"` +} + +// NovaInstance represents OpenStack Nova instance metadata. +type NovaInstance struct { + XMLName xml.Name `xml:"http://openstack.org/xmlns/libvirt/nova/1.1 instance"` + Package *NovaPackage `xml:"package,omitempty"` + Name string `xml:"name,omitempty"` + CreationTime string `xml:"creationTime,omitempty"` + Flavor *NovaFlavor `xml:"flavor,omitempty"` + Owner *NovaOwner `xml:"owner,omitempty"` + Root *NovaRoot `xml:"root,omitempty"` + Ports *NovaPorts `xml:"ports,omitempty"` +} + +// NovaPackage represents the Nova package version. +type NovaPackage struct { + Version string `xml:"version,attr"` +} + +// NovaFlavor represents the instance flavor. +type NovaFlavor struct { + Name string `xml:"name,attr"` + Memory int `xml:"memory"` + Disk int `xml:"disk"` + Swap int `xml:"swap"` + Ephemeral int `xml:"ephemeral"` + VCPUs int `xml:"vcpus"` +} + +// NovaOwner represents the instance owner. +type NovaOwner struct { + User *NovaUser `xml:"user,omitempty"` + Project *NovaProject `xml:"project,omitempty"` +} + +// NovaUser represents the user who owns the instance. +type NovaUser struct { + UUID string `xml:"uuid,attr"` + Value string `xml:",chardata"` +} + +// NovaProject represents the project that owns the instance. +type NovaProject struct { + UUID string `xml:"uuid,attr"` + Value string `xml:",chardata"` +} + +// NovaRoot represents the root image. +type NovaRoot struct { + Type string `xml:"type,attr"` + UUID string `xml:"uuid,attr"` +} + +// NovaPorts represents the network ports. +type NovaPorts struct { + Ports []NovaPort `xml:"port"` +} + +// NovaPort represents a network port. +type NovaPort struct { + UUID string `xml:"uuid,attr"` + IPs []NovaIP `xml:"ip"` +} + +// NovaIP represents an IP address. +type NovaIP struct { + Type string `xml:"type,attr"` + Address string `xml:"address,attr"` + IPVersion string `xml:"ipVersion,attr"` +} + +// DomainMemory represents memory configuration. +type DomainMemory struct { + Unit string `xml:"unit,attr"` + Value int64 `xml:",chardata"` +} + +// DomainMemoryBacking represents memory backing configuration. +type DomainMemoryBacking struct { + HugePages *DomainHugePages `xml:"hugepages,omitempty"` +} + +// DomainHugePages represents huge pages configuration. +type DomainHugePages struct { + Pages []DomainPage `xml:"page"` +} + +// DomainPage represents a huge page configuration. +type DomainPage struct { + Size string `xml:"size,attr"` + Unit string `xml:"unit,attr"` + Nodeset string `xml:"nodeset,attr,omitempty"` +} + +// DomainVCPU represents virtual CPU configuration. +type DomainVCPU struct { + Placement string `xml:"placement,attr,omitempty"` + Value int `xml:",chardata"` +} + +// DomainCPUTune represents CPU tuning configuration. +type DomainCPUTune struct { + VCPUPins []DomainVCPUPin `xml:"vcpupin,omitempty"` + EmulatorPin *DomainCPUPin `xml:"emulatorpin,omitempty"` +} + +// DomainVCPUPin represents a VCPU pinning configuration. +type DomainVCPUPin struct { + VCPU int `xml:"vcpu,attr"` + CPUSet string `xml:"cpuset,attr"` +} + +// DomainCPUPin represents a CPU pinning configuration. +type DomainCPUPin struct { + CPUSet string `xml:"cpuset,attr"` +} + +// DomainNumaTune represents NUMA tuning configuration. +type DomainNumaTune struct { + Memory *DomainNumaMemory `xml:"memory,omitempty"` + MemNodes []DomainNumaMemNode `xml:"memnode,omitempty"` +} + +// DomainNumaMemory represents NUMA memory configuration. +type DomainNumaMemory struct { + Mode string `xml:"mode,attr"` + Nodeset string `xml:"nodeset,attr"` +} + +// DomainNumaMemNode represents a NUMA memory node configuration. +type DomainNumaMemNode struct { + CellID uint64 `xml:"cellid,attr"` + Mode string `xml:"mode,attr"` + Nodeset string `xml:"nodeset,attr"` +} + +// DomainResource represents resource configuration. +type DomainResource struct { + Partition string `xml:"partition,omitempty"` +} + +// DomainOS represents OS configuration. +type DomainOS struct { + Type *DomainOSType `xml:"type,omitempty"` + Kernel string `xml:"kernel,omitempty"` + Boot *DomainBoot `xml:"boot,omitempty"` +} + +// DomainOSType represents the OS type. +type DomainOSType struct { + Arch string `xml:"arch,attr"` + Value string `xml:",chardata"` +} + +// DomainBoot represents boot configuration. +type DomainBoot struct { + Dev string `xml:"dev,attr"` +} + +// DomainCPU represents CPU configuration. +type DomainCPU struct { + Mode string `xml:"mode,attr,omitempty"` + Topology *DomainCPUTopology `xml:"topology,omitempty"` + Numa *DomainCPUNuma `xml:"numa,omitempty"` +} + +// DomainCPUTopology represents CPU topology. +type DomainCPUTopology struct { + Sockets int `xml:"sockets,attr"` + Dies int `xml:"dies,attr"` + Clusters int `xml:"clusters,attr"` + Cores int `xml:"cores,attr"` + Threads int `xml:"threads,attr"` +} + +// DomainCPUNuma represents CPU NUMA configuration. +type DomainCPUNuma struct { + Cells []DomainCPUNumaCell `xml:"cell"` +} + +// DomainCPUNumaCell represents a NUMA cell. +type DomainCPUNumaCell struct { + ID uint64 `xml:"id,attr"` + CPUs string `xml:"cpus,attr"` + Memory uint64 `xml:"memory,attr"` + Unit string `xml:"unit,attr"` + MemAccess string `xml:"memAccess,attr,omitempty"` +} + +// DomainClock represents clock configuration. +type DomainClock struct { + Offset string `xml:"offset,attr"` +} + +// DomainDevices represents all devices. +type DomainDevices struct { + Emulator string `xml:"emulator,omitempty"` + Disks []DomainDisk `xml:"disk,omitempty"` + Interfaces []DomainInterface `xml:"interface,omitempty"` + Serials []DomainSerial `xml:"serial,omitempty"` +} + +// DomainDisk represents a disk device. +type DomainDisk struct { + Type string `xml:"type,attr"` + Device string `xml:"device,attr"` + Driver *DomainDiskDriver `xml:"driver,omitempty"` + Source *DomainDiskSource `xml:"source,omitempty"` + Target *DomainDiskTarget `xml:"target,omitempty"` + Alias *DomainAlias `xml:"alias,omitempty"` +} + +// DomainDiskDriver represents disk driver configuration. +type DomainDiskDriver struct { + Type string `xml:"type,attr"` + Cache string `xml:"cache,attr,omitempty"` + Discard string `xml:"discard,attr,omitempty"` +} + +// DomainDiskSource represents disk source. +type DomainDiskSource struct { + File string `xml:"file,attr,omitempty"` +} + +// DomainDiskTarget represents disk target. +type DomainDiskTarget struct { + Dev string `xml:"dev,attr"` + Bus string `xml:"bus,attr"` +} + +// DomainInterface represents a network interface. +type DomainInterface struct { + Type string `xml:"type,attr"` + MAC *DomainInterfaceMAC `xml:"mac,omitempty"` + Source *DomainInterfaceSource `xml:"source,omitempty"` + Target *DomainInterfaceTarget `xml:"target,omitempty"` + Model *DomainInterfaceModel `xml:"model,omitempty"` + Driver *DomainInterfaceDriver `xml:"driver,omitempty"` + MTU *DomainInterfaceMTU `xml:"mtu,omitempty"` + Alias *DomainAlias `xml:"alias,omitempty"` + Address *DomainAddress `xml:"address,omitempty"` +} + +// DomainInterfaceMAC represents MAC address. +type DomainInterfaceMAC struct { + Address string `xml:"address,attr"` +} + +// DomainInterfaceSource represents network source. +type DomainInterfaceSource struct { + Bridge string `xml:"bridge,attr,omitempty"` +} + +// DomainInterfaceTarget represents network target. +type DomainInterfaceTarget struct { + Dev string `xml:"dev,attr"` +} + +// DomainInterfaceModel represents network model. +type DomainInterfaceModel struct { + Type string `xml:"type,attr"` +} + +// DomainInterfaceDriver represents network driver. +type DomainInterfaceDriver struct { + Queues string `xml:"queues,attr,omitempty"` + Packed string `xml:"packed,attr,omitempty"` +} + +// DomainInterfaceMTU represents MTU configuration. +type DomainInterfaceMTU struct { + Size int `xml:"size,attr"` +} + +// DomainSerial represents a serial device. +type DomainSerial struct { + Type string `xml:"type,attr"` + Source *DomainSerialSource `xml:"source,omitempty"` + Protocol *DomainSerialProtocol `xml:"protocol,omitempty"` + Log *DomainSerialLog `xml:"log,omitempty"` + Target *DomainSerialTarget `xml:"target,omitempty"` +} + +// DomainSerialSource represents serial source. +type DomainSerialSource struct { + Mode string `xml:"mode,attr"` + Host string `xml:"host,attr"` + Service string `xml:"service,attr"` +} + +// DomainSerialProtocol represents serial protocol. +type DomainSerialProtocol struct { + Type string `xml:"type,attr"` +} + +// DomainSerialLog represents serial log configuration. +type DomainSerialLog struct { + File string `xml:"file,attr"` + Append string `xml:"append,attr"` +} + +// DomainSerialTarget represents serial target. +type DomainSerialTarget struct { + Port int `xml:"port,attr"` +} + +// DomainAlias represents a device alias. +type DomainAlias struct { + Name string `xml:"name,attr"` +} + +// DomainAddress represents a device address. +type DomainAddress struct { + Type string `xml:"type,attr"` + Domain string `xml:"domain,attr,omitempty"` + Bus string `xml:"bus,attr,omitempty"` + Slot string `xml:"slot,attr,omitempty"` + Function string `xml:"function,attr,omitempty"` +} diff --git a/internal/libvirt/dominfo/schema_test.go b/internal/libvirt/dominfo/schema_test.go new file mode 100644 index 0000000..5f98e4c --- /dev/null +++ b/internal/libvirt/dominfo/schema_test.go @@ -0,0 +1,568 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dominfo + +import ( + "encoding/xml" + "testing" +) + +func TestDomainInfoDeserialization(t *testing.T) { + // Unmarshal the XML into our DomainInfo struct + var domainInfo DomainInfo + err := xml.Unmarshal(exampleXML, &domainInfo) + if err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + // Verify domain type and basic attributes + if domainInfo.Type != "kvm" { + t.Errorf("Expected domain type to be 'kvm', got '%s'", domainInfo.Type) + } + if domainInfo.ID != "654321" { + t.Errorf("Expected domain ID to be '654321', got '%s'", domainInfo.ID) + } + if domainInfo.Name != "instance-12345-abc" { + t.Errorf("Expected domain name to be 'instance-12345-abc', got '%s'", domainInfo.Name) + } + if domainInfo.UUID != "12345-abc" { + t.Errorf("Expected domain UUID to be '12345-abc', got '%s'", domainInfo.UUID) + } + + // Verify metadata section + if domainInfo.Metadata == nil { + t.Fatal("Expected metadata to be present") + } + if domainInfo.Metadata.NovaInstance == nil { + t.Fatal("Expected nova instance metadata to be present") + } + + nova := domainInfo.Metadata.NovaInstance + if nova.Name != "example-12345-abc" { + t.Errorf("Expected nova name to be 'example-12345-abc', got '%s'", nova.Name) + } + if nova.CreationTime != "2025-12-18 00:49:23" { + t.Errorf("Expected creation time to be '2025-12-18 00:49:23', got '%s'", nova.CreationTime) + } + + // Verify nova package + if nova.Package == nil { + t.Fatal("Expected nova package to be present") + } + if nova.Package.Version != "28.1.1" { + t.Errorf("Expected package version to be '28.1.1', got '%s'", nova.Package.Version) + } + + // Verify nova flavor + if nova.Flavor == nil { + t.Fatal("Expected nova flavor to be present") + } + if nova.Flavor.Name != "g_k_c6_m24_v2" { + t.Errorf("Expected flavor name to be 'g_k_c6_m24_v2', got '%s'", nova.Flavor.Name) + } + if nova.Flavor.Memory != 24560 { + t.Errorf("Expected flavor memory to be 24560, got %d", nova.Flavor.Memory) + } + if nova.Flavor.VCPUs != 6 { + t.Errorf("Expected flavor vcpus to be 6, got %d", nova.Flavor.VCPUs) + } + + // Verify nova owner + if nova.Owner == nil { + t.Fatal("Expected nova owner to be present") + } + if nova.Owner.User == nil { + t.Fatal("Expected nova user to be present") + } + if nova.Owner.User.UUID != "12345-abc" { + t.Errorf("Expected user UUID to be '12345-abc', got '%s'", nova.Owner.User.UUID) + } + if nova.Owner.User.Value != "example-user" { + t.Errorf("Expected user value to be 'example-user', got '%s'", nova.Owner.User.Value) + } + if nova.Owner.Project == nil { + t.Fatal("Expected nova project to be present") + } + if nova.Owner.Project.UUID != "12345-abc" { + t.Errorf("Expected project UUID to be '12345-abc', got '%s'", nova.Owner.Project.UUID) + } + if nova.Owner.Project.Value != "example-project" { + t.Errorf("Expected project value to be 'example-project', got '%s'", nova.Owner.Project.Value) + } + + // Verify nova root + if nova.Root == nil { + t.Fatal("Expected nova root to be present") + } + if nova.Root.Type != "image" { + t.Errorf("Expected root type to be 'image', got '%s'", nova.Root.Type) + } + if nova.Root.UUID != "12345-abc" { + t.Errorf("Expected root UUID to be '12345-abc', got '%s'", nova.Root.UUID) + } + + // Verify nova ports + if nova.Ports == nil { + t.Fatal("Expected nova ports to be present") + } + if len(nova.Ports.Ports) != 1 { + t.Fatalf("Expected 1 nova port, got %d", len(nova.Ports.Ports)) + } + port := nova.Ports.Ports[0] + if port.UUID != "12345-abc" { + t.Errorf("Expected port UUID to be '12345-abc', got '%s'", port.UUID) + } + if len(port.IPs) != 1 { + t.Fatalf("Expected 1 IP address, got %d", len(port.IPs)) + } + ip := port.IPs[0] + if ip.Type != "fixed" { + t.Errorf("Expected IP type to be 'fixed', got '%s'", ip.Type) + } + if ip.Address != "0.0.0.0" { + t.Errorf("Expected IP address to be '0.0.0.0', got '%s'", ip.Address) + } + if ip.IPVersion != "4" { + t.Errorf("Expected IP version to be '4', got '%s'", ip.IPVersion) + } + + // Verify memory section + if domainInfo.Memory == nil { + t.Fatal("Expected memory to be present") + } + if domainInfo.Memory.Unit != "KiB" { + t.Errorf("Expected memory unit to be 'KiB', got '%s'", domainInfo.Memory.Unit) + } + if domainInfo.Memory.Value != 25149440 { + t.Errorf("Expected memory value to be 25149440, got %d", domainInfo.Memory.Value) + } + + // Verify current memory + if domainInfo.CurrentMemory == nil { + t.Fatal("Expected current memory to be present") + } + if domainInfo.CurrentMemory.Value != 25149440 { + t.Errorf("Expected current memory value to be 25149440, got %d", domainInfo.CurrentMemory.Value) + } + + // Verify memory backing + if domainInfo.MemoryBacking == nil { + t.Fatal("Expected memory backing to be present") + } + if domainInfo.MemoryBacking.HugePages == nil { + t.Fatal("Expected huge pages to be present") + } + if len(domainInfo.MemoryBacking.HugePages.Pages) != 1 { + t.Fatalf("Expected 1 huge page configuration, got %d", len(domainInfo.MemoryBacking.HugePages.Pages)) + } + page := domainInfo.MemoryBacking.HugePages.Pages[0] + if page.Size != "2048" { + t.Errorf("Expected page size to be '2048', got '%s'", page.Size) + } + if page.Unit != "KiB" { + t.Errorf("Expected page unit to be 'KiB', got '%s'", page.Unit) + } + if page.Nodeset != "0" { + t.Errorf("Expected page nodeset to be '0', got '%s'", page.Nodeset) + } + + // Verify VCPU + if domainInfo.VCPU == nil { + t.Fatal("Expected VCPU to be present") + } + if domainInfo.VCPU.Placement != "static" { + t.Errorf("Expected VCPU placement to be 'static', got '%s'", domainInfo.VCPU.Placement) + } + if domainInfo.VCPU.Value != 6 { + t.Errorf("Expected VCPU value to be 6, got %d", domainInfo.VCPU.Value) + } + + // Verify CPU tune + if domainInfo.CPUTune == nil { + t.Fatal("Expected CPU tune to be present") + } + if len(domainInfo.CPUTune.VCPUPins) != 6 { + t.Fatalf("Expected 6 VCPU pins, got %d", len(domainInfo.CPUTune.VCPUPins)) + } + vcpuPin := domainInfo.CPUTune.VCPUPins[0] + if vcpuPin.VCPU != 0 { + t.Errorf("Expected first VCPU pin to be for VCPU 0, got %d", vcpuPin.VCPU) + } + if vcpuPin.CPUSet != "32-63,160-191" { + t.Errorf("Expected first VCPU pin cpuset to be '32-63,160-191', got '%s'", vcpuPin.CPUSet) + } + if domainInfo.CPUTune.EmulatorPin == nil { + t.Fatal("Expected emulator pin to be present") + } + if domainInfo.CPUTune.EmulatorPin.CPUSet != "32-63,160-191" { + t.Errorf("Expected emulator pin cpuset to be '32-63,160-191', got '%s'", domainInfo.CPUTune.EmulatorPin.CPUSet) + } + + // Verify NUMA tune + if domainInfo.NumaTune == nil { + t.Fatal("Expected NUMA tune to be present") + } + if domainInfo.NumaTune.Memory == nil { + t.Fatal("Expected NUMA memory to be present") + } + if domainInfo.NumaTune.Memory.Mode != "strict" { + t.Errorf("Expected NUMA memory mode to be 'strict', got '%s'", domainInfo.NumaTune.Memory.Mode) + } + if domainInfo.NumaTune.Memory.Nodeset != "1" { + t.Errorf("Expected NUMA memory nodeset to be '1', got '%s'", domainInfo.NumaTune.Memory.Nodeset) + } + if len(domainInfo.NumaTune.MemNodes) != 1 { + t.Fatalf("Expected 1 NUMA memory node, got %d", len(domainInfo.NumaTune.MemNodes)) + } + memNode := domainInfo.NumaTune.MemNodes[0] + if memNode.CellID != 0 { + t.Errorf("Expected memory node cell ID to be 0, got %d", memNode.CellID) + } + if memNode.Mode != "strict" { + t.Errorf("Expected memory node mode to be 'strict', got '%s'", memNode.Mode) + } + if memNode.Nodeset != "1" { + t.Errorf("Expected memory node nodeset to be '1', got '%s'", memNode.Nodeset) + } + + // Verify resource + if domainInfo.Resource == nil { + t.Fatal("Expected resource to be present") + } + if domainInfo.Resource.Partition != "/machine" { + t.Errorf("Expected resource partition to be '/machine', got '%s'", domainInfo.Resource.Partition) + } + + // Verify OS + if domainInfo.OS == nil { + t.Fatal("Expected OS to be present") + } + if domainInfo.OS.Type == nil { + t.Fatal("Expected OS type to be present") + } + if domainInfo.OS.Type.Arch != "x86_64" { + t.Errorf("Expected OS arch to be 'x86_64', got '%s'", domainInfo.OS.Type.Arch) + } + if domainInfo.OS.Type.Value != "hvm" { + t.Errorf("Expected OS type value to be 'hvm', got '%s'", domainInfo.OS.Type.Value) + } + if domainInfo.OS.Kernel != "/usr/share/cloud-hypervisor/CLOUDHV_EFI.fd" { + t.Errorf("Expected OS kernel path, got '%s'", domainInfo.OS.Kernel) + } + if domainInfo.OS.Boot == nil { + t.Fatal("Expected OS boot to be present") + } + if domainInfo.OS.Boot.Dev != "hd" { + t.Errorf("Expected boot dev to be 'hd', got '%s'", domainInfo.OS.Boot.Dev) + } + + // Verify CPU + if domainInfo.CPU == nil { + t.Fatal("Expected CPU to be present") + } + if domainInfo.CPU.Mode != "host-passthrough" { + t.Errorf("Expected CPU mode to be 'host-passthrough', got '%s'", domainInfo.CPU.Mode) + } + if domainInfo.CPU.Topology == nil { + t.Fatal("Expected CPU topology to be present") + } + topo := domainInfo.CPU.Topology + if topo.Sockets != 6 { + t.Errorf("Expected 6 sockets, got %d", topo.Sockets) + } + if topo.Dies != 1 { + t.Errorf("Expected 1 die, got %d", topo.Dies) + } + if topo.Clusters != 1 { + t.Errorf("Expected 1 cluster, got %d", topo.Clusters) + } + if topo.Cores != 1 { + t.Errorf("Expected 1 core, got %d", topo.Cores) + } + if topo.Threads != 1 { + t.Errorf("Expected 1 thread, got %d", topo.Threads) + } + + // Verify CPU NUMA + if domainInfo.CPU.Numa == nil { + t.Fatal("Expected CPU NUMA to be present") + } + if len(domainInfo.CPU.Numa.Cells) != 1 { + t.Fatalf("Expected 1 NUMA cell, got %d", len(domainInfo.CPU.Numa.Cells)) + } + cell := domainInfo.CPU.Numa.Cells[0] + if cell.ID != 0 { + t.Errorf("Expected cell ID to be 0, got %d", cell.ID) + } + if cell.CPUs != "0-5" { + t.Errorf("Expected cell CPUs to be '0-5', got '%s'", cell.CPUs) + } + if cell.Memory != 25149440 { + t.Errorf("Expected cell memory to be 25149440, got %d", cell.Memory) + } + if cell.Unit != "KiB" { + t.Errorf("Expected cell unit to be 'KiB', got '%s'", cell.Unit) + } + if cell.MemAccess != "shared" { + t.Errorf("Expected cell memAccess to be 'shared', got '%s'", cell.MemAccess) + } + + // Verify clock + if domainInfo.Clock == nil { + t.Fatal("Expected clock to be present") + } + if domainInfo.Clock.Offset != "utc" { + t.Errorf("Expected clock offset to be 'utc', got '%s'", domainInfo.Clock.Offset) + } + + // Verify lifecycle actions + if domainInfo.OnPoweroff != "destroy" { + t.Errorf("Expected on_poweroff to be 'destroy', got '%s'", domainInfo.OnPoweroff) + } + if domainInfo.OnReboot != "restart" { + t.Errorf("Expected on_reboot to be 'restart', got '%s'", domainInfo.OnReboot) + } + if domainInfo.OnCrash != "destroy" { + t.Errorf("Expected on_crash to be 'destroy', got '%s'", domainInfo.OnCrash) + } + + // Verify devices + if domainInfo.Devices == nil { + t.Fatal("Expected devices to be present") + } + if domainInfo.Devices.Emulator != "/usr/bin/cloud-hypervisor" { + t.Errorf("Expected emulator to be '/usr/bin/cloud-hypervisor', got '%s'", domainInfo.Devices.Emulator) + } + + // Verify disks + if len(domainInfo.Devices.Disks) != 1 { + t.Fatalf("Expected 1 disk, got %d", len(domainInfo.Devices.Disks)) + } + disk := domainInfo.Devices.Disks[0] + if disk.Type != "file" { + t.Errorf("Expected disk type to be 'file', got '%s'", disk.Type) + } + if disk.Device != "disk" { + t.Errorf("Expected disk device to be 'disk', got '%s'", disk.Device) + } + if disk.Driver == nil { + t.Fatal("Expected disk driver to be present") + } + if disk.Driver.Type != "raw" { + t.Errorf("Expected disk driver type to be 'raw', got '%s'", disk.Driver.Type) + } + if disk.Driver.Cache != "none" { + t.Errorf("Expected disk driver cache to be 'none', got '%s'", disk.Driver.Cache) + } + if disk.Driver.Discard != "unmap" { + t.Errorf("Expected disk driver discard to be 'unmap', got '%s'", disk.Driver.Discard) + } + if disk.Source == nil { + t.Fatal("Expected disk source to be present") + } + if disk.Source.File != "/var/lib/nova/instances/12345-abc/disk" { + t.Errorf("Expected disk source file path, got '%s'", disk.Source.File) + } + if disk.Target == nil { + t.Fatal("Expected disk target to be present") + } + if disk.Target.Dev != "vda" { + t.Errorf("Expected disk target dev to be 'vda', got '%s'", disk.Target.Dev) + } + if disk.Target.Bus != "virtio" { + t.Errorf("Expected disk target bus to be 'virtio', got '%s'", disk.Target.Bus) + } + if disk.Alias == nil { + t.Fatal("Expected disk alias to be present") + } + if disk.Alias.Name != "virtio-disk0" { + t.Errorf("Expected disk alias to be 'virtio-disk0', got '%s'", disk.Alias.Name) + } + + // Verify interfaces + if len(domainInfo.Devices.Interfaces) != 1 { + t.Fatalf("Expected 1 interface, got %d", len(domainInfo.Devices.Interfaces)) + } + iface := domainInfo.Devices.Interfaces[0] + if iface.Type != "bridge" { + t.Errorf("Expected interface type to be 'bridge', got '%s'", iface.Type) + } + if iface.MAC == nil { + t.Fatal("Expected interface MAC to be present") + } + if iface.MAC.Address != "ab:cd:ef:12:34:56" { + t.Errorf("Expected MAC address to be 'ab:cd:ef:12:34:56', got '%s'", iface.MAC.Address) + } + if iface.Source == nil { + t.Fatal("Expected interface source to be present") + } + if iface.Source.Bridge != "abcdef" { + t.Errorf("Expected interface bridge to be 'abcdef', got '%s'", iface.Source.Bridge) + } + if iface.Target == nil { + t.Fatal("Expected interface target to be present") + } + if iface.Target.Dev != "abcdef" { + t.Errorf("Expected interface target dev to be 'abcdef', got '%s'", iface.Target.Dev) + } + if iface.Model == nil { + t.Fatal("Expected interface model to be present") + } + if iface.Model.Type != "virtio" { + t.Errorf("Expected interface model type to be 'virtio', got '%s'", iface.Model.Type) + } + if iface.Driver == nil { + t.Fatal("Expected interface driver to be present") + } + if iface.Driver.Queues != "1" { + t.Errorf("Expected interface driver queues to be '1', got '%s'", iface.Driver.Queues) + } + if iface.Driver.Packed != "on" { + t.Errorf("Expected interface driver packed to be 'on', got '%s'", iface.Driver.Packed) + } + if iface.MTU == nil { + t.Fatal("Expected interface MTU to be present") + } + if iface.MTU.Size != 8950 { + t.Errorf("Expected interface MTU size to be 8950, got %d", iface.MTU.Size) + } + if iface.Alias == nil { + t.Fatal("Expected interface alias to be present") + } + if iface.Alias.Name != "net_0" { + t.Errorf("Expected interface alias to be 'net_0', got '%s'", iface.Alias.Name) + } + if iface.Address == nil { + t.Fatal("Expected interface address to be present") + } + if iface.Address.Type != "pci" { + t.Errorf("Expected interface address type to be 'pci', got '%s'", iface.Address.Type) + } + + // Verify serials + if len(domainInfo.Devices.Serials) != 1 { + t.Fatalf("Expected 1 serial, got %d", len(domainInfo.Devices.Serials)) + } + serial := domainInfo.Devices.Serials[0] + if serial.Type != "tcp" { + t.Errorf("Expected serial type to be 'tcp', got '%s'", serial.Type) + } + if serial.Source == nil { + t.Fatal("Expected serial source to be present") + } + if serial.Source.Mode != "bind" { + t.Errorf("Expected serial source mode to be 'bind', got '%s'", serial.Source.Mode) + } + if serial.Source.Host != "10.245.239.50" { + t.Errorf("Expected serial source host to be '10.245.239.50', got '%s'", serial.Source.Host) + } + if serial.Source.Service != "10000" { + t.Errorf("Expected serial source service to be '10000', got '%s'", serial.Source.Service) + } + if serial.Protocol == nil { + t.Fatal("Expected serial protocol to be present") + } + if serial.Protocol.Type != "raw" { + t.Errorf("Expected serial protocol type to be 'raw', got '%s'", serial.Protocol.Type) + } + if serial.Log == nil { + t.Fatal("Expected serial log to be present") + } + if serial.Log.File != "/var/lib/nova/instances/12345-abc/console.log" { + t.Errorf("Expected serial log file path, got '%s'", serial.Log.File) + } + if serial.Log.Append != "off" { + t.Errorf("Expected serial log append to be 'off', got '%s'", serial.Log.Append) + } + if serial.Target == nil { + t.Fatal("Expected serial target to be present") + } + if serial.Target.Port != 0 { + t.Errorf("Expected serial target port to be 0, got %d", serial.Target.Port) + } +} + +func TestDomainInfoRoundTrip(t *testing.T) { + // Unmarshal into struct + var domainInfo DomainInfo + err := xml.Unmarshal(exampleXML, &domainInfo) + if err != nil { + t.Fatalf("Failed to unmarshal XML: %v", err) + } + + // Marshal back to XML + marshaledXML, err := xml.MarshalIndent(domainInfo, "", " ") + if err != nil { + t.Fatalf("Failed to marshal back to XML: %v", err) + } + + // Unmarshal the marshaled XML to verify it's still valid + var roundTripDomainInfo DomainInfo + err = xml.Unmarshal(marshaledXML, &roundTripDomainInfo) + if err != nil { + t.Fatalf("Failed to unmarshal round-trip XML: %v", err) + } + + // Verify key fields are preserved + if domainInfo.Type != roundTripDomainInfo.Type { + t.Errorf("Type mismatch after round trip: expected '%s', got '%s'", + domainInfo.Type, roundTripDomainInfo.Type) + } + if domainInfo.ID != roundTripDomainInfo.ID { + t.Errorf("ID mismatch after round trip: expected '%s', got '%s'", + domainInfo.ID, roundTripDomainInfo.ID) + } + if domainInfo.Name != roundTripDomainInfo.Name { + t.Errorf("Name mismatch after round trip: expected '%s', got '%s'", + domainInfo.Name, roundTripDomainInfo.Name) + } + if domainInfo.UUID != roundTripDomainInfo.UUID { + t.Errorf("UUID mismatch after round trip: expected '%s', got '%s'", + domainInfo.UUID, roundTripDomainInfo.UUID) + } + + // Verify nested structures + if domainInfo.Memory != nil && roundTripDomainInfo.Memory != nil { + if domainInfo.Memory.Value != roundTripDomainInfo.Memory.Value { + t.Errorf("Memory value mismatch after round trip: expected %d, got %d", + domainInfo.Memory.Value, roundTripDomainInfo.Memory.Value) + } + } + + if domainInfo.VCPU != nil && roundTripDomainInfo.VCPU != nil { + if domainInfo.VCPU.Value != roundTripDomainInfo.VCPU.Value { + t.Errorf("VCPU value mismatch after round trip: expected %d, got %d", + domainInfo.VCPU.Value, roundTripDomainInfo.VCPU.Value) + } + } + + if domainInfo.Devices != nil && roundTripDomainInfo.Devices != nil { + if len(domainInfo.Devices.Disks) != len(roundTripDomainInfo.Devices.Disks) { + t.Errorf("Disks count mismatch after round trip: expected %d, got %d", + len(domainInfo.Devices.Disks), len(roundTripDomainInfo.Devices.Disks)) + } + if len(domainInfo.Devices.Interfaces) != len(roundTripDomainInfo.Devices.Interfaces) { + t.Errorf("Interfaces count mismatch after round trip: expected %d, got %d", + len(domainInfo.Devices.Interfaces), len(roundTripDomainInfo.Devices.Interfaces)) + } + if len(domainInfo.Devices.Serials) != len(roundTripDomainInfo.Devices.Serials) { + t.Errorf("Serials count mismatch after round trip: expected %d, got %d", + len(domainInfo.Devices.Serials), len(roundTripDomainInfo.Devices.Serials)) + } + } +} diff --git a/internal/libvirt/interface.go b/internal/libvirt/interface.go index 005fcfd..4a92e1d 100644 --- a/internal/libvirt/interface.go +++ b/internal/libvirt/interface.go @@ -21,7 +21,6 @@ package libvirt import ( v1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" - "github.com/digitalocean/go-libvirt" ) type Interface interface { @@ -31,21 +30,8 @@ type Interface interface { // Close closes the connection to the libvirt daemon. Close() error - // GetInstances returns a list of instances. - GetInstances() ([]v1.Instance, error) - - // GetDomainsActive returns all active domains. - GetDomainsActive() ([]libvirt.Domain, error) - - // IsConnected returns true if the connection to the libvirt daemon is open. - IsConnected() bool - - // GetVersion returns the version of the libvirt daemon. - GetVersion() string - - // GetNumInstances returns the number of instances. - GetNumInstances() int - - // Get the capabilities of the libvirt daemon. - GetCapabilities() (v1.CapabilitiesStatus, error) + // Add information extracted from the libvirt socket to the hypervisor instance. + // If an error occurs, the instance is returned unmodified. The libvirt + // connection needs to be established before calling this function. + Process(hv v1.Hypervisor) (v1.Hypervisor, error) } diff --git a/internal/libvirt/interface_mock.go b/internal/libvirt/interface_mock.go index da7c464..06963d0 100644 --- a/internal/libvirt/interface_mock.go +++ b/internal/libvirt/interface_mock.go @@ -4,9 +4,9 @@ package libvirt import ( - v1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" - "github.com/digitalocean/go-libvirt" "sync" + + v1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" ) // Ensure, that InterfaceMock does implement Interface. @@ -25,23 +25,8 @@ var _ Interface = &InterfaceMock{} // ConnectFunc: func() error { // panic("mock out the Connect method") // }, -// GetCapabilitiesFunc: func() (v1.CapabilitiesStatus, error) { -// panic("mock out the GetCapabilities method") -// }, -// GetDomainsActiveFunc: func() ([]libvirt.Domain, error) { -// panic("mock out the GetDomainsActive method") -// }, -// GetInstancesFunc: func() ([]v1.Instance, error) { -// panic("mock out the GetInstances method") -// }, -// GetNumInstancesFunc: func() int { -// panic("mock out the GetNumInstances method") -// }, -// GetVersionFunc: func() string { -// panic("mock out the GetVersion method") -// }, -// IsConnectedFunc: func() bool { -// panic("mock out the IsConnected method") +// ProcessFunc: func(hv v1.Hypervisor) (v1.Hypervisor, error) { +// panic("mock out the Process method") // }, // } // @@ -56,23 +41,8 @@ type InterfaceMock struct { // ConnectFunc mocks the Connect method. ConnectFunc func() error - // GetCapabilitiesFunc mocks the GetCapabilities method. - GetCapabilitiesFunc func() (v1.CapabilitiesStatus, error) - - // GetDomainsActiveFunc mocks the GetDomainsActive method. - GetDomainsActiveFunc func() ([]libvirt.Domain, error) - - // GetInstancesFunc mocks the GetInstances method. - GetInstancesFunc func() ([]v1.Instance, error) - - // GetNumInstancesFunc mocks the GetNumInstances method. - GetNumInstancesFunc func() int - - // GetVersionFunc mocks the GetVersion method. - GetVersionFunc func() string - - // IsConnectedFunc mocks the IsConnected method. - IsConnectedFunc func() bool + // ProcessFunc mocks the Process method. + ProcessFunc func(hv v1.Hypervisor) (v1.Hypervisor, error) // calls tracks calls to the methods. calls struct { @@ -82,33 +52,14 @@ type InterfaceMock struct { // Connect holds details about calls to the Connect method. Connect []struct { } - // GetCapabilities holds details about calls to the GetCapabilities method. - GetCapabilities []struct { - } - // GetDomainsActive holds details about calls to the GetDomainsActive method. - GetDomainsActive []struct { - } - // GetInstances holds details about calls to the GetInstances method. - GetInstances []struct { - } - // GetNumInstances holds details about calls to the GetNumInstances method. - GetNumInstances []struct { - } - // GetVersion holds details about calls to the GetVersion method. - GetVersion []struct { - } - // IsConnected holds details about calls to the IsConnected method. - IsConnected []struct { + // Process holds details about calls to the Process method. + Process []struct { + Hv v1.Hypervisor } } - lockClose sync.RWMutex - lockConnect sync.RWMutex - lockGetCapabilities sync.RWMutex - lockGetDomainsActive sync.RWMutex - lockGetInstances sync.RWMutex - lockGetNumInstances sync.RWMutex - lockGetVersion sync.RWMutex - lockIsConnected sync.RWMutex + lockClose sync.RWMutex + lockConnect sync.RWMutex + lockProcess sync.RWMutex } // Close calls CloseFunc. @@ -165,164 +116,34 @@ func (mock *InterfaceMock) ConnectCalls() []struct { return calls } -// GetCapabilities calls GetCapabilitiesFunc. -func (mock *InterfaceMock) GetCapabilities() (v1.CapabilitiesStatus, error) { - if mock.GetCapabilitiesFunc == nil { - panic("InterfaceMock.GetCapabilitiesFunc: method is nil but Interface.GetCapabilities was just called") - } - callInfo := struct { - }{} - mock.lockGetCapabilities.Lock() - mock.calls.GetCapabilities = append(mock.calls.GetCapabilities, callInfo) - mock.lockGetCapabilities.Unlock() - return mock.GetCapabilitiesFunc() -} - -// GetCapabilitiesCalls gets all the calls that were made to GetCapabilities. -// Check the length with: -// -// len(mockedInterface.GetCapabilitiesCalls()) -func (mock *InterfaceMock) GetCapabilitiesCalls() []struct { -} { - var calls []struct { - } - mock.lockGetCapabilities.RLock() - calls = mock.calls.GetCapabilities - mock.lockGetCapabilities.RUnlock() - return calls -} - -// GetDomainsActive calls GetDomainsActiveFunc. -func (mock *InterfaceMock) GetDomainsActive() ([]libvirt.Domain, error) { - if mock.GetDomainsActiveFunc == nil { - panic("InterfaceMock.GetDomainsActiveFunc: method is nil but Interface.GetDomainsActive was just called") +// Process calls ProcessFunc. +func (mock *InterfaceMock) Process(hv v1.Hypervisor) (v1.Hypervisor, error) { + if mock.ProcessFunc == nil { + panic("InterfaceMock.ProcessFunc: method is nil but Interface.Process was just called") } callInfo := struct { - }{} - mock.lockGetDomainsActive.Lock() - mock.calls.GetDomainsActive = append(mock.calls.GetDomainsActive, callInfo) - mock.lockGetDomainsActive.Unlock() - return mock.GetDomainsActiveFunc() -} - -// GetDomainsActiveCalls gets all the calls that were made to GetDomainsActive. -// Check the length with: -// -// len(mockedInterface.GetDomainsActiveCalls()) -func (mock *InterfaceMock) GetDomainsActiveCalls() []struct { -} { - var calls []struct { - } - mock.lockGetDomainsActive.RLock() - calls = mock.calls.GetDomainsActive - mock.lockGetDomainsActive.RUnlock() - return calls -} - -// GetInstances calls GetInstancesFunc. -func (mock *InterfaceMock) GetInstances() ([]v1.Instance, error) { - if mock.GetInstancesFunc == nil { - panic("InterfaceMock.GetInstancesFunc: method is nil but Interface.GetInstances was just called") + Hv v1.Hypervisor + }{ + Hv: hv, } - callInfo := struct { - }{} - mock.lockGetInstances.Lock() - mock.calls.GetInstances = append(mock.calls.GetInstances, callInfo) - mock.lockGetInstances.Unlock() - return mock.GetInstancesFunc() -} - -// GetInstancesCalls gets all the calls that were made to GetInstances. -// Check the length with: -// -// len(mockedInterface.GetInstancesCalls()) -func (mock *InterfaceMock) GetInstancesCalls() []struct { -} { - var calls []struct { - } - mock.lockGetInstances.RLock() - calls = mock.calls.GetInstances - mock.lockGetInstances.RUnlock() - return calls -} - -// GetNumInstances calls GetNumInstancesFunc. -func (mock *InterfaceMock) GetNumInstances() int { - if mock.GetNumInstancesFunc == nil { - panic("InterfaceMock.GetNumInstancesFunc: method is nil but Interface.GetNumInstances was just called") - } - callInfo := struct { - }{} - mock.lockGetNumInstances.Lock() - mock.calls.GetNumInstances = append(mock.calls.GetNumInstances, callInfo) - mock.lockGetNumInstances.Unlock() - return mock.GetNumInstancesFunc() -} - -// GetNumInstancesCalls gets all the calls that were made to GetNumInstances. -// Check the length with: -// -// len(mockedInterface.GetNumInstancesCalls()) -func (mock *InterfaceMock) GetNumInstancesCalls() []struct { -} { - var calls []struct { - } - mock.lockGetNumInstances.RLock() - calls = mock.calls.GetNumInstances - mock.lockGetNumInstances.RUnlock() - return calls -} - -// GetVersion calls GetVersionFunc. -func (mock *InterfaceMock) GetVersion() string { - if mock.GetVersionFunc == nil { - panic("InterfaceMock.GetVersionFunc: method is nil but Interface.GetVersion was just called") - } - callInfo := struct { - }{} - mock.lockGetVersion.Lock() - mock.calls.GetVersion = append(mock.calls.GetVersion, callInfo) - mock.lockGetVersion.Unlock() - return mock.GetVersionFunc() -} - -// GetVersionCalls gets all the calls that were made to GetVersion. -// Check the length with: -// -// len(mockedInterface.GetVersionCalls()) -func (mock *InterfaceMock) GetVersionCalls() []struct { -} { - var calls []struct { - } - mock.lockGetVersion.RLock() - calls = mock.calls.GetVersion - mock.lockGetVersion.RUnlock() - return calls -} - -// IsConnected calls IsConnectedFunc. -func (mock *InterfaceMock) IsConnected() bool { - if mock.IsConnectedFunc == nil { - panic("InterfaceMock.IsConnectedFunc: method is nil but Interface.IsConnected was just called") - } - callInfo := struct { - }{} - mock.lockIsConnected.Lock() - mock.calls.IsConnected = append(mock.calls.IsConnected, callInfo) - mock.lockIsConnected.Unlock() - return mock.IsConnectedFunc() + mock.lockProcess.Lock() + mock.calls.Process = append(mock.calls.Process, callInfo) + mock.lockProcess.Unlock() + return mock.ProcessFunc(hv) } -// IsConnectedCalls gets all the calls that were made to IsConnected. +// ProcessCalls gets all the calls that were made to Process. // Check the length with: // -// len(mockedInterface.IsConnectedCalls()) -func (mock *InterfaceMock) IsConnectedCalls() []struct { +// len(mockedInterface.ProcessCalls()) +func (mock *InterfaceMock) ProcessCalls() []struct { + Hv v1.Hypervisor } { var calls []struct { + Hv v1.Hypervisor } - mock.lockIsConnected.RLock() - calls = mock.calls.IsConnected - mock.lockIsConnected.RUnlock() + mock.lockProcess.RLock() + calls = mock.calls.Process + mock.lockProcess.RUnlock() return calls } diff --git a/internal/libvirt/libvirt.go b/internal/libvirt/libvirt.go index 9a319e4..048a5dd 100644 --- a/internal/libvirt/libvirt.go +++ b/internal/libvirt/libvirt.go @@ -27,10 +27,13 @@ import ( v1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" "github.com/digitalocean/go-libvirt" "github.com/digitalocean/go-libvirt/socket/dialers" + "k8s.io/apimachinery/pkg/api/resource" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt/capabilities" + "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt/domcapabilities" + "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt/dominfo" ) type LibVirt struct { @@ -41,8 +44,16 @@ type LibVirt struct { version string domains map[libvirt.ConnectListAllDomainsFlags][]libvirt.Domain - // Client that connects to libvirt and fetches capabilities of the hypervisor. + // Client that connects to libvirt and fetches capabilities of the + // hypervisor. The capabilities client abstracts the xml parsing away. capabilitiesClient capabilities.Client + // Client that connects to libvirt and fetches domain capabilities + // of the hypervisor. The domain capabilities client abstracts the + // xml parsing away. + domainCapabilitiesClient domcapabilities.Client + // Client that connects to libvirt and fetches domain information. + // The domain information client abstracts the xml parsing away. + domainInfoClient dominfo.Client } func NewLibVirt(k client.Client) *LibVirt { @@ -64,6 +75,8 @@ func NewLibVirt(k client.Client) *LibVirt { "N/A", make(map[libvirt.ConnectListAllDomainsFlags][]libvirt.Domain, 2), capabilities.NewClient(), + domcapabilities.NewClient(), + dominfo.NewClient(), } } @@ -99,15 +112,42 @@ func (l *LibVirt) Connect() error { return err } -func (l *LibVirt) GetVersion() string { - return l.version -} - func (l *LibVirt) Close() error { return l.virt.Disconnect() } -func (l *LibVirt) GetInstances() ([]v1.Instance, error) { +// Add information extracted from the libvirt socket to the hypervisor instance. +// If an error occurs, the instance is returned unmodified. The libvirt +// connection needs to be established before calling this function. +func (l *LibVirt) Process(hv v1.Hypervisor) (v1.Hypervisor, error) { + processors := []func(v1.Hypervisor) (v1.Hypervisor, error){ + l.addVersion, + l.addInstancesInfo, + l.addCapabilities, + l.addDomainCapabilities, + l.addAllocationCapacity, + } + var err error + for _, processor := range processors { + if hv, err = processor(hv); err != nil { + log.Log.Error(err, "failed to process hypervisor", "step", processor) + return hv, err + } + } + return hv, nil +} + +// Add the libvirt version to the hypervisor instance. +func (l *LibVirt) addVersion(old v1.Hypervisor) (v1.Hypervisor, error) { + newHv := old + newHv.Status.LibVirtVersion = l.version + return newHv, nil +} + +// Add the domain flags to the hypervisor instance, i.e. how many +// instances are running and how many are inactive. +func (l *LibVirt) addInstancesInfo(old v1.Hypervisor) (v1.Hypervisor, error) { + newHv := old var instances []v1.Instance flags := []libvirt.ConnectListAllDomainsFlags{libvirt.ConnectListDomainsActive, libvirt.ConnectListDomainsInactive} @@ -120,22 +160,229 @@ func (l *LibVirt) GetInstances() ([]v1.Instance, error) { }) } } - return instances, nil -} -func (l *LibVirt) GetDomainsActive() ([]libvirt.Domain, error) { - return l.domains[libvirt.ConnectListDomainsActive], nil + newHv.Status.Instances = instances + newHv.Status.NumInstances = len(l.domains) + return newHv, nil } -func (l *LibVirt) IsConnected() bool { - return l.virt.IsConnected() +// Call the libvirt capabilities API and add the resulting information +// to the hypervisor capabilities status. +func (l *LibVirt) addCapabilities(old v1.Hypervisor) (v1.Hypervisor, error) { + newHv := old + caps, err := l.capabilitiesClient.Get(l.virt) + if err != nil { + return old, err + } + newHv.Status.Capabilities.HostCpuArch = caps.Host.CPU.Arch + // Loop over all numa cells to get the total memory + vcpus capacity. + totalMemory := resource.NewQuantity(0, resource.BinarySI) + totalCpus := resource.NewQuantity(0, resource.DecimalSI) + for _, cell := range caps.Host.Topology.CellSpec.Cells { + mem, err := MemoryToResource(cell.Memory.Value, cell.Memory.Unit) + if err != nil { + return old, err + } + totalMemory.Add(mem) + cpu := resource.NewQuantity(cell.CPUs.Num, resource.DecimalSI) + if cpu == nil { + return old, fmt.Errorf("invalid CPU count for cell %d", cell.ID) + } + totalCpus.Add(*cpu) + } + newHv.Status.Capabilities.HostMemory = *totalMemory + newHv.Status.Capabilities.HostCpus = *totalCpus + return newHv, nil } -func (l *LibVirt) GetNumInstances() int { - return len(l.domains[libvirt.ConnectListDomainsActive]) +// Call the libvirt domcapabilities api and add the resulting information +// to the hypervisor domain capabilities status. +func (l *LibVirt) addDomainCapabilities(old v1.Hypervisor) (v1.Hypervisor, error) { + newHv := old + domCapabilities, err := l.domainCapabilitiesClient.Get(l.virt) + if err != nil { + return old, err + } + + newHv.Status.DomainCapabilities.Arch = domCapabilities.Arch + newHv.Status.DomainCapabilities.HypervisorType = domCapabilities.Domain + + // Convert the supported cpu modes into a flat list of supported cpu types. + // - becomes + // "mode/example" and "mode/example/1" + // - is ignored + // - becomes "mode/example3" + newHv.Status.DomainCapabilities.SupportedCpuModes = []string{} + for _, cpuMode := range domCapabilities.CPU.Modes { + if cpuMode.Supported != "yes" { + continue + } + newHv.Status.DomainCapabilities.SupportedCpuModes = append( + newHv.Status.DomainCapabilities.SupportedCpuModes, + "mode/"+cpuMode.Name, + ) + for _, enum := range cpuMode.Enums { + for _, cpuType := range enum.Values { + newHv.Status.DomainCapabilities.SupportedCpuModes = append( + newHv.Status.DomainCapabilities.SupportedCpuModes, + fmt.Sprintf("mode/%s/%s", cpuMode.Name, cpuType), + ) + } + } + } + + // Convert the supported devices into a flat list. + // - + // becomes "video" and "video/v1" + // - is ignored + // - becomes "video". + newHv.Status.DomainCapabilities.SupportedDevices = []string{} + for _, device := range domCapabilities.Devices.Devices { + if device.Supported != "yes" { + continue + } + newHv.Status.DomainCapabilities.SupportedDevices = append( + newHv.Status.DomainCapabilities.SupportedDevices, + device.XMLName.Local, + ) + for _, enum := range device.Enums { + for _, deviceType := range enum.Values { + newHv.Status.DomainCapabilities.SupportedDevices = append( + newHv.Status.DomainCapabilities.SupportedDevices, + fmt.Sprintf("%s/%s", device.XMLName.Local, deviceType), + ) + } + } + } + + // Convert the supported features into a flat list. + newHv.Status.DomainCapabilities.SupportedFeatures = []string{} + for _, feature := range domCapabilities.Features.Features { + if feature.Supported == "yes" { + newHv.Status.DomainCapabilities.SupportedFeatures = append( + newHv.Status.DomainCapabilities.SupportedFeatures, + feature.XMLName.Local, + ) + } + } + + return newHv, nil } -// Get the capabilities of the libvirt daemon. -func (l *LibVirt) GetCapabilities() (v1.CapabilitiesStatus, error) { - return l.capabilitiesClient.Get(l.virt) +// Add total allocation, total capacity, and numa cell information +// to the hypervisor instance, by combining domain infos and hypervisor +// capabilities in libvirt. +func (l *LibVirt) addAllocationCapacity(old v1.Hypervisor) (v1.Hypervisor, error) { + newHv := old + + // First get all the numa cells from the capabilities + caps, err := l.capabilitiesClient.Get(l.virt) + if err != nil { + return old, err + } + totalMemoryCapacity := resource.NewQuantity(0, resource.BinarySI) + totalCpuCapacity := resource.NewQuantity(0, resource.DecimalSI) + cellsById := make(map[uint64]v1.Cell) + for _, cell := range caps.Host.Topology.CellSpec.Cells { + memoryCapacity, err := MemoryToResource( + cell.Memory.Value, + cell.Memory.Unit, + ) + if err != nil { + return old, err + } + totalMemoryCapacity.Add(memoryCapacity) + + cpuCapacity := *resource.NewQuantity( + cell.CPUs.Num, + resource.DecimalSI, + ) + totalCpuCapacity.Add(cpuCapacity) + + cellsById[cell.ID] = v1.Cell{ + CellID: cell.ID, + Allocation: map[string]resource.Quantity{ + // Will be updated below when we look at the domain infos. + "memory": *resource.NewQuantity(0, resource.BinarySI), + "cpu": *resource.NewQuantity(0, resource.DecimalSI), + }, + Capacity: map[string]resource.Quantity{ + "memory": memoryCapacity, + "cpu": cpuCapacity, + }, + } + } + + // Now get all domain infos to calculate the total allocation. + domInfos, err := l.domainInfoClient.Get(l.virt) + if err != nil { + return old, err + } + totalMemoryAlloc := resource.NewQuantity(0, resource.BinarySI) + totalCpuAlloc := resource.NewQuantity(0, resource.DecimalSI) + for _, domInfo := range domInfos { + memAlloc, err := MemoryToResource( + domInfo.Memory.Value, + domInfo.Memory.Unit, + ) + if err != nil { + return old, err + } + totalMemoryAlloc.Add(memAlloc) + + if domInfo.CPUTune == nil { + return old, fmt.Errorf("missing cpu tune for dom %s", domInfo.Name) + } + cpuAlloc := *resource.NewQuantity( + int64(len(domInfo.CPUTune.VCPUPins)), + resource.DecimalSI, + ) + totalCpuAlloc.Add(cpuAlloc) + + // Add memory allocation to the cells this domain is using. + for _, memoryNode := range domInfo.NumaTune.MemNodes { + cell, ok := cellsById[memoryNode.CellID] + if !ok { + return old, fmt.Errorf( + "domain %s uses unknown memory cell %d", + domInfo.Name, memoryNode.CellID, + ) + } + memAllocCell := cell.Allocation["memory"] + memAllocCell.Add(memAlloc) + cell.Allocation["memory"] = memAllocCell + cellsById[memoryNode.CellID] = cell + } + + // Add cpu allocation to the cells this domain is using. + if domInfo.CPU.Numa == nil { + return old, fmt.Errorf("missing cpu numa for dom %s", domInfo.Name) + } + for _, cpuCell := range domInfo.CPU.Numa.Cells { + cell, ok := cellsById[cpuCell.ID] + if !ok { + return old, fmt.Errorf( + "domain %s uses unknown cpu cell %d", + domInfo.Name, cpuCell.ID, + ) + } + cpuAllocCell := cell.Allocation["cpu"] + cpuAllocCell.Add(cpuAlloc) + cell.Allocation["cpu"] = cpuAllocCell + cellsById[cpuCell.ID] = cell + } + } + cellsAsSlice := []v1.Cell{} + for _, cell := range cellsById { + cellsAsSlice = append(cellsAsSlice, cell) + } + + newHv.Status.Capacity = make(map[string]resource.Quantity) + newHv.Status.Capacity["memory"] = *totalMemoryCapacity + newHv.Status.Capacity["cpu"] = *totalCpuCapacity + newHv.Status.Allocation = make(map[string]resource.Quantity) + newHv.Status.Allocation["memory"] = *totalMemoryAlloc + newHv.Status.Allocation["cpu"] = *totalCpuAlloc + newHv.Status.Cells = cellsAsSlice + return newHv, nil } diff --git a/internal/libvirt/libvirt_test.go b/internal/libvirt/libvirt_test.go new file mode 100644 index 0000000..68f0558 --- /dev/null +++ b/internal/libvirt/libvirt_test.go @@ -0,0 +1,655 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package libvirt + +import ( + "testing" + + v1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + libvirt "github.com/digitalocean/go-libvirt" + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt/capabilities" + "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt/domcapabilities" + "github.com/cobaltcore-dev/kvm-node-agent/internal/libvirt/dominfo" +) + +// mockCapabilitiesClient implements the capabilities.Client interface for testing +type mockCapabilitiesClient struct { + caps capabilities.Capabilities + err error +} + +func (m *mockCapabilitiesClient) Get(virt *libvirt.Libvirt) (capabilities.Capabilities, error) { + if m.err != nil { + return capabilities.Capabilities{}, m.err + } + return m.caps, nil +} + +// mockDomCapabilitiesClient implements the domcapabilities.Client interface for testing +type mockDomCapabilitiesClient struct { + caps domcapabilities.DomainCapabilities + err error +} + +func (m *mockDomCapabilitiesClient) Get(virt *libvirt.Libvirt) (domcapabilities.DomainCapabilities, error) { + if m.err != nil { + return domcapabilities.DomainCapabilities{}, m.err + } + return m.caps, nil +} + +// mockDomInfoClient implements the dominfo.Client interface for testing +type mockDomInfoClient struct { + infos []dominfo.DomainInfo + err error +} + +func (m *mockDomInfoClient) Get(virt *libvirt.Libvirt) ([]dominfo.DomainInfo, error) { + if m.err != nil { + return nil, m.err + } + return m.infos, nil +} + +func TestAddVersion(t *testing.T) { + l := &LibVirt{ + version: "8.0.0", + } + + hv := v1.Hypervisor{} + result, err := l.addVersion(hv) + + if err != nil { + t.Fatalf("addVersion() returned unexpected error: %v", err) + } + + if result.Status.LibVirtVersion != "8.0.0" { + t.Errorf("Expected LibVirtVersion '8.0.0', got '%s'", result.Status.LibVirtVersion) + } +} + +func TestAddVersion_PreservesOtherFields(t *testing.T) { + l := &LibVirt{ + version: "8.0.0", + } + + hv := v1.Hypervisor{ + Status: v1.HypervisorStatus{ + NumInstances: 5, + }, + } + + result, err := l.addVersion(hv) + + if err != nil { + t.Fatalf("addVersion() returned unexpected error: %v", err) + } + + if result.Status.NumInstances != 5 { + t.Errorf("Expected NumInstances to be preserved, got %d", result.Status.NumInstances) + } +} + +func TestAddInstancesInfo_ActiveDomains(t *testing.T) { + l := &LibVirt{ + domains: map[libvirt.ConnectListAllDomainsFlags][]libvirt.Domain{ + libvirt.ConnectListDomainsActive: { + {Name: "instance-1", UUID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}}, + {Name: "instance-2", UUID: [16]byte{2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}}, + }, + libvirt.ConnectListDomainsInactive: {}, + }, + } + + hv := v1.Hypervisor{} + result, err := l.addInstancesInfo(hv) + + if err != nil { + t.Fatalf("addInstancesInfo() returned unexpected error: %v", err) + } + + if len(result.Status.Instances) != 2 { + t.Fatalf("Expected 2 instances, got %d", len(result.Status.Instances)) + } + + // Check that both instances are active + for _, instance := range result.Status.Instances { + if !instance.Active { + t.Errorf("Expected instance '%s' to be active", instance.Name) + } + } +} + +func TestAddInstancesInfo_InactiveDomains(t *testing.T) { + l := &LibVirt{ + domains: map[libvirt.ConnectListAllDomainsFlags][]libvirt.Domain{ + libvirt.ConnectListDomainsActive: {}, + libvirt.ConnectListDomainsInactive: { + {Name: "instance-3", UUID: [16]byte{3, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}}, + }, + }, + } + + hv := v1.Hypervisor{} + result, err := l.addInstancesInfo(hv) + + if err != nil { + t.Fatalf("addInstancesInfo() returned unexpected error: %v", err) + } + + if len(result.Status.Instances) != 1 { + t.Fatalf("Expected 1 instance, got %d", len(result.Status.Instances)) + } + + if result.Status.Instances[0].Active { + t.Error("Expected instance to be inactive") + } +} + +func TestAddInstancesInfo_MixedDomains(t *testing.T) { + l := &LibVirt{ + domains: map[libvirt.ConnectListAllDomainsFlags][]libvirt.Domain{ + libvirt.ConnectListDomainsActive: { + {Name: "active-1"}, + {Name: "active-2"}, + }, + libvirt.ConnectListDomainsInactive: { + {Name: "inactive-1"}, + }, + }, + } + + hv := v1.Hypervisor{} + result, err := l.addInstancesInfo(hv) + + if err != nil { + t.Fatalf("addInstancesInfo() returned unexpected error: %v", err) + } + + if len(result.Status.Instances) != 3 { + t.Fatalf("Expected 3 instances, got %d", len(result.Status.Instances)) + } + + // Count active and inactive + activeCount := 0 + inactiveCount := 0 + for _, instance := range result.Status.Instances { + if instance.Active { + activeCount++ + } else { + inactiveCount++ + } + } + + if activeCount != 2 { + t.Errorf("Expected 2 active instances, got %d", activeCount) + } + if inactiveCount != 1 { + t.Errorf("Expected 1 inactive instance, got %d", inactiveCount) + } +} + +func TestAddCapabilities_Success(t *testing.T) { + caps := capabilities.Capabilities{ + Host: capabilities.CapabilitiesHost{ + CPU: capabilities.CapabilitiesHostCPU{ + Arch: "x86_64", + }, + Topology: capabilities.CapabilitiesHostTopology{ + CellSpec: capabilities.CapabilitiesHostTopologyCells{ + Num: 1, + Cells: []capabilities.CapabilitiesHostTopologyCell{ + { + ID: 0, + Memory: capabilities.CapabilitiesHostTopologyCellMemory{ + Unit: "KiB", + Value: 16777216, // 16 GiB in KiB + }, + CPUs: capabilities.CapabilitiesHostTopologyCellCPUs{ + Num: 8, + }, + }, + }, + }, + }, + }, + } + + l := &LibVirt{ + capabilitiesClient: &mockCapabilitiesClient{caps: caps}, + } + + hv := v1.Hypervisor{} + result, err := l.addCapabilities(hv) + + if err != nil { + t.Fatalf("addCapabilities() returned unexpected error: %v", err) + } + + if result.Status.Capabilities.HostCpuArch != "x86_64" { + t.Errorf("Expected HostCpuArch 'x86_64', got '%s'", result.Status.Capabilities.HostCpuArch) + } + + expectedMemory := resource.NewQuantity(16777216*1024, resource.BinarySI) + if !result.Status.Capabilities.HostMemory.Equal(*expectedMemory) { + t.Errorf("Expected HostMemory %s, got %s", expectedMemory.String(), result.Status.Capabilities.HostMemory.String()) + } + + expectedCpus := resource.NewQuantity(8, resource.DecimalSI) + if !result.Status.Capabilities.HostCpus.Equal(*expectedCpus) { + t.Errorf("Expected HostCpus %s, got %s", expectedCpus.String(), result.Status.Capabilities.HostCpus.String()) + } +} + +func TestAddCapabilities_MultipleCells(t *testing.T) { + caps := capabilities.Capabilities{ + Host: capabilities.CapabilitiesHost{ + CPU: capabilities.CapabilitiesHostCPU{ + Arch: "x86_64", + }, + Topology: capabilities.CapabilitiesHostTopology{ + CellSpec: capabilities.CapabilitiesHostTopologyCells{ + Num: 2, + Cells: []capabilities.CapabilitiesHostTopologyCell{ + { + ID: 0, + Memory: capabilities.CapabilitiesHostTopologyCellMemory{ + Unit: "GiB", + Value: 32, + }, + CPUs: capabilities.CapabilitiesHostTopologyCellCPUs{ + Num: 16, + }, + }, + { + ID: 1, + Memory: capabilities.CapabilitiesHostTopologyCellMemory{ + Unit: "GiB", + Value: 32, + }, + CPUs: capabilities.CapabilitiesHostTopologyCellCPUs{ + Num: 16, + }, + }, + }, + }, + }, + }, + } + + l := &LibVirt{ + capabilitiesClient: &mockCapabilitiesClient{caps: caps}, + } + + hv := v1.Hypervisor{} + result, err := l.addCapabilities(hv) + + if err != nil { + t.Fatalf("addCapabilities() returned unexpected error: %v", err) + } + + // Total should be 64 GiB + expectedMemory := resource.NewQuantity(64*1024*1024*1024, resource.BinarySI) + if !result.Status.Capabilities.HostMemory.Equal(*expectedMemory) { + t.Errorf("Expected HostMemory %s, got %s", expectedMemory.String(), result.Status.Capabilities.HostMemory.String()) + } + + // Total should be 32 CPUs + expectedCpus := resource.NewQuantity(32, resource.DecimalSI) + if !result.Status.Capabilities.HostCpus.Equal(*expectedCpus) { + t.Errorf("Expected HostCpus %s, got %s", expectedCpus.String(), result.Status.Capabilities.HostCpus.String()) + } +} + +func TestAddDomainCapabilities_Success(t *testing.T) { + domCaps := domcapabilities.DomainCapabilities{ + Domain: "kvm", + Arch: "x86_64", + CPU: domcapabilities.DomainCapabilitiesCPU{ + Modes: []domcapabilities.DomainCapabilitiesCPUMode{ + { + Name: "host-passthrough", + Supported: "yes", + }, + { + Name: "custom", + Supported: "yes", + Enums: []domcapabilities.DomainCapabilitiesEnum{ + { + Name: "model", + Values: []string{"Skylake-Client", "Broadwell"}, + }, + }, + }, + }, + }, + Devices: domcapabilities.DomainCapabilitiesDevices{ + Devices: []domcapabilities.DomainCapabilitiesDevice{ + { + Supported: "yes", + Enums: []domcapabilities.DomainCapabilitiesEnum{ + { + Name: "type", + Values: []string{"vnc", "spice"}, + }, + }, + }, + }, + }, + Features: domcapabilities.DomainCapabilitiesFeatures{ + Features: []domcapabilities.DomainCapabilitiesFeature{ + { + Supported: "yes", + }, + }, + }, + } + // Set XMLName for device + domCaps.Devices.Devices[0].XMLName.Local = "graphics" + // Set XMLName for feature + domCaps.Features.Features[0].XMLName.Local = "acpi" + + l := &LibVirt{ + domainCapabilitiesClient: &mockDomCapabilitiesClient{caps: domCaps}, + } + + hv := v1.Hypervisor{} + result, err := l.addDomainCapabilities(hv) + + if err != nil { + t.Fatalf("addDomainCapabilities() returned unexpected error: %v", err) + } + + if result.Status.DomainCapabilities.Arch != "x86_64" { + t.Errorf("Expected Arch 'x86_64', got '%s'", result.Status.DomainCapabilities.Arch) + } + + if result.Status.DomainCapabilities.HypervisorType != "kvm" { + t.Errorf("Expected HypervisorType 'kvm', got '%s'", result.Status.DomainCapabilities.HypervisorType) + } + + // Check CPU modes + expectedCpuModes := []string{ + "mode/host-passthrough", + "mode/custom", + "mode/custom/Skylake-Client", + "mode/custom/Broadwell", + } + if len(result.Status.DomainCapabilities.SupportedCpuModes) != len(expectedCpuModes) { + t.Errorf("Expected %d CPU modes, got %d", len(expectedCpuModes), len(result.Status.DomainCapabilities.SupportedCpuModes)) + } + + // Check devices + expectedDevices := []string{ + "graphics", + "graphics/vnc", + "graphics/spice", + } + if len(result.Status.DomainCapabilities.SupportedDevices) != len(expectedDevices) { + t.Errorf("Expected %d devices, got %d", len(expectedDevices), len(result.Status.DomainCapabilities.SupportedDevices)) + } + + // Check features + if len(result.Status.DomainCapabilities.SupportedFeatures) != 1 { + t.Errorf("Expected 1 feature, got %d", len(result.Status.DomainCapabilities.SupportedFeatures)) + } + if result.Status.DomainCapabilities.SupportedFeatures[0] != "acpi" { + t.Errorf("Expected feature 'acpi', got '%s'", result.Status.DomainCapabilities.SupportedFeatures[0]) + } +} + +func TestAddDomainCapabilities_UnsupportedFiltered(t *testing.T) { + domCaps := domcapabilities.DomainCapabilities{ + Domain: "kvm", + Arch: "x86_64", + CPU: domcapabilities.DomainCapabilitiesCPU{ + Modes: []domcapabilities.DomainCapabilitiesCPUMode{ + { + Name: "supported-mode", + Supported: "yes", + }, + { + Name: "unsupported-mode", + Supported: "no", + }, + }, + }, + } + + l := &LibVirt{ + domainCapabilitiesClient: &mockDomCapabilitiesClient{caps: domCaps}, + } + + hv := v1.Hypervisor{} + result, err := l.addDomainCapabilities(hv) + + if err != nil { + t.Fatalf("addDomainCapabilities() returned unexpected error: %v", err) + } + + // Only the supported mode should be included + if len(result.Status.DomainCapabilities.SupportedCpuModes) != 1 { + t.Errorf("Expected 1 supported CPU mode, got %d", len(result.Status.DomainCapabilities.SupportedCpuModes)) + } + + if result.Status.DomainCapabilities.SupportedCpuModes[0] != "mode/supported-mode" { + t.Errorf("Expected 'mode/supported-mode', got '%s'", result.Status.DomainCapabilities.SupportedCpuModes[0]) + } +} + +func TestAddAllocationCapacity_Success(t *testing.T) { + caps := capabilities.Capabilities{ + Host: capabilities.CapabilitiesHost{ + Topology: capabilities.CapabilitiesHostTopology{ + CellSpec: capabilities.CapabilitiesHostTopologyCells{ + Num: 1, + Cells: []capabilities.CapabilitiesHostTopologyCell{ + { + ID: 0, + Memory: capabilities.CapabilitiesHostTopologyCellMemory{ + Unit: "GiB", + Value: 64, + }, + CPUs: capabilities.CapabilitiesHostTopologyCellCPUs{ + Num: 16, + }, + }, + }, + }, + }, + }, + } + + domInfos := []dominfo.DomainInfo{ + { + Name: "test-instance", + Memory: &dominfo.DomainMemory{ + Unit: "GiB", + Value: 8, + }, + CPUTune: &dominfo.DomainCPUTune{ + VCPUPins: []dominfo.DomainVCPUPin{ + {VCPU: 0, CPUSet: "0"}, + {VCPU: 1, CPUSet: "1"}, + }, + }, + NumaTune: &dominfo.DomainNumaTune{ + MemNodes: []dominfo.DomainNumaMemNode{ + {CellID: 0, Mode: "strict", Nodeset: "0"}, + }, + }, + CPU: &dominfo.DomainCPU{ + Numa: &dominfo.DomainCPUNuma{ + Cells: []dominfo.DomainCPUNumaCell{ + {ID: 0, CPUs: "0-1", Memory: 8, Unit: "GiB"}, + }, + }, + }, + }, + } + + l := &LibVirt{ + capabilitiesClient: &mockCapabilitiesClient{caps: caps}, + domainInfoClient: &mockDomInfoClient{infos: domInfos}, + } + + hv := v1.Hypervisor{} + result, err := l.addAllocationCapacity(hv) + + if err != nil { + t.Fatalf("addAllocationCapacity() returned unexpected error: %v", err) + } + + // Check total capacity + expectedMemCapacity := resource.NewQuantity(64*1024*1024*1024, resource.BinarySI) + memCap := result.Status.Capacity["memory"] + if !memCap.Equal(*expectedMemCapacity) { + t.Errorf("Expected memory capacity %s, got %s", + expectedMemCapacity.String(), memCap.String()) + } + + expectedCpuCapacity := resource.NewQuantity(16, resource.DecimalSI) + cpuCap := result.Status.Capacity["cpu"] + if !cpuCap.Equal(*expectedCpuCapacity) { + t.Errorf("Expected CPU capacity %s, got %s", + expectedCpuCapacity.String(), cpuCap.String()) + } + + // Check total allocation + expectedMemAlloc := resource.NewQuantity(8*1024*1024*1024, resource.BinarySI) + memAlloc := result.Status.Allocation["memory"] + if !memAlloc.Equal(*expectedMemAlloc) { + t.Errorf("Expected memory allocation %s, got %s", + expectedMemAlloc.String(), memAlloc.String()) + } + + expectedCpuAlloc := resource.NewQuantity(2, resource.DecimalSI) + cpuAlloc := result.Status.Allocation["cpu"] + if !cpuAlloc.Equal(*expectedCpuAlloc) { + t.Errorf("Expected CPU allocation %s, got %s", + expectedCpuAlloc.String(), cpuAlloc.String()) + } + + // Check cells + if len(result.Status.Cells) != 1 { + t.Fatalf("Expected 1 cell, got %d", len(result.Status.Cells)) + } +} + +func TestProcess_Success(t *testing.T) { + caps := capabilities.Capabilities{ + Host: capabilities.CapabilitiesHost{ + CPU: capabilities.CapabilitiesHostCPU{ + Arch: "x86_64", + }, + Topology: capabilities.CapabilitiesHostTopology{ + CellSpec: capabilities.CapabilitiesHostTopologyCells{ + Num: 1, + Cells: []capabilities.CapabilitiesHostTopologyCell{ + { + ID: 0, + Memory: capabilities.CapabilitiesHostTopologyCellMemory{ + Unit: "GiB", + Value: 16, + }, + CPUs: capabilities.CapabilitiesHostTopologyCellCPUs{ + Num: 4, + }, + }, + }, + }, + }, + }, + } + + domCaps := domcapabilities.DomainCapabilities{ + Domain: "kvm", + Arch: "x86_64", + CPU: domcapabilities.DomainCapabilitiesCPU{ + Modes: []domcapabilities.DomainCapabilitiesCPUMode{}, + }, + } + + l := &LibVirt{ + version: "8.0.0", + domains: make(map[libvirt.ConnectListAllDomainsFlags][]libvirt.Domain), + capabilitiesClient: &mockCapabilitiesClient{caps: caps}, + domainCapabilitiesClient: &mockDomCapabilitiesClient{caps: domCaps}, + domainInfoClient: &mockDomInfoClient{infos: []dominfo.DomainInfo{}}, + } + + hv := v1.Hypervisor{} + result, err := l.Process(hv) + + if err != nil { + t.Fatalf("Process() returned unexpected error: %v", err) + } + + // Verify all processors ran + if result.Status.LibVirtVersion != "8.0.0" { + t.Error("addVersion did not run") + } + if result.Status.Capabilities.HostCpuArch != "x86_64" { + t.Error("addCapabilities did not run") + } + if result.Status.DomainCapabilities.HypervisorType != "kvm" { + t.Error("addDomainCapabilities did not run") + } + if result.Status.Capacity == nil { + t.Error("addAllocationCapacity did not run") + } +} + +func TestProcess_PreservesOriginalOnError(t *testing.T) { + l := &LibVirt{ + version: "8.0.0", + domains: make(map[libvirt.ConnectListAllDomainsFlags][]libvirt.Domain), + capabilitiesClient: &mockCapabilitiesClient{err: &testError{"capability error"}}, + domainCapabilitiesClient: &mockDomCapabilitiesClient{}, + domainInfoClient: &mockDomInfoClient{}, + } + + originalHv := v1.Hypervisor{ + Status: v1.HypervisorStatus{ + NumInstances: 42, + }, + } + + result, err := l.Process(originalHv) + + if err == nil { + t.Fatal("Expected error from Process(), got nil") + } + + // The hypervisor should be returned even on error + // Version should have been added before the error + if result.Status.LibVirtVersion != "8.0.0" { + t.Error("Expected version to be added before error") + } +} + +// testError is a simple error type for testing +type testError struct { + msg string +} + +func (e *testError) Error() string { + return e.msg +} diff --git a/internal/libvirt/utils.go b/internal/libvirt/utils.go index 85a71a8..40aa713 100644 --- a/internal/libvirt/utils.go +++ b/internal/libvirt/utils.go @@ -22,6 +22,7 @@ import ( "fmt" "github.com/digitalocean/go-libvirt" + "k8s.io/apimachinery/pkg/api/resource" ) type UUID [16]byte @@ -57,3 +58,24 @@ func ByteCountIEC(b uint64) string { return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) } + +// Get the cell memory as resource.Quantity. +func MemoryToResource(value int64, unit string) (resource.Quantity, error) { + var quantity *resource.Quantity + // Check the unit + switch unit { + case "KiB": + quantity = resource.NewQuantity(value*1024, resource.BinarySI) + case "MiB": + quantity = resource.NewQuantity(value*1024*1024, resource.BinarySI) + case "GiB": + quantity = resource.NewQuantity(value*1024*1024*1024, resource.BinarySI) + case "TiB": + quantity = resource.NewQuantity(value*1024*1024*1024*1024, resource.BinarySI) + } + if quantity == nil { + return resource.Quantity{}, fmt.Errorf("unknown memory unit %s", unit) + } + // Set the value + return *quantity, nil +} diff --git a/internal/libvirt/utils_test.go b/internal/libvirt/utils_test.go new file mode 100644 index 0000000..20e90a3 --- /dev/null +++ b/internal/libvirt/utils_test.go @@ -0,0 +1,422 @@ +/* +SPDX-FileCopyrightText: Copyright 2025 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package libvirt + +import ( + "testing" + + "k8s.io/apimachinery/pkg/api/resource" +) + +func TestMemoryToResourceKiB(t *testing.T) { + tests := []struct { + name string + value int64 + unit string + expectedBytes int64 + }{ + { + name: "1 KiB", + value: 1, + unit: "KiB", + expectedBytes: 1024, + }, + { + name: "1024 KiB (1 MiB)", + value: 1024, + unit: "KiB", + expectedBytes: 1024 * 1024, + }, + { + name: "Zero KiB", + value: 0, + unit: "KiB", + expectedBytes: 0, + }, + { + name: "Large value KiB", + value: 1048576, // 1 GiB in KiB + unit: "KiB", + expectedBytes: 1024 * 1024 * 1024, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MemoryToResource(tt.value, tt.unit) + if err != nil { + t.Fatalf("MemoryToResource() returned unexpected error: %v", err) + } + + expectedQuantity := resource.NewQuantity(tt.expectedBytes, resource.BinarySI) + if !result.Equal(*expectedQuantity) { + t.Errorf("Expected quantity %s, got %s", expectedQuantity.String(), result.String()) + } + + // Verify the value in bytes + resultBytes, ok := result.AsInt64() + if !ok { + t.Fatal("Failed to convert result to int64") + } + if resultBytes != tt.expectedBytes { + t.Errorf("Expected %d bytes, got %d bytes", tt.expectedBytes, resultBytes) + } + }) + } +} + +func TestMemoryToResourceMiB(t *testing.T) { + tests := []struct { + name string + value int64 + unit string + expectedBytes int64 + }{ + { + name: "1 MiB", + value: 1, + unit: "MiB", + expectedBytes: 1024 * 1024, + }, + { + name: "1024 MiB (1 GiB)", + value: 1024, + unit: "MiB", + expectedBytes: 1024 * 1024 * 1024, + }, + { + name: "Zero MiB", + value: 0, + unit: "MiB", + expectedBytes: 0, + }, + { + name: "Large value MiB", + value: 16384, // 16 GiB in MiB + unit: "MiB", + expectedBytes: 16 * 1024 * 1024 * 1024, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MemoryToResource(tt.value, tt.unit) + if err != nil { + t.Fatalf("MemoryToResource() returned unexpected error: %v", err) + } + + expectedQuantity := resource.NewQuantity(tt.expectedBytes, resource.BinarySI) + if !result.Equal(*expectedQuantity) { + t.Errorf("Expected quantity %s, got %s", expectedQuantity.String(), result.String()) + } + + // Verify the value in bytes + resultBytes, ok := result.AsInt64() + if !ok { + t.Fatal("Failed to convert result to int64") + } + if resultBytes != tt.expectedBytes { + t.Errorf("Expected %d bytes, got %d bytes", tt.expectedBytes, resultBytes) + } + }) + } +} + +func TestMemoryToResourceGiB(t *testing.T) { + tests := []struct { + name string + value int64 + unit string + expectedBytes int64 + }{ + { + name: "1 GiB", + value: 1, + unit: "GiB", + expectedBytes: 1024 * 1024 * 1024, + }, + { + name: "8 GiB", + value: 8, + unit: "GiB", + expectedBytes: 8 * 1024 * 1024 * 1024, + }, + { + name: "Zero GiB", + value: 0, + unit: "GiB", + expectedBytes: 0, + }, + { + name: "Large value GiB", + value: 128, + unit: "GiB", + expectedBytes: 128 * 1024 * 1024 * 1024, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MemoryToResource(tt.value, tt.unit) + if err != nil { + t.Fatalf("MemoryToResource() returned unexpected error: %v", err) + } + + expectedQuantity := resource.NewQuantity(tt.expectedBytes, resource.BinarySI) + if !result.Equal(*expectedQuantity) { + t.Errorf("Expected quantity %s, got %s", expectedQuantity.String(), result.String()) + } + + // Verify the value in bytes + resultBytes, ok := result.AsInt64() + if !ok { + t.Fatal("Failed to convert result to int64") + } + if resultBytes != tt.expectedBytes { + t.Errorf("Expected %d bytes, got %d bytes", tt.expectedBytes, resultBytes) + } + }) + } +} + +func TestMemoryToResourceTiB(t *testing.T) { + tests := []struct { + name string + value int64 + unit string + expectedBytes int64 + }{ + { + name: "1 TiB", + value: 1, + unit: "TiB", + expectedBytes: 1024 * 1024 * 1024 * 1024, + }, + { + name: "2 TiB", + value: 2, + unit: "TiB", + expectedBytes: 2 * 1024 * 1024 * 1024 * 1024, + }, + { + name: "Zero TiB", + value: 0, + unit: "TiB", + expectedBytes: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MemoryToResource(tt.value, tt.unit) + if err != nil { + t.Fatalf("MemoryToResource() returned unexpected error: %v", err) + } + + expectedQuantity := resource.NewQuantity(tt.expectedBytes, resource.BinarySI) + if !result.Equal(*expectedQuantity) { + t.Errorf("Expected quantity %s, got %s", expectedQuantity.String(), result.String()) + } + + // Verify the value in bytes + resultBytes, ok := result.AsInt64() + if !ok { + t.Fatal("Failed to convert result to int64") + } + if resultBytes != tt.expectedBytes { + t.Errorf("Expected %d bytes, got %d bytes", tt.expectedBytes, resultBytes) + } + }) + } +} + +func TestMemoryToResourceInvalidUnit(t *testing.T) { + tests := []struct { + name string + value int64 + unit string + }{ + { + name: "Invalid unit KB", + value: 1024, + unit: "KB", + }, + { + name: "Invalid unit MB", + value: 1024, + unit: "MB", + }, + { + name: "Invalid unit GB", + value: 1024, + unit: "GB", + }, + { + name: "Invalid unit TB", + value: 1024, + unit: "TB", + }, + { + name: "Invalid unit bytes", + value: 1024, + unit: "bytes", + }, + { + name: "Empty unit", + value: 1024, + unit: "", + }, + { + name: "Random string", + value: 1024, + unit: "invalid", + }, + { + name: "Case sensitive - kib", + value: 1024, + unit: "kib", + }, + { + name: "Case sensitive - mib", + value: 1024, + unit: "mib", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MemoryToResource(tt.value, tt.unit) + if err == nil { + t.Errorf("Expected error for invalid unit '%s', but got result: %s", tt.unit, result.String()) + } + + expectedError := "unknown memory unit " + tt.unit + if err.Error() != expectedError { + t.Errorf("Expected error message '%s', got '%s'", expectedError, err.Error()) + } + + // Verify the result is an empty Quantity + if !result.IsZero() { + t.Errorf("Expected zero quantity for error case, got %s", result.String()) + } + }) + } +} + +func TestMemoryToResourceBinaryFormat(t *testing.T) { + // Verify that the returned quantities use BinarySI format + result, err := MemoryToResource(1, "GiB") + if err != nil { + t.Fatalf("MemoryToResource() returned unexpected error: %v", err) + } + + // BinarySI should format as "1Gi" not "1073741824" + resultString := result.String() + if resultString != "1Gi" { + t.Errorf("Expected BinarySI format '1Gi', got '%s'", resultString) + } +} + +func TestMemoryToResourceRealWorldScenarios(t *testing.T) { + tests := []struct { + name string + value int64 + unit string + expectedStr string + description string + }{ + { + name: "Typical VM memory - 8GB", + value: 8192, + unit: "MiB", + expectedStr: "8Gi", + description: "8GB RAM for a typical VM", + }, + { + name: "Large VM memory - 64GB", + value: 64, + unit: "GiB", + expectedStr: "64Gi", + description: "64GB RAM for a large VM", + }, + { + name: "Memory from example XML - ~24GB", + value: 25149440, + unit: "KiB", + expectedStr: "24560Mi", + description: "Memory value from the example domain XML", + }, + { + name: "Small container memory - 512MB", + value: 524288, + unit: "KiB", + expectedStr: "512Mi", + description: "512MB memory for a small container", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := MemoryToResource(tt.value, tt.unit) + if err != nil { + t.Fatalf("MemoryToResource() returned unexpected error: %v", err) + } + + resultString := result.String() + if resultString != tt.expectedStr { + t.Errorf("Expected '%s', got '%s' for %s", tt.expectedStr, resultString, tt.description) + } + }) + } +} + +func TestMemoryToResourceNegativeValues(t *testing.T) { + // Test behavior with negative values (edge case) + tests := []struct { + name string + value int64 + unit string + }{ + { + name: "Negative KiB", + value: -1024, + unit: "KiB", + }, + { + name: "Negative MiB", + value: -512, + unit: "MiB", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // The function doesn't explicitly check for negative values, + // so it will create a negative quantity + result, err := MemoryToResource(tt.value, tt.unit) + if err != nil { + t.Fatalf("MemoryToResource() returned unexpected error: %v", err) + } + + // Verify it's negative + if result.Sign() >= 0 { + t.Errorf("Expected negative quantity for negative input, got %s", result.String()) + } + }) + } +}