From a57e19fc91fb8fe0f30d2f9f8880b01faf22aa1c Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Tue, 2 Jun 2026 13:49:09 +0200 Subject: [PATCH 1/5] feat: Add KVM HANA stacking KPI --- .../cortex-nova/templates/kpis_kvm.yaml | 13 + .../infrastructure/kvm_hana_stacking.go | 117 +++++ .../infrastructure/kvm_hana_stacking_test.go | 413 ++++++++++++++++++ internal/knowledge/kpis/supported_kpis.go | 1 + 4 files changed, 544 insertions(+) create mode 100644 internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking.go create mode 100644 internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go diff --git a/helm/bundles/cortex-nova/templates/kpis_kvm.yaml b/helm/bundles/cortex-nova/templates/kpis_kvm.yaml index 48b9eb155..a1915566b 100644 --- a/helm/bundles/cortex-nova/templates/kpis_kvm.yaml +++ b/helm/bundles/cortex-nova/templates/kpis_kvm.yaml @@ -26,4 +26,17 @@ spec: - name: nova-servers - name: nova-flavors - name: identity-projects +--- +apiVersion: cortex.cloud/v1alpha1 +kind: KPI +metadata: + name: kvm-hana-stacking +spec: + schedulingDomain: nova + impl: kvm_hana_stacking_kpi + dependencies: + datasources: + - name: nova-servers + - name: nova-flavors + - name: identity-projects {{- end }} \ No newline at end of file diff --git a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking.go b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking.go new file mode 100644 index 000000000..4db427916 --- /dev/null +++ b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking.go @@ -0,0 +1,117 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package infrastructure + +import ( + "context" + "log/slog" + + "github.com/cobaltcore-dev/cortex/internal/knowledge/datasources/plugins/openstack/identity" + "github.com/cobaltcore-dev/cortex/internal/knowledge/datasources/plugins/openstack/nova" + "github.com/cobaltcore-dev/cortex/internal/knowledge/db" + "github.com/cobaltcore-dev/cortex/internal/knowledge/kpis/plugins" + "github.com/cobaltcore-dev/cortex/pkg/conf" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "github.com/prometheus/client_golang/prometheus" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const hanaKVMFlavorPattern = "hana_k_%" + +type kvmHanaStackingRow struct { + ProjectID string `db:"project_id"` + ProjectName string `db:"project_name"` + DomainID string `db:"domain_id"` + DomainName string `db:"domain_name"` + ComputeHost string `db:"compute_host"` + TotalRAMMB float64 `db:"total_ram_mb"` +} + +type KVMHanaStackingKPI struct { + plugins.BaseKPI[struct{}] + ramPerProjectAndHost *prometheus.Desc +} + +func (k *KVMHanaStackingKPI) GetName() string { + return "kvm_hana_stacking_kpi" +} + +func (k *KVMHanaStackingKPI) Init(dbConn *db.DB, c client.Client, opts conf.RawOpts) error { + if err := k.BaseKPI.Init(dbConn, c, opts); err != nil { + return err + } + k.ramPerProjectAndHost = prometheus.NewDesc( + "cortex_kvm_hana_stacking_ram_bytes", + "Total RAM in bytes used by HANA instances of a project on a KVM hypervisor.", + append(kvmHostLabels, "project_id", "project_name", "domain_id", "domain_name"), nil, + ) + return nil +} + +func (k *KVMHanaStackingKPI) Describe(ch chan<- *prometheus.Desc) { + ch <- k.ramPerProjectAndHost +} + +func (k *KVMHanaStackingKPI) Collect(ch chan<- prometheus.Metric) { + hosts, err := k.getKVMHosts() + if err != nil { + slog.Error("kvm_hana_stacking: failed to get KVM hosts", "error", err) + return + } + + rows, err := k.queryHanaStacking() + if err != nil { + slog.Error("kvm_hana_stacking: failed to query HANA stacking", "error", err) + return + } + + for _, row := range rows { + host, ok := hosts[row.ComputeHost] + if !ok { + slog.Warn("kvm_hana_stacking: compute host not found", "compute_host", row.ComputeHost) + continue + } + hostLabels := host.getHostLabels() + hostLabels = append(hostLabels, row.ProjectID, row.ProjectName, row.DomainID, row.DomainName) + ch <- prometheus.MustNewConstMetric(k.ramPerProjectAndHost, prometheus.GaugeValue, row.TotalRAMMB*1024*1024, hostLabels...) + } +} + +func (k *KVMHanaStackingKPI) getKVMHosts() (map[string]kvmHost, error) { + hvs := &hv1.HypervisorList{} + if err := k.Client.List(context.Background(), hvs); err != nil { + return nil, err + } + hosts := make(map[string]kvmHost, len(hvs.Items)) + for _, hv := range hvs.Items { + host := kvmHost{Hypervisor: hv} + hosts[host.Name] = host + } + return hosts, nil +} + +func (k *KVMHanaStackingKPI) queryHanaStacking() ([]kvmHanaStackingRow, error) { + query := ` + SELECT + s.tenant_id AS project_id, + COALESCE(p.name, '') AS project_name, + COALESCE(p.domain_id, '') AS domain_id, + COALESCE(d.name, '') AS domain_name, + s.os_ext_srv_attr_host AS compute_host, + COALESCE(SUM(f.ram), 0) AS total_ram_mb + FROM ` + nova.Server{}.TableName() + ` s + LEFT JOIN ` + nova.Flavor{}.TableName() + ` f ON s.flavor_name = f.name + LEFT JOIN ` + identity.Project{}.TableName() + ` p ON p.id = s.tenant_id + LEFT JOIN ` + identity.Domain{}.TableName() + ` d ON d.id = p.domain_id + WHERE s.status NOT IN ('DELETED', 'ERROR') + AND s.os_ext_srv_attr_host LIKE '` + kvmComputeHostPattern + `' + AND s.flavor_name LIKE '` + hanaKVMFlavorPattern + `' + GROUP BY s.tenant_id, p.name, p.domain_id, d.name, s.os_ext_srv_attr_host + ` + var rows []kvmHanaStackingRow + if _, err := k.DB.Select(&rows, query); err != nil { + return nil, err + } + return rows, nil +} diff --git a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go new file mode 100644 index 000000000..7d6ed930c --- /dev/null +++ b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go @@ -0,0 +1,413 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package infrastructure + +import ( + "reflect" + "testing" + + "github.com/cobaltcore-dev/cortex/internal/knowledge/datasources/plugins/openstack/identity" + "github.com/cobaltcore-dev/cortex/internal/knowledge/datasources/plugins/openstack/nova" + "github.com/cobaltcore-dev/cortex/internal/knowledge/db" + testlibDB "github.com/cobaltcore-dev/cortex/internal/knowledge/db/testing" + "github.com/cobaltcore-dev/cortex/pkg/conf" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "github.com/prometheus/client_golang/prometheus" + prometheusgo "github.com/prometheus/client_model/go" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func hanaStackingMetric(computeHost, az, projectID, projectName, domainID, domainName string, value float64) collectedKVMMetric { + labels := mockKVMHostLabels(computeHost, az) + labels["project_id"] = projectID + labels["project_name"] = projectName + labels["domain_id"] = domainID + labels["domain_name"] = domainName + return collectedKVMMetric{Name: "cortex_kvm_hana_stacking_ram_bytes", Labels: labels, Value: value} +} + +func setupHanaStackingDB(t *testing.T, servers []nova.Server, projects []identity.Project, domains []identity.Domain, flavors []nova.Flavor) db.DB { + t.Helper() + dbEnv := testlibDB.SetupDBEnv(t) + testDB := db.DB{DbMap: dbEnv.DbMap} + t.Cleanup(dbEnv.Close) + + if err := testDB.CreateTable( + testDB.AddTable(nova.Server{}), + testDB.AddTable(identity.Project{}), + testDB.AddTable(identity.Domain{}), + testDB.AddTable(nova.Flavor{}), + ); err != nil { + t.Fatalf("failed to create tables: %v", err) + } + + var mockData []any + for i := range servers { + mockData = append(mockData, &servers[i]) + } + for i := range projects { + mockData = append(mockData, &projects[i]) + } + for i := range domains { + mockData = append(mockData, &domains[i]) + } + for i := range flavors { + mockData = append(mockData, &flavors[i]) + } + if len(mockData) > 0 { + if err := testDB.Insert(mockData...); err != nil { + t.Fatalf("expected no error inserting data, got %v", err) + } + } + return testDB +} + +func TestKVMHanaStackingKPI_Init(t *testing.T) { + dbEnv := testlibDB.SetupDBEnv(t) + testDB := db.DB{DbMap: dbEnv.DbMap} + defer dbEnv.Close() + kpi := &KVMHanaStackingKPI{} + if err := kpi.Init(&testDB, nil, conf.NewRawOpts("{}")); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestKVMHanaStackingKPI_queryHanaStacking(t *testing.T) { + tests := []struct { + name string + servers []nova.Server + projects []identity.Project + domains []identity.Domain + flavors []nova.Flavor + expected map[string]kvmHanaStackingRow + }{ + { + name: "single HANA instance", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + }, + }, + { + name: "multiple HANA instances same project and host are aggregated", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + {ID: "s2", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_large", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{ + {ID: "f1", Name: "hana_k_medium", RAM: 1638400}, + {ID: "f2", Name: "hana_k_large", RAM: 3276800}, + }, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 4915200}, + }, + }, + { + name: "multiple projects on different hosts", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + {ID: "s2", TenantID: "project-2", OSEXTSRVATTRHost: "node002-bb02", FlavorName: "hana_k_large", Status: "ACTIVE"}, + }, + projects: []identity.Project{ + {ID: "project-1", Name: "Project One", DomainID: "domain-1"}, + {ID: "project-2", Name: "Project Two", DomainID: "domain-1"}, + }, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{ + {ID: "f1", Name: "hana_k_medium", RAM: 1638400}, + {ID: "f2", Name: "hana_k_large", RAM: 3276800}, + }, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + "project-2|node002-bb02": {ProjectID: "project-2", ProjectName: "Project Two", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node002-bb02", TotalRAMMB: 3276800}, + }, + }, + { + name: "non-HANA flavor instances are excluded", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + {ID: "s2", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "m1_k_large", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{ + {ID: "f1", Name: "hana_k_medium", RAM: 1638400}, + {ID: "f2", Name: "m1_k_large", RAM: 65536}, + }, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + }, + }, + { + name: "non-KVM host instances are excluded", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + {ID: "s2", TenantID: "project-1", OSEXTSRVATTRHost: "nova-compute-1", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + }, + }, + { + name: "DELETED and ERROR instances are excluded", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "DELETED"}, + {ID: "s2", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ERROR"}, + {ID: "s3", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + }, + }, + { + name: "no instances returns empty result", + servers: []nova.Server{}, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + expected: map[string]kvmHanaStackingRow{}, + }, + { + name: "missing flavor entry results in zero RAM", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_unknown", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{}, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 0}, + }, + }, + { + name: "missing project entry results in empty strings", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{}, + domains: []identity.Domain{}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "", DomainID: "", DomainName: "", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + }, + }, + { + name: "project with unknown domain results in empty domain name", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-unknown"}}, + domains: []identity.Domain{}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + expected: map[string]kvmHanaStackingRow{ + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-unknown", DomainName: "", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testDB := setupHanaStackingDB(t, tt.servers, tt.projects, tt.domains, tt.flavors) + + kpi := &KVMHanaStackingKPI{} + if err := kpi.Init(&testDB, buildKVMHypervisorClient(t, []hv1.Hypervisor{}).Build(), conf.NewRawOpts("{}")); err != nil { + t.Fatalf("expected no error on Init, got %v", err) + } + + rows, err := kpi.queryHanaStacking() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(rows) != len(tt.expected) { + t.Fatalf("expected %d rows, got %d", len(tt.expected), len(rows)) + } + for _, got := range rows { + key := got.ProjectID + "|" + got.ComputeHost + exp, ok := tt.expected[key] + if !ok { + t.Errorf("unexpected row for key %q: %+v", key, got) + continue + } + if got != exp { + t.Errorf("row mismatch for key %q: expected %+v, got %+v", key, exp, got) + } + } + }) + } +} + +func TestKVMHanaStackingKPI_Collect(t *testing.T) { + tests := []struct { + name string + servers []nova.Server + projects []identity.Project + domains []identity.Domain + flavors []nova.Flavor + hypervisors []hv1.Hypervisor + expectedMetrics []collectedKVMMetric + }{ + { + name: "single HANA instance produces one RAM metric", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + hypervisors: []hv1.Hypervisor{ + {ObjectMeta: metav1.ObjectMeta{Name: "node001-bb01", Labels: map[string]string{"topology.kubernetes.io/zone": "az1"}}}, + }, + expectedMetrics: []collectedKVMMetric{ + hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-1", "Domain One", 1638400*1024*1024), + }, + }, + { + name: "compute_host not in hypervisor list produces no metric", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + hypervisors: []hv1.Hypervisor{}, + expectedMetrics: []collectedKVMMetric{}, + }, + { + name: "only HANA flavors are counted, non-HANA on same host excluded", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + {ID: "s2", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "m1_k_large", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{ + {ID: "f1", Name: "hana_k_medium", RAM: 1638400}, + {ID: "f2", Name: "m1_k_large", RAM: 65536}, + }, + hypervisors: []hv1.Hypervisor{ + {ObjectMeta: metav1.ObjectMeta{Name: "node001-bb01", Labels: map[string]string{"topology.kubernetes.io/zone": "az1"}}}, + }, + expectedMetrics: []collectedKVMMetric{ + hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-1", "Domain One", 1638400*1024*1024), + }, + }, + { + name: "DELETED and ERROR instances are excluded", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "DELETED"}, + {ID: "s2", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ERROR"}, + {ID: "s3", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + }, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + hypervisors: []hv1.Hypervisor{ + {ObjectMeta: metav1.ObjectMeta{Name: "node001-bb01", Labels: map[string]string{"topology.kubernetes.io/zone": "az1"}}}, + }, + expectedMetrics: []collectedKVMMetric{ + hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-1", "Domain One", 1638400*1024*1024), + }, + }, + { + name: "multiple projects on multiple hosts", + servers: []nova.Server{ + {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + {ID: "s2", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, + {ID: "s3", TenantID: "project-2", OSEXTSRVATTRHost: "node002-bb02", FlavorName: "hana_k_large", Status: "ACTIVE"}, + }, + projects: []identity.Project{ + {ID: "project-1", Name: "Project One", DomainID: "domain-1"}, + {ID: "project-2", Name: "Project Two", DomainID: "domain-1"}, + }, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{ + {ID: "f1", Name: "hana_k_medium", RAM: 1638400}, + {ID: "f2", Name: "hana_k_large", RAM: 3276800}, + }, + hypervisors: []hv1.Hypervisor{ + {ObjectMeta: metav1.ObjectMeta{Name: "node001-bb01", Labels: map[string]string{"topology.kubernetes.io/zone": "az1"}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "node002-bb02", Labels: map[string]string{"topology.kubernetes.io/zone": "az2"}}}, + }, + expectedMetrics: []collectedKVMMetric{ + hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-1", "Domain One", 2*1638400*1024*1024), + hanaStackingMetric("node002-bb02", "az2", "project-2", "Project Two", "domain-1", "Domain One", 3276800*1024*1024), + }, + }, + { + name: "no instances produces no metrics", + servers: []nova.Server{}, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, + domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, + hypervisors: []hv1.Hypervisor{{ObjectMeta: metav1.ObjectMeta{Name: "node001-bb01"}}}, + expectedMetrics: []collectedKVMMetric{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testDB := setupHanaStackingDB(t, tt.servers, tt.projects, tt.domains, tt.flavors) + + client := buildKVMHypervisorClient(t, tt.hypervisors) + kpi := &KVMHanaStackingKPI{} + if err := kpi.Init(&testDB, client.Build(), conf.NewRawOpts("{}")); err != nil { + t.Fatalf("expected no error on Init, got %v", err) + } + + ch := make(chan prometheus.Metric, 100) + kpi.Collect(ch) + close(ch) + + actual := make(map[string]collectedKVMMetric) + for m := range ch { + var pm prometheusgo.Metric + if err := m.Write(&pm); err != nil { + t.Fatalf("failed to write metric: %v", err) + } + labels := make(map[string]string) + for _, lbl := range pm.Label { + labels[lbl.GetName()] = lbl.GetValue() + } + name := getMetricName(m.Desc().String()) + key := name + "|" + labels["compute_host"] + "|" + labels["project_id"] + if _, exists := actual[key]; exists { + t.Fatalf("duplicate metric key %q", key) + } + actual[key] = collectedKVMMetric{Name: name, Labels: labels, Value: pm.GetGauge().GetValue()} + } + + if len(actual) != len(tt.expectedMetrics) { + t.Errorf("expected %d metrics, got %d: actual=%v", len(tt.expectedMetrics), len(actual), actual) + } + for _, exp := range tt.expectedMetrics { + key := exp.Name + "|" + exp.Labels["compute_host"] + "|" + exp.Labels["project_id"] + got, ok := actual[key] + if !ok { + t.Errorf("missing metric %q", key) + continue + } + if got.Value != exp.Value { + t.Errorf("metric %q value: expected %v, got %v", key, exp.Value, got.Value) + } + if !reflect.DeepEqual(exp.Labels, got.Labels) { + t.Errorf("metric %q labels: expected %v, got %v", key, exp.Labels, got.Labels) + } + } + }) + } +} diff --git a/internal/knowledge/kpis/supported_kpis.go b/internal/knowledge/kpis/supported_kpis.go index cfcf56bd3..155e3aab9 100644 --- a/internal/knowledge/kpis/supported_kpis.go +++ b/internal/knowledge/kpis/supported_kpis.go @@ -22,6 +22,7 @@ var supportedKPIs = map[string]plugins.KPI{ "kvm_host_capacity_kpi": &infrastructure.KVMHostCapacityKPI{}, "kvm_project_utilization_kpi": &infrastructure.KVMProjectUtilizationKPI{}, + "kvm_hana_stacking_kpi": &infrastructure.KVMHanaStackingKPI{}, "vmware_project_utilization_kpi": &infrastructure.VMwareProjectUtilizationKPI{}, "vmware_project_commitments_kpi": &infrastructure.VMwareProjectCommitmentsKPI{}, "vmware_host_capacity_kpi": &infrastructure.VMwareHostCapacityKPI{}, From 78f0e7194777f4a73fbdecb9174735dce209be11 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Tue, 2 Jun 2026 13:52:36 +0200 Subject: [PATCH 2/5] lint --- .../kpis/plugins/infrastructure/kvm_hana_stacking_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go index 7d6ed930c..3b5c4cb4e 100644 --- a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go +++ b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go @@ -87,11 +87,11 @@ func TestKVMHanaStackingKPI_queryHanaStacking(t *testing.T) { servers: []nova.Server{ {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, }, - projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, - domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-0"}}, + domains: []identity.Domain{{ID: "domain-0", Name: "Domain One"}}, flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, expected: map[string]kvmHanaStackingRow{ - "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-1", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, + "project-1|node001-bb01": {ProjectID: "project-1", ProjectName: "Project One", DomainID: "domain-0", DomainName: "Domain One", ComputeHost: "node001-bb01", TotalRAMMB: 1638400}, }, }, { From f339bb269a4591f5162c1bb53dd14b2f1b8a98b0 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Tue, 2 Jun 2026 14:24:08 +0200 Subject: [PATCH 3/5] fix: Add missing identity-domains datasource to kvm-hana-stacking KPI --- helm/bundles/cortex-nova/templates/kpis_kvm.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helm/bundles/cortex-nova/templates/kpis_kvm.yaml b/helm/bundles/cortex-nova/templates/kpis_kvm.yaml index a1915566b..10ff5c45f 100644 --- a/helm/bundles/cortex-nova/templates/kpis_kvm.yaml +++ b/helm/bundles/cortex-nova/templates/kpis_kvm.yaml @@ -26,6 +26,7 @@ spec: - name: nova-servers - name: nova-flavors - name: identity-projects + - name: identity-domains --- apiVersion: cortex.cloud/v1alpha1 kind: KPI @@ -39,4 +40,5 @@ spec: - name: nova-servers - name: nova-flavors - name: identity-projects + - name: identity-domains {{- end }} \ No newline at end of file From 4ee5d620df30a24bc54d116baf8b8598b7e8e537 Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Tue, 2 Jun 2026 14:24:57 +0200 Subject: [PATCH 4/5] fix: Update domain ID in KVM Hana stacking KPI test case --- .../kpis/plugins/infrastructure/kvm_hana_stacking_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go index 3b5c4cb4e..9e1441c9d 100644 --- a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go +++ b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go @@ -266,14 +266,14 @@ func TestKVMHanaStackingKPI_Collect(t *testing.T) { servers: []nova.Server{ {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, }, - projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-1"}}, - domains: []identity.Domain{{ID: "domain-1", Name: "Domain One"}}, + projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-0"}}, + domains: []identity.Domain{{ID: "domain-0", Name: "Domain One"}}, flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, hypervisors: []hv1.Hypervisor{ {ObjectMeta: metav1.ObjectMeta{Name: "node001-bb01", Labels: map[string]string{"topology.kubernetes.io/zone": "az1"}}}, }, expectedMetrics: []collectedKVMMetric{ - hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-1", "Domain One", 1638400*1024*1024), + hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-0", "Domain One", 1638400*1024*1024), }, }, { From 5eeae8db740bf8cb846c58333120dc1ef8a7a7ea Mon Sep 17 00:00:00 2001 From: Markus Wieland Date: Tue, 2 Jun 2026 14:28:14 +0200 Subject: [PATCH 5/5] fix: Update domain name in KVM Hana stacking KPI test case --- .../kpis/plugins/infrastructure/kvm_hana_stacking_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go index 9e1441c9d..dc7892e21 100644 --- a/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go +++ b/internal/knowledge/kpis/plugins/infrastructure/kvm_hana_stacking_test.go @@ -267,13 +267,13 @@ func TestKVMHanaStackingKPI_Collect(t *testing.T) { {ID: "s1", TenantID: "project-1", OSEXTSRVATTRHost: "node001-bb01", FlavorName: "hana_k_medium", Status: "ACTIVE"}, }, projects: []identity.Project{{ID: "project-1", Name: "Project One", DomainID: "domain-0"}}, - domains: []identity.Domain{{ID: "domain-0", Name: "Domain One"}}, + domains: []identity.Domain{{ID: "domain-0", Name: "Domain Zero"}}, flavors: []nova.Flavor{{ID: "f1", Name: "hana_k_medium", RAM: 1638400}}, hypervisors: []hv1.Hypervisor{ {ObjectMeta: metav1.ObjectMeta{Name: "node001-bb01", Labels: map[string]string{"topology.kubernetes.io/zone": "az1"}}}, }, expectedMetrics: []collectedKVMMetric{ - hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-0", "Domain One", 1638400*1024*1024), + hanaStackingMetric("node001-bb01", "az1", "project-1", "Project One", "domain-0", "Domain Zero", 1638400*1024*1024), }, }, {