diff --git a/cmd/main.go b/cmd/main.go index 52eebbadc..7a175105d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -324,10 +324,6 @@ func main() { } nova.NewAPI(filterWeigherController).Init(mux) - // Initialize commitments API for LIQUID interface - commitmentsAPI := commitments.NewAPI(multiclusterClient) - commitmentsAPI.Init(mux, metrics.Registry) - // Detector pipeline controller setup. novaClient := nova.NewNovaClient() novaClientConfig := conf.GetConfigOrDie[nova.NovaClientConfig]() @@ -337,6 +333,12 @@ func main() { setupLog.Error(err, "unable to initialize nova client") os.Exit(1) } + + // Initialize commitments API for LIQUID interface (with Nova client for usage reporting) + commitmentsConfig := conf.GetConfigOrDie[commitments.Config]() + commitmentsAPI := commitments.NewAPIWithConfig(multiclusterClient, commitmentsConfig, novaClient) + commitmentsAPI.Init(mux, metrics.Registry) + deschedulingsController := &nova.DetectorPipelineController{ Monitor: detectorPipelineMonitor, Breaker: &nova.DetectorCycleBreaker{NovaClient: novaClient}, diff --git a/helm/bundles/cortex-nova/alerts/nova.alerts.yaml b/helm/bundles/cortex-nova/alerts/nova.alerts.yaml index 675c5a27a..9b80e079b 100644 --- a/helm/bundles/cortex-nova/alerts/nova.alerts.yaml +++ b/helm/bundles/cortex-nova/alerts/nova.alerts.yaml @@ -368,3 +368,100 @@ groups: to be scheduled. Affected commitment changes are rolled back and Limes will see them as failed. Consider investigating the scheduler performance or increasing the timeout configuration. + + # Committed Resource Usage API Alerts + - alert: CortexNovaCommittedResourceUsageHttpRequest400sTooHigh + expr: rate(cortex_committed_resource_usage_api_requests_total{service="cortex-nova-metrics", status_code=~"4.."}[5m]) > 0.1 + for: 5m + labels: + context: committed-resource-api + dashboard: cortex/cortex + service: cortex + severity: warning + support_group: workload-management + annotations: + summary: "Committed Resource usage API HTTP 400 errors too high" + description: > + The committed resource usage API (Limes LIQUID integration) is responding + with HTTP 4xx errors. This may indicate invalid project IDs or malformed + requests from Limes. Limes will typically retry these requests. + + - alert: CortexNovaCommittedResourceUsageHttpRequest500sTooHigh + expr: rate(cortex_committed_resource_usage_api_requests_total{service="cortex-nova-metrics", status_code=~"5.."}[5m]) > 0.1 + for: 5m + labels: + context: committed-resource-api + dashboard: cortex/cortex + service: cortex + severity: warning + support_group: workload-management + annotations: + summary: "Committed Resource usage API HTTP 500 errors too high" + description: > + The committed resource usage API (Limes LIQUID integration) is responding + with HTTP 5xx errors. This indicates internal problems fetching reservations + or Nova server data. Limes may receive stale or incomplete usage data. + + - alert: CortexNovaCommittedResourceUsageLatencyTooHigh + expr: histogram_quantile(0.95, sum(rate(cortex_committed_resource_usage_api_request_duration_seconds_bucket{service="cortex-nova-metrics"}[5m])) by (le)) > 5 + for: 5m + labels: + context: committed-resource-api + dashboard: cortex/cortex + service: cortex + severity: warning + support_group: workload-management + annotations: + summary: "Committed Resource usage API latency too high" + description: > + The committed resource usage API (Limes LIQUID integration) is experiencing + high latency (p95 > 5s). This may indicate slow Nova API responses or + database queries. Limes scrapes may time out, affecting quota reporting. + + # Committed Resource Capacity API Alerts + - alert: CortexNovaCommittedResourceCapacityHttpRequest400sTooHigh + expr: rate(cortex_committed_resource_capacity_api_requests_total{service="cortex-nova-metrics", status_code=~"4.."}[5m]) > 0.1 + for: 5m + labels: + context: committed-resource-api + dashboard: cortex/cortex + service: cortex + severity: warning + support_group: workload-management + annotations: + summary: "Committed Resource capacity API HTTP 400 errors too high" + description: > + The committed resource capacity API (Limes LIQUID integration) is responding + with HTTP 4xx errors. This may indicate malformed requests from Limes. + + - alert: CortexNovaCommittedResourceCapacityHttpRequest500sTooHigh + expr: rate(cortex_committed_resource_capacity_api_requests_total{service="cortex-nova-metrics", status_code=~"5.."}[5m]) > 0.1 + for: 5m + labels: + context: committed-resource-api + dashboard: cortex/cortex + service: cortex + severity: warning + support_group: workload-management + annotations: + summary: "Committed Resource capacity API HTTP 500 errors too high" + description: > + The committed resource capacity API (Limes LIQUID integration) is responding + with HTTP 5xx errors. This indicates internal problems calculating cluster + capacity. Limes may receive stale or incomplete capacity data. + + - alert: CortexNovaCommittedResourceCapacityLatencyTooHigh + expr: histogram_quantile(0.95, sum(rate(cortex_committed_resource_capacity_api_request_duration_seconds_bucket{service="cortex-nova-metrics"}[5m])) by (le)) > 5 + for: 5m + labels: + context: committed-resource-api + dashboard: cortex/cortex + service: cortex + severity: warning + support_group: workload-management + annotations: + summary: "Committed Resource capacity API latency too high" + description: > + The committed resource capacity API (Limes LIQUID integration) is experiencing + high latency (p95 > 5s). This may indicate slow database queries or knowledge + CRD retrieval. Limes scrapes may time out, affecting capacity reporting. diff --git a/internal/scheduling/nova/deschedulings_executor_test.go b/internal/scheduling/nova/deschedulings_executor_test.go index d7bf0e739..95429d182 100644 --- a/internal/scheduling/nova/deschedulings_executor_test.go +++ b/internal/scheduling/nova/deschedulings_executor_test.go @@ -72,6 +72,10 @@ func (m *mockExecutorNovaClient) GetServerMigrations(ctx context.Context, id str return []migration{}, nil } +func (m *mockExecutorNovaClient) ListProjectServers(ctx context.Context, projectID string) ([]ServerDetail, error) { + return []ServerDetail{}, nil +} + func TestExecutor_Reconcile(t *testing.T) { scheme := runtime.NewScheme() err := v1alpha1.AddToScheme(scheme) diff --git a/internal/scheduling/nova/detector_cycle_breaker_test.go b/internal/scheduling/nova/detector_cycle_breaker_test.go index 0c7e8d1ef..492027fe6 100644 --- a/internal/scheduling/nova/detector_cycle_breaker_test.go +++ b/internal/scheduling/nova/detector_cycle_breaker_test.go @@ -39,6 +39,10 @@ func (m *mockDetectorCycleBreakerNovaClient) GetServerMigrations(ctx context.Con return []migration{}, nil } +func (m *mockDetectorCycleBreakerNovaClient) ListProjectServers(ctx context.Context, projectID string) ([]ServerDetail, error) { + return []ServerDetail{}, nil +} + func TestDetectorCycleBreaker_Filter(t *testing.T) { tests := []struct { name string diff --git a/internal/scheduling/nova/nova_client.go b/internal/scheduling/nova/nova_client.go index b30becb3f..321ff0b70 100644 --- a/internal/scheduling/nova/nova_client.go +++ b/internal/scheduling/nova/nova_client.go @@ -38,6 +38,21 @@ type migration struct { DestCompute string `json:"dest_compute"` } +// ServerDetail contains extended server information for usage reporting. +type ServerDetail struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + TenantID string `json:"tenant_id"` + Created string `json:"created"` + AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"` + Hypervisor string `json:"OS-EXT-SRV-ATTR:hypervisor_hostname"` + FlavorName string // Populated from nested flavor.original_name + FlavorRAM uint64 // Populated from nested flavor.ram + FlavorVCPUs uint64 // Populated from nested flavor.vcpus + FlavorDisk uint64 // Populated from nested flavor.disk +} + type NovaClient interface { // Initialize the Nova API with the Keystone authentication. Init(ctx context.Context, client client.Client, conf NovaClientConfig) error @@ -47,6 +62,8 @@ type NovaClient interface { LiveMigrate(ctx context.Context, id string) error // Get migrations for a server by ID. GetServerMigrations(ctx context.Context, id string) ([]migration, error) + // List all servers for a project with detailed info. + ListProjectServers(ctx context.Context, projectID string) ([]ServerDetail, error) } type novaClient struct { @@ -160,3 +177,86 @@ func (api *novaClient) GetServerMigrations(ctx context.Context, id string) ([]mi slog.Info("fetched migrations for vm", "id", id, "count", len(migrations)) return migrations, nil } + +// ListProjectServers retrieves all servers for a project with detailed info. +func (api *novaClient) ListProjectServers(ctx context.Context, projectID string) ([]ServerDetail, error) { + // Build URL with pagination support + initialURL := api.sc.Endpoint + "servers/detail?all_tenants=true&tenant_id=" + projectID + var nextURL = &initialURL + var result []ServerDetail + + for nextURL != nil { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, *nextURL, http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("X-Auth-Token", api.sc.Token()) + req.Header.Set("X-OpenStack-Nova-API-Version", api.sc.Microversion) + + resp, err := api.sc.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Response structure with nested flavor + var list struct { + Servers []struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + TenantID string `json:"tenant_id"` + Created string `json:"created"` + AvailabilityZone string `json:"OS-EXT-AZ:availability_zone"` + Hypervisor string `json:"OS-EXT-SRV-ATTR:hypervisor_hostname"` + Flavor struct { + OriginalName string `json:"original_name"` + RAM uint64 `json:"ram"` + VCPUs uint64 `json:"vcpus"` + Disk uint64 `json:"disk"` + } `json:"flavor"` + } `json:"servers"` + Links []struct { + Rel string `json:"rel"` + Href string `json:"href"` + } `json:"servers_links"` + } + + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return nil, err + } + + // Convert to ServerDetail + for _, s := range list.Servers { + result = append(result, ServerDetail{ + ID: s.ID, + Name: s.Name, + Status: s.Status, + TenantID: s.TenantID, + Created: s.Created, + AvailabilityZone: s.AvailabilityZone, + Hypervisor: s.Hypervisor, + FlavorName: s.Flavor.OriginalName, + FlavorRAM: s.Flavor.RAM, + FlavorVCPUs: s.Flavor.VCPUs, + FlavorDisk: s.Flavor.Disk, + }) + } + + // Check for next page + nextURL = nil + for _, link := range list.Links { + if link.Rel == "next" { + nextURL = &link.Href + break + } + } + } + + slog.Info("fetched servers for project", "projectID", projectID, "count", len(result)) + return result, nil +} diff --git a/internal/scheduling/reservations/commitments/api.go b/internal/scheduling/reservations/commitments/api.go index eadaca37d..f8450ad8a 100644 --- a/internal/scheduling/reservations/commitments/api.go +++ b/internal/scheduling/reservations/commitments/api.go @@ -4,37 +4,54 @@ package commitments import ( + "context" "net/http" "sync" + "github.com/cobaltcore-dev/cortex/internal/scheduling/nova" "github.com/prometheus/client_golang/prometheus" "sigs.k8s.io/controller-runtime/pkg/client" ) +// UsageNovaClient is a minimal interface for the Nova client needed by the usage API. +// This allows for easy mocking in tests without implementing the full NovaClient interface. +type UsageNovaClient interface { + ListProjectServers(ctx context.Context, projectID string) ([]nova.ServerDetail, error) +} + // HTTPAPI implements Limes LIQUID commitment validation endpoints. type HTTPAPI struct { - client client.Client - config Config - monitor ChangeCommitmentsAPIMonitor + client client.Client + config Config + novaClient UsageNovaClient + monitor ChangeCommitmentsAPIMonitor + usageMonitor ReportUsageAPIMonitor + capacityMonitor ReportCapacityAPIMonitor // Mutex to serialize change-commitments requests changeMutex sync.Mutex } func NewAPI(client client.Client) *HTTPAPI { - return NewAPIWithConfig(client, DefaultConfig()) + return NewAPIWithConfig(client, DefaultConfig(), nil) } -func NewAPIWithConfig(client client.Client, config Config) *HTTPAPI { +func NewAPIWithConfig(client client.Client, config Config, novaClient UsageNovaClient) *HTTPAPI { return &HTTPAPI{ - client: client, - config: config, - monitor: NewChangeCommitmentsAPIMonitor(), + client: client, + config: config, + novaClient: novaClient, + monitor: NewChangeCommitmentsAPIMonitor(), + usageMonitor: NewReportUsageAPIMonitor(), + capacityMonitor: NewReportCapacityAPIMonitor(), } } func (api *HTTPAPI) Init(mux *http.ServeMux, registry prometheus.Registerer) { registry.MustRegister(&api.monitor) + registry.MustRegister(&api.usageMonitor) + registry.MustRegister(&api.capacityMonitor) mux.HandleFunc("/v1/commitments/change-commitments", api.HandleChangeCommitments) mux.HandleFunc("/v1/commitments/report-capacity", api.HandleReportCapacity) mux.HandleFunc("/v1/commitments/info", api.HandleInfo) + mux.HandleFunc("/v1/commitments/projects/", api.HandleReportUsage) // matches /v1/commitments/projects/:project_id/report-usage } diff --git a/internal/scheduling/reservations/commitments/api_change_commitments_test.go b/internal/scheduling/reservations/commitments/api_change_commitments_test.go index bf0a8cf8f..4d378af55 100644 --- a/internal/scheduling/reservations/commitments/api_change_commitments_test.go +++ b/internal/scheduling/reservations/commitments/api_change_commitments_test.go @@ -991,7 +991,7 @@ func newCommitmentTestEnv( // Use custom config if provided, otherwise use default var api *HTTPAPI if customConfig != nil { - api = NewAPIWithConfig(wrappedClient, *customConfig) + api = NewAPIWithConfig(wrappedClient, *customConfig, nil) } else { api = NewAPI(wrappedClient) } diff --git a/internal/scheduling/reservations/commitments/api_info.go b/internal/scheduling/reservations/commitments/api_info.go index c7dfe9cd4..71a84feb4 100644 --- a/internal/scheduling/reservations/commitments/api_info.go +++ b/internal/scheduling/reservations/commitments/api_info.go @@ -72,7 +72,7 @@ func (api *HTTPAPI) buildServiceInfo(ctx context.Context, logger logr.Logger) (l // Build resources map resources := make(map[liquid.ResourceName]liquid.ResourceInfo) for groupName, groupData := range flavorGroups { - resourceName := liquid.ResourceName("ram_" + groupName) + resourceName := liquid.ResourceName(commitmentResourceNamePrefix + groupName) flavorNames := make([]string, 0, len(groupData.Flavors)) for _, flavor := range groupData.Flavors { diff --git a/internal/scheduling/reservations/commitments/api_report_capacity.go b/internal/scheduling/reservations/commitments/api_report_capacity.go index f526dbd2c..2f7618ced 100644 --- a/internal/scheduling/reservations/commitments/api_report_capacity.go +++ b/internal/scheduling/reservations/commitments/api_report_capacity.go @@ -6,21 +6,28 @@ package commitments import ( "encoding/json" "net/http" + "strconv" + "time" "github.com/sapcc/go-api-declarations/liquid" ) -// handles POST /v1/report-capacity requests from Limes: +// handles POST /v1/commitments/report-capacity requests from Limes: // See: https://github.com/sapcc/go-api-declarations/blob/main/liquid/commitment.go // See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid // Reports available capacity across all flavor group resources. Note, unit is specified in the Info API response with multiple of the smallest memory resource unit within a flavor group. func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + statusCode := http.StatusOK + ctx := WithNewGlobalRequestID(r.Context()) - logger := LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/v1/report-capacity") + logger := LoggerFromContext(ctx).WithValues("component", "api", "endpoint", "/v1/commitments/report-capacity") // Only accept POST method if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + statusCode = http.StatusMethodNotAllowed + http.Error(w, "Method not allowed", statusCode) + api.recordCapacityMetrics(statusCode, startTime) return } @@ -38,8 +45,9 @@ func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request) report, err := calculator.CalculateCapacity(ctx) if err != nil { logger.Error(err, "failed to calculate capacity") - http.Error(w, "Failed to calculate capacity: "+err.Error(), - http.StatusInternalServerError) + statusCode = http.StatusInternalServerError + http.Error(w, "Failed to calculate capacity: "+err.Error(), statusCode) + api.recordCapacityMetrics(statusCode, startTime) return } @@ -47,9 +55,17 @@ func (api *HTTPAPI) HandleReportCapacity(w http.ResponseWriter, r *http.Request) // Return response w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + w.WriteHeader(statusCode) if err := json.NewEncoder(w).Encode(report); err != nil { logger.Error(err, "failed to encode capacity report") - return } + api.recordCapacityMetrics(statusCode, startTime) +} + +// recordCapacityMetrics records Prometheus metrics for a report-capacity request. +func (api *HTTPAPI) recordCapacityMetrics(statusCode int, startTime time.Time) { + duration := time.Since(startTime).Seconds() + statusCodeStr := strconv.Itoa(statusCode) + api.capacityMonitor.requestCounter.WithLabelValues(statusCodeStr).Inc() + api.capacityMonitor.requestDuration.WithLabelValues(statusCodeStr).Observe(duration) } diff --git a/internal/scheduling/reservations/commitments/api_report_capacity_monitor.go b/internal/scheduling/reservations/commitments/api_report_capacity_monitor.go new file mode 100644 index 000000000..d484b6f27 --- /dev/null +++ b/internal/scheduling/reservations/commitments/api_report_capacity_monitor.go @@ -0,0 +1,41 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +// ReportCapacityAPIMonitor provides metrics for the CR report-capacity API. +type ReportCapacityAPIMonitor struct { + requestCounter *prometheus.CounterVec + requestDuration *prometheus.HistogramVec +} + +// NewReportCapacityAPIMonitor creates a new monitor with Prometheus metrics. +func NewReportCapacityAPIMonitor() ReportCapacityAPIMonitor { + return ReportCapacityAPIMonitor{ + requestCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "cortex_committed_resource_capacity_api_requests_total", + Help: "Total number of committed resource capacity API requests by HTTP status code", + }, []string{"status_code"}), + requestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "cortex_committed_resource_capacity_api_request_duration_seconds", + Help: "Duration of committed resource capacity API requests in seconds by HTTP status code", + Buckets: []float64{0.1, 0.25, 0.5, 1, 2.5, 5, 10}, + }, []string{"status_code"}), + } +} + +// Describe implements prometheus.Collector. +func (m *ReportCapacityAPIMonitor) Describe(ch chan<- *prometheus.Desc) { + m.requestCounter.Describe(ch) + m.requestDuration.Describe(ch) +} + +// Collect implements prometheus.Collector. +func (m *ReportCapacityAPIMonitor) Collect(ch chan<- prometheus.Metric) { + m.requestCounter.Collect(ch) + m.requestDuration.Collect(ch) +} diff --git a/internal/scheduling/reservations/commitments/api_report_usage.go b/internal/scheduling/reservations/commitments/api_report_usage.go new file mode 100644 index 000000000..86b514d87 --- /dev/null +++ b/internal/scheduling/reservations/commitments/api_report_usage.go @@ -0,0 +1,105 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/sapcc/go-api-declarations/liquid" +) + +// HandleReportUsage implements POST /v1/commitments/projects/:project_id/report-usage from Limes LIQUID API. +// See: https://github.com/sapcc/go-api-declarations/blob/main/liquid/report_usage.go +// See: https://pkg.go.dev/github.com/sapcc/go-api-declarations/liquid +// +// This endpoint reports usage information for a specific project's committed resources, +// including per-AZ usage, physical usage, and detailed VM subresources. +func (api *HTTPAPI) HandleReportUsage(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + statusCode := http.StatusOK + + requestID := r.Header.Get("X-Request-ID") + if requestID == "" { + requestID = fmt.Sprintf("req-%d", time.Now().UnixNano()) + } + log := baseLog.WithValues("requestID", requestID, "endpoint", "report-usage") + + if r.Method != http.MethodPost { + statusCode = http.StatusMethodNotAllowed + http.Error(w, "Method not allowed", statusCode) + api.recordUsageMetrics(statusCode, startTime) + return + } + + // Extract project UUID from URL path + // URL pattern: /v1/commitments/projects/:project_id/report-usage + projectID, err := extractProjectIDFromPath(r.URL.Path) + if err != nil { + log.Error(err, "failed to extract project ID from path") + statusCode = http.StatusBadRequest + http.Error(w, "Invalid URL path: "+err.Error(), statusCode) + api.recordUsageMetrics(statusCode, startTime) + return + } + + // Parse request body + var req liquid.ServiceUsageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error(err, "failed to decode request body") + statusCode = http.StatusBadRequest + http.Error(w, "Invalid request body: "+err.Error(), statusCode) + api.recordUsageMetrics(statusCode, startTime) + return + } + + // Use UsageCalculator to build usage report + calculator := NewUsageCalculator(api.client, api.novaClient) + report, err := calculator.CalculateUsage(r.Context(), log, projectID, req.AllAZs) + if err != nil { + log.Error(err, "failed to calculate usage report", "projectID", projectID) + statusCode = http.StatusInternalServerError + http.Error(w, "Failed to generate usage report: "+err.Error(), statusCode) + api.recordUsageMetrics(statusCode, startTime) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(report); err != nil { + log.Error(err, "failed to encode usage report") + } + api.recordUsageMetrics(statusCode, startTime) +} + +// recordUsageMetrics records Prometheus metrics for a report-usage request. +func (api *HTTPAPI) recordUsageMetrics(statusCode int, startTime time.Time) { + duration := time.Since(startTime).Seconds() + statusCodeStr := strconv.Itoa(statusCode) + api.usageMonitor.requestCounter.WithLabelValues(statusCodeStr).Inc() + api.usageMonitor.requestDuration.WithLabelValues(statusCodeStr).Observe(duration) +} + +// extractProjectIDFromPath extracts the project UUID from the URL path. +// Expected path format: /v1/commitments/projects/:project_id/report-usage +func extractProjectIDFromPath(path string) (string, error) { + // Path: /v1/commitments/projects//report-usage + parts := strings.Split(strings.Trim(path, "/"), "/") + // Expected: ["v1", "commitments", "projects", "", "report-usage"] + if len(parts) < 5 { + return "", fmt.Errorf("path too short: %s", path) + } + if parts[2] != "projects" || parts[4] != "report-usage" { + return "", fmt.Errorf("unexpected path format: %s", path) + } + projectID := parts[3] + if projectID == "" { + return "", fmt.Errorf("empty project ID in path: %s", path) + } + return projectID, nil +} diff --git a/internal/scheduling/reservations/commitments/api_report_usage_monitor.go b/internal/scheduling/reservations/commitments/api_report_usage_monitor.go new file mode 100644 index 000000000..d3fc68018 --- /dev/null +++ b/internal/scheduling/reservations/commitments/api_report_usage_monitor.go @@ -0,0 +1,41 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "github.com/prometheus/client_golang/prometheus" +) + +// ReportUsageAPIMonitor provides metrics for the CR report-usage API. +type ReportUsageAPIMonitor struct { + requestCounter *prometheus.CounterVec + requestDuration *prometheus.HistogramVec +} + +// NewReportUsageAPIMonitor creates a new monitor with Prometheus metrics. +func NewReportUsageAPIMonitor() ReportUsageAPIMonitor { + return ReportUsageAPIMonitor{ + requestCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "cortex_committed_resource_usage_api_requests_total", + Help: "Total number of committed resource usage API requests by HTTP status code", + }, []string{"status_code"}), + requestDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "cortex_committed_resource_usage_api_request_duration_seconds", + Help: "Duration of committed resource usage API requests in seconds by HTTP status code", + Buckets: []float64{0.1, 0.25, 0.5, 1, 2.5, 5, 10}, + }, []string{"status_code"}), + } +} + +// Describe implements prometheus.Collector. +func (m *ReportUsageAPIMonitor) Describe(ch chan<- *prometheus.Desc) { + m.requestCounter.Describe(ch) + m.requestDuration.Describe(ch) +} + +// Collect implements prometheus.Collector. +func (m *ReportUsageAPIMonitor) Collect(ch chan<- prometheus.Metric) { + m.requestCounter.Collect(ch) + m.requestDuration.Collect(ch) +} diff --git a/internal/scheduling/reservations/commitments/api_report_usage_test.go b/internal/scheduling/reservations/commitments/api_report_usage_test.go new file mode 100644 index 000000000..0500735b1 --- /dev/null +++ b/internal/scheduling/reservations/commitments/api_report_usage_test.go @@ -0,0 +1,800 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" + "github.com/cobaltcore-dev/cortex/internal/scheduling/nova" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "github.com/prometheus/client_golang/prometheus" + "github.com/sapcc/go-api-declarations/liquid" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +// ============================================================================ +// Integration Tests for Usage API +// ============================================================================ + +func TestReportUsageIntegration(t *testing.T) { + // Flavor definitions - smallest flavor in group determines the "unit" + // hana_1 group: smallest = 1024 MB, so 1 unit = 1 GB + m1Small := &TestFlavor{Name: "m1.small", Group: "hana_1", MemoryMB: 1024, VCPUs: 4} // 1 unit + m1Large := &TestFlavor{Name: "m1.large", Group: "hana_1", MemoryMB: 4096, VCPUs: 16} // 4 units + m1XL := &TestFlavor{Name: "m1.xl", Group: "hana_1", MemoryMB: 8192, VCPUs: 32} // 8 units + + // gp_1 group: smallest = 512 MB, so 1 unit = 0.5 GB + gpSmall := &TestFlavor{Name: "gp.small", Group: "gp_1", MemoryMB: 512, VCPUs: 1} // 1 unit + gpMedium := &TestFlavor{Name: "gp.medium", Group: "gp_1", MemoryMB: 2048, VCPUs: 4} // 4 units + + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + testCases := []UsageReportTestCase{ + { + Name: "Empty project - no VMs, no commitments", + ProjectID: "project-empty", + Flavors: []*TestFlavor{m1Small}, + VMs: []*TestVMUsage{}, + Reservations: []*UsageTestReservation{}, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": {Usage: 0, VMs: []ExpectedVMUsage{}}, + }, + }, + }, + }, + { + Name: "Single VM with matching commitment - fully assigned", + ProjectID: "project-A", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-001", m1Large, "project-A", "az-a", "host-1", baseTime), + }, + Reservations: []*UsageTestReservation{ + // 4 units capacity (4 × 1024 MB) + {CommitmentID: "commit-1", Flavor: m1Small, ProjectID: "project-A", AZ: "az-a", Count: 4}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, // 4096 MB / 1024 MB = 4 units + VMs: []ExpectedVMUsage{ + {UUID: "vm-001", CommitmentID: "commit-1", MemoryMB: 4096}, + }, + }, + }, + }, + }, + }, + { + Name: "Single VM, no commitment - PAYG", + ProjectID: "project-B", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-002", m1Large, "project-B", "az-a", "host-1", baseTime), + }, + Reservations: []*UsageTestReservation{}, // No commitments + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, + VMs: []ExpectedVMUsage{ + {UUID: "vm-002", CommitmentID: "", MemoryMB: 4096}, // PAYG + }, + }, + }, + }, + }, + }, + { + Name: "VM overflow to PAYG when commitment capacity exhausted", + ProjectID: "project-C", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + // 3 VMs × 4 units = 12 units total + newTestVMUsage("vm-001", m1Large, "project-C", "az-a", "host-1", baseTime), + newTestVMUsage("vm-002", m1Large, "project-C", "az-a", "host-2", baseTime.Add(1*time.Hour)), + newTestVMUsage("vm-003", m1Large, "project-C", "az-a", "host-3", baseTime.Add(2*time.Hour)), + }, + Reservations: []*UsageTestReservation{ + // Only 8 units capacity (8 × 1024 MB = 8 GB) + {CommitmentID: "commit-1", Flavor: m1Small, ProjectID: "project-C", AZ: "az-a", Count: 8}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 12, // 12 units total + VMs: []ExpectedVMUsage{ + {UUID: "vm-001", CommitmentID: "commit-1", MemoryMB: 4096}, // 4 units → commit-1 (4/8) + {UUID: "vm-002", CommitmentID: "commit-1", MemoryMB: 4096}, // 4 units → commit-1 (8/8) + {UUID: "vm-003", CommitmentID: "", MemoryMB: 4096}, // 4 units → PAYG (overflow) + }, + }, + }, + }, + }, + }, + { + Name: "Deterministic ordering - oldest VMs assigned first", + ProjectID: "project-D", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + // VMs with different creation times - newest first in input (should be reordered) + newTestVMUsage("vm-newest", m1Large, "project-D", "az-a", "host-1", baseTime.Add(2*time.Hour)), + newTestVMUsage("vm-oldest", m1Large, "project-D", "az-a", "host-2", baseTime), + newTestVMUsage("vm-middle", m1Large, "project-D", "az-a", "host-3", baseTime.Add(1*time.Hour)), + }, + Reservations: []*UsageTestReservation{ + // Only 4 units capacity - only oldest VM should be assigned + {CommitmentID: "commit-1", Flavor: m1Small, ProjectID: "project-D", AZ: "az-a", Count: 4}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 12, + VMs: []ExpectedVMUsage{ + {UUID: "vm-oldest", CommitmentID: "commit-1", MemoryMB: 4096}, // Oldest → assigned + {UUID: "vm-middle", CommitmentID: "", MemoryMB: 4096}, // PAYG + {UUID: "vm-newest", CommitmentID: "", MemoryMB: 4096}, // PAYG + }, + }, + }, + }, + }, + }, + { + Name: "Same creation time - largest VMs assigned first", + ProjectID: "project-E", + Flavors: []*TestFlavor{m1Small, m1Large, m1XL}, + VMs: []*TestVMUsage{ + // All same creation time, different sizes + newTestVMUsage("vm-small", m1Small, "project-E", "az-a", "host-1", baseTime), // 1 unit + newTestVMUsage("vm-large", m1Large, "project-E", "az-a", "host-2", baseTime), // 4 units + newTestVMUsage("vm-xl", m1XL, "project-E", "az-a", "host-3", baseTime), // 8 units + }, + Reservations: []*UsageTestReservation{ + // 8 units capacity - only xl fits exactly + {CommitmentID: "commit-1", Flavor: m1Small, ProjectID: "project-E", AZ: "az-a", Count: 8}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 13, // 1 + 4 + 8 = 13 units + VMs: []ExpectedVMUsage{ + {UUID: "vm-xl", CommitmentID: "commit-1", MemoryMB: 8192}, // Largest → assigned (8/8) + {UUID: "vm-large", CommitmentID: "", MemoryMB: 4096}, // PAYG + {UUID: "vm-small", CommitmentID: "", MemoryMB: 1024}, // PAYG + }, + }, + }, + }, + }, + }, + { + Name: "Multiple commitments - fill oldest commitment first", + ProjectID: "project-F", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-001", m1Large, "project-F", "az-a", "host-1", baseTime), + newTestVMUsage("vm-002", m1Large, "project-F", "az-a", "host-2", baseTime.Add(1*time.Hour)), + }, + Reservations: []*UsageTestReservation{ + // Two commitments, 4 units each + {CommitmentID: "commit-old", Flavor: m1Small, ProjectID: "project-F", AZ: "az-a", Count: 4, StartTime: baseTime.Add(-2 * time.Hour)}, + {CommitmentID: "commit-new", Flavor: m1Small, ProjectID: "project-F", AZ: "az-a", Count: 4, StartTime: baseTime.Add(-1 * time.Hour)}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 8, + VMs: []ExpectedVMUsage{ + {UUID: "vm-001", CommitmentID: "commit-old", MemoryMB: 4096}, // → oldest commitment + {UUID: "vm-002", CommitmentID: "commit-new", MemoryMB: 4096}, // → newer commitment + }, + }, + }, + }, + }, + }, + { + Name: "Multi-AZ - VMs in different AZs assigned separately", + ProjectID: "project-G", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-az-a", m1Large, "project-G", "az-a", "host-1", baseTime), + newTestVMUsage("vm-az-b", m1Large, "project-G", "az-b", "host-2", baseTime), + }, + Reservations: []*UsageTestReservation{ + // Commitment only in az-a + {CommitmentID: "commit-a", Flavor: m1Small, ProjectID: "project-G", AZ: "az-a", Count: 4}, + }, + AllAZs: []string{"az-a", "az-b"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, + VMs: []ExpectedVMUsage{ + {UUID: "vm-az-a", CommitmentID: "commit-a", MemoryMB: 4096}, + }, + }, + "az-b": { + Usage: 4, + VMs: []ExpectedVMUsage{ + {UUID: "vm-az-b", CommitmentID: "", MemoryMB: 4096}, // PAYG - no commitment in az-b + }, + }, + }, + }, + }, + }, + { + Name: "Multiple flavor groups - separate resources", + ProjectID: "project-H", + Flavors: []*TestFlavor{m1Small, m1Large, gpSmall, gpMedium}, + VMs: []*TestVMUsage{ + newTestVMUsage("vm-hana", m1Large, "project-H", "az-a", "host-1", baseTime), + newTestVMUsage("vm-gp", gpMedium, "project-H", "az-a", "host-2", baseTime), + }, + Reservations: []*UsageTestReservation{ + {CommitmentID: "commit-hana", Flavor: m1Small, ProjectID: "project-H", AZ: "az-a", Count: 4}, + {CommitmentID: "commit-gp", Flavor: gpSmall, ProjectID: "project-H", AZ: "az-a", Count: 4}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, // 4096 MB / 1024 MB = 4 units + VMs: []ExpectedVMUsage{ + {UUID: "vm-hana", CommitmentID: "commit-hana", MemoryMB: 4096}, + }, + }, + }, + }, + "ram_gp_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, // 2048 MB / 512 MB = 4 units + VMs: []ExpectedVMUsage{ + {UUID: "vm-gp", CommitmentID: "commit-gp", MemoryMB: 2048}, + }, + }, + }, + }, + }, + }, + { + Name: "Invalid project ID - 400 Bad Request", + ProjectID: "", + Flavors: []*TestFlavor{m1Small}, + VMs: []*TestVMUsage{}, + Reservations: []*UsageTestReservation{}, + AllAZs: []string{"az-a"}, + ExpectedStatusCode: http.StatusBadRequest, + }, + { + Name: "Method not POST - 405 Method Not Allowed", + ProjectID: "project-X", + UseGET: true, + Flavors: []*TestFlavor{m1Small}, + VMs: []*TestVMUsage{}, + Reservations: []*UsageTestReservation{}, + AllAZs: []string{"az-a"}, + ExpectedStatusCode: http.StatusMethodNotAllowed, + }, + { + Name: "VM with empty AZ - normalized to unknown", + ProjectID: "project-empty-az", + Flavors: []*TestFlavor{m1Small, m1Large}, + VMs: []*TestVMUsage{ + // VM with empty AZ (e.g., ERROR or BUILDING state VM not yet scheduled) + newTestVMUsageWithEmptyAZ("vm-error", m1Large, "project-empty-az", "host-1", baseTime), + // Normal VM with valid AZ + newTestVMUsage("vm-ok", m1Large, "project-empty-az", "az-a", "host-2", baseTime.Add(1*time.Hour)), + }, + Reservations: []*UsageTestReservation{ + // Commitment in az-a + {CommitmentID: "commit-1", Flavor: m1Small, ProjectID: "project-empty-az", AZ: "az-a", Count: 8}, + }, + AllAZs: []string{"az-a"}, + Expected: map[string]ExpectedResourceUsage{ + "ram_hana_1": { + PerAZ: map[string]ExpectedAZUsage{ + "az-a": { + Usage: 4, + VMs: []ExpectedVMUsage{ + {UUID: "vm-ok", CommitmentID: "commit-1", MemoryMB: 4096}, + }, + }, + "unknown": { + Usage: 4, // VM with empty AZ normalized to "unknown" + VMs: []ExpectedVMUsage{ + {UUID: "vm-error", CommitmentID: "", MemoryMB: 4096}, // PAYG - no commitment in "unknown" AZ + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + runUsageReportTest(t, tc) + }) + } +} + +// ============================================================================ +// Test Types +// ============================================================================ + +type UsageReportTestCase struct { + Name string + ProjectID string + UseGET bool // Use GET instead of POST + Flavors []*TestFlavor + VMs []*TestVMUsage + Reservations []*UsageTestReservation + AllAZs []string + Expected map[string]ExpectedResourceUsage + ExpectedStatusCode int // 0 means expect 200 OK +} + +// UsageTestReservation represents a commitment reservation for usage tests. +type UsageTestReservation struct { + CommitmentID string + Flavor *TestFlavor + ProjectID string + AZ string + Count int // Number of reservation slots to create + StartTime time.Time // For commitment ordering +} + +type TestVMUsage struct { + UUID string + Flavor *TestFlavor + ProjectID string + AZ string + Host string + CreatedAt time.Time +} + +func newTestVMUsage(uuid string, flavor *TestFlavor, projectID, az, host string, createdAt time.Time) *TestVMUsage { + return &TestVMUsage{ + UUID: uuid, + Flavor: flavor, + ProjectID: projectID, + AZ: az, + Host: host, + CreatedAt: createdAt, + } +} + +func newTestVMUsageWithEmptyAZ(uuid string, flavor *TestFlavor, projectID, host string, createdAt time.Time) *TestVMUsage { + return &TestVMUsage{ + UUID: uuid, + Flavor: flavor, + ProjectID: projectID, + AZ: "", // Empty AZ simulates ERROR/BUILDING state VMs + Host: host, + CreatedAt: createdAt, + } +} + +type ExpectedResourceUsage struct { + PerAZ map[string]ExpectedAZUsage +} + +type ExpectedAZUsage struct { + Usage uint64 // Usage in multiples of smallest flavor + VMs []ExpectedVMUsage +} + +type ExpectedVMUsage struct { + UUID string + CommitmentID string // Empty string = PAYG + MemoryMB uint64 // For verification +} + +// ============================================================================ +// Mock Nova Client +// ============================================================================ + +type mockUsageNovaClient struct { + servers map[string][]nova.ServerDetail // projectID -> servers + err error +} + +func newMockUsageNovaClient() *mockUsageNovaClient { + return &mockUsageNovaClient{ + servers: make(map[string][]nova.ServerDetail), + } +} + +func (m *mockUsageNovaClient) ListProjectServers(_ context.Context, projectID string) ([]nova.ServerDetail, error) { + if m.err != nil { + return nil, m.err + } + return m.servers[projectID], nil +} + +func (m *mockUsageNovaClient) addVM(vm *TestVMUsage) { + server := nova.ServerDetail{ + ID: vm.UUID, + Name: vm.UUID, + Status: "ACTIVE", + TenantID: vm.ProjectID, + Created: vm.CreatedAt.Format(time.RFC3339), + AvailabilityZone: vm.AZ, + Hypervisor: vm.Host, + FlavorName: vm.Flavor.Name, + FlavorRAM: uint64(vm.Flavor.MemoryMB), //nolint:gosec + FlavorVCPUs: uint64(vm.Flavor.VCPUs), //nolint:gosec + FlavorDisk: vm.Flavor.DiskGB, + } + m.servers[vm.ProjectID] = append(m.servers[vm.ProjectID], server) +} + +// ============================================================================ +// Test Environment for Usage API +// ============================================================================ + +type UsageTestEnv struct { + T *testing.T + Scheme *runtime.Scheme + K8sClient client.Client + NovaClient *mockUsageNovaClient + FlavorGroups FlavorGroupsKnowledge + HTTPServer *httptest.Server + API *HTTPAPI +} + +func newUsageTestEnv( + t *testing.T, + vms []*TestVMUsage, + reservations []*UsageTestReservation, + flavorGroups FlavorGroupsKnowledge, +) *UsageTestEnv { + + t.Helper() + + log.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(true))) + + scheme := runtime.NewScheme() + if err := v1alpha1.AddToScheme(scheme); err != nil { + t.Fatalf("Failed to add v1alpha1 scheme: %v", err) + } + if err := hv1.AddToScheme(scheme); err != nil { + t.Fatalf("Failed to add hv1 scheme: %v", err) + } + + // Convert test reservations to K8s objects + var k8sReservations []client.Object + reservationCounters := make(map[string]int) + for _, tr := range reservations { + for range tr.Count { + number := reservationCounters[tr.CommitmentID] + reservationCounters[tr.CommitmentID]++ + k8sRes := tr.toK8sReservation(number) + k8sReservations = append(k8sReservations, k8sRes) + } + } + + // Create Knowledge CRD + knowledgeCRD := createKnowledgeCRD(flavorGroups) + k8sReservations = append(k8sReservations, knowledgeCRD) + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(k8sReservations...). + WithStatusSubresource(&v1alpha1.Reservation{}). + WithStatusSubresource(&v1alpha1.Knowledge{}). + WithIndex(&v1alpha1.Reservation{}, "spec.type", func(obj client.Object) []string { + res := obj.(*v1alpha1.Reservation) + return []string{string(res.Spec.Type)} + }). + Build() + + // Create mock Nova client with VMs + novaClient := newMockUsageNovaClient() + for _, vm := range vms { + novaClient.addVM(vm) + } + + // Create API with mock Nova client + api := NewAPIWithConfig(k8sClient, DefaultConfig(), novaClient) + mux := http.NewServeMux() + registry := prometheus.NewRegistry() + api.Init(mux, registry) + httpServer := httptest.NewServer(mux) + + return &UsageTestEnv{ + T: t, + Scheme: scheme, + K8sClient: k8sClient, + NovaClient: novaClient, + FlavorGroups: flavorGroups, + HTTPServer: httpServer, + API: api, + } +} + +func (env *UsageTestEnv) Close() { + if env.HTTPServer != nil { + env.HTTPServer.Close() + } +} + +func (env *UsageTestEnv) CallReportUsageAPI(projectID string, allAZs []string, useGET bool) (report liquid.ServiceUsageReport, statusCode int) { + env.T.Helper() + + // Build request body + reqBody := liquid.ServiceUsageRequest{ + AllAZs: make([]liquid.AvailabilityZone, len(allAZs)), + } + for i, az := range allAZs { + reqBody.AllAZs[i] = liquid.AvailabilityZone(az) + } + reqJSON, err := json.Marshal(reqBody) + if err != nil { + env.T.Fatalf("Failed to marshal request: %v", err) + } + + // Build URL + url := env.HTTPServer.URL + "/v1/commitments/projects/" + projectID + "/report-usage" + + method := http.MethodPost + if useGET { + method = http.MethodGet + } + + req, err := http.NewRequest(method, url, bytes.NewReader(reqJSON)) //nolint:noctx + if err != nil { + env.T.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + env.T.Fatalf("Failed to make HTTP request: %v", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + env.T.Fatalf("Failed to read response body: %v", err) + } + + if resp.StatusCode == http.StatusOK { + if err := json.Unmarshal(respBytes, &report); err != nil { + env.T.Fatalf("Failed to unmarshal response: %v\nBody: %s", err, string(respBytes)) + } + } + + return report, resp.StatusCode +} + +// ============================================================================ +// Test Runner +// ============================================================================ + +func runUsageReportTest(t *testing.T, tc UsageReportTestCase) { + t.Helper() + + // Build flavor groups + var flavorInGroups []compute.FlavorInGroup + for _, f := range tc.Flavors { + flavorInGroups = append(flavorInGroups, f.ToFlavorInGroup()) + } + flavorGroups := TestFlavorGroup{ + infoVersion: 1234, + flavors: flavorInGroups, + }.ToFlavorGroupsKnowledge() + + // Create test environment + env := newUsageTestEnv(t, tc.VMs, tc.Reservations, flavorGroups) + defer env.Close() + + // Call API + report, statusCode := env.CallReportUsageAPI(tc.ProjectID, tc.AllAZs, tc.UseGET) + + // Check status code + expectedStatus := tc.ExpectedStatusCode + if expectedStatus == 0 { + expectedStatus = http.StatusOK + } + if statusCode != expectedStatus { + t.Errorf("Expected status code %d, got %d", expectedStatus, statusCode) + return + } + + // If not 200 OK, no need to verify response body + if expectedStatus != http.StatusOK { + return + } + + // Verify response + verifyUsageReport(t, tc, report, flavorGroups) +} + +func verifyUsageReport(t *testing.T, tc UsageReportTestCase, actual liquid.ServiceUsageReport, _ FlavorGroupsKnowledge) { + t.Helper() + + for resourceName, expectedResource := range tc.Expected { + actualResource, exists := actual.Resources[liquid.ResourceName(resourceName)] + if !exists { + t.Errorf("Resource %s not found in response", resourceName) + continue + } + + for azName, expectedAZ := range expectedResource.PerAZ { + az := liquid.AvailabilityZone(azName) + actualAZ, exists := actualResource.PerAZ[az] + if !exists { + t.Errorf("AZ %s not found in resource %s", azName, resourceName) + continue + } + + // Verify usage + if actualAZ.Usage != expectedAZ.Usage { + t.Errorf("Resource %s AZ %s: expected usage %d, got %d", + resourceName, azName, expectedAZ.Usage, actualAZ.Usage) + } + + // Verify VM count + if len(actualAZ.Subresources) != len(expectedAZ.VMs) { + t.Errorf("Resource %s AZ %s: expected %d VMs, got %d", + resourceName, azName, len(expectedAZ.VMs), len(actualAZ.Subresources)) + continue + } + + // Build actual VM map for comparison (parse attributes) + actualVMs := make(map[string]vmAttributes) + for _, sub := range actualAZ.Subresources { + var attrs vmAttributes + attrs.ID = sub.ID + if err := json.Unmarshal(sub.Attributes, &attrs); err != nil { + t.Errorf("Failed to unmarshal attributes for VM %s: %v", sub.ID, err) + continue + } + actualVMs[sub.ID] = attrs + } + + // Verify each expected VM + for _, expectedVM := range expectedAZ.VMs { + actualVM, exists := actualVMs[expectedVM.UUID] + if !exists { + t.Errorf("Resource %s AZ %s: VM %s not found", resourceName, azName, expectedVM.UUID) + continue + } + + // Verify commitment assignment + if actualVM.CommitmentID != expectedVM.CommitmentID { + if expectedVM.CommitmentID == "" { + t.Errorf("Resource %s AZ %s VM %s: expected PAYG (empty), got commitment %s", + resourceName, azName, expectedVM.UUID, actualVM.CommitmentID) + } else { + t.Errorf("Resource %s AZ %s VM %s: expected commitment %s, got %s", + resourceName, azName, expectedVM.UUID, expectedVM.CommitmentID, actualVM.CommitmentID) + } + } + + // Verify memory + if actualVM.RAM != expectedVM.MemoryMB { + t.Errorf("Resource %s AZ %s VM %s: expected RAM %d MB, got %d MB", + resourceName, azName, expectedVM.UUID, expectedVM.MemoryMB, actualVM.RAM) + } + } + } + } +} + +// vmAttributes is used to parse the subresource attributes JSON. +type vmAttributes struct { + ID string `json:"-"` // set from Subresource.ID + Name string `json:"name"` + Flavor string `json:"flavor"` + Status string `json:"status"` + Hypervisor string `json:"hypervisor"` + RAM uint64 `json:"ram"` + VCPU uint64 `json:"vcpu"` + Disk uint64 `json:"disk"` + CommitmentID string `json:"commitment_id,omitempty"` +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +// toK8sReservation converts a UsageTestReservation to a K8s Reservation. +func (tr *UsageTestReservation) toK8sReservation(number int) *v1alpha1.Reservation { + name := fmt.Sprintf("commitment-%s-%d", tr.CommitmentID, number) + + memoryMB := tr.Flavor.MemoryMB + + spec := v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + Resources: map[hv1.ResourceName]resource.Quantity{ + "memory": resource.MustParse(strconv.FormatInt(memoryMB, 10) + "Mi"), + "cpu": resource.MustParse(strconv.FormatInt(tr.Flavor.VCPUs, 10)), + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + CommitmentUUID: tr.CommitmentID, + ProjectID: tr.ProjectID, + ResourceName: tr.Flavor.Name, + ResourceGroup: tr.Flavor.Group, + Allocations: map[string]v1alpha1.CommittedResourceAllocation{}, + }, + } + + if tr.AZ != "" { + spec.AvailabilityZone = tr.AZ + } + + // Set StartTime for commitment ordering + if !tr.StartTime.IsZero() { + spec.StartTime = &metav1.Time{Time: tr.StartTime} + } + + status := v1alpha1.ReservationStatus{ + Conditions: []metav1.Condition{ + { + Type: v1alpha1.ReservationConditionReady, + Status: metav1.ConditionTrue, + Reason: "ReservationActive", + }, + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationStatus{ + Allocations: map[string]string{}, + }, + } + + labels := map[string]string{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + } + + return &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: labels, + CreationTimestamp: metav1.Time{Time: tr.StartTime}, + }, + Spec: spec, + Status: status, + } +} diff --git a/internal/scheduling/reservations/commitments/capacity.go b/internal/scheduling/reservations/commitments/capacity.go index b61a5369e..1dff26b71 100644 --- a/internal/scheduling/reservations/commitments/capacity.go +++ b/internal/scheduling/reservations/commitments/capacity.go @@ -54,7 +54,7 @@ func (c *CapacityCalculator) CalculateCapacity(ctx context.Context) (liquid.Serv } // Resource name follows pattern: ram_ - resourceName := liquid.ResourceName("ram_" + groupName) + resourceName := liquid.ResourceName(commitmentResourceNamePrefix + groupName) // Calculate per-AZ capacity and usage azCapacity, err := c.calculateAZCapacity(ctx, groupName, groupData) diff --git a/internal/scheduling/reservations/commitments/state.go b/internal/scheduling/reservations/commitments/state.go index 11bbc4f1d..dbc3ad72d 100644 --- a/internal/scheduling/reservations/commitments/state.go +++ b/internal/scheduling/reservations/commitments/state.go @@ -160,6 +160,41 @@ func FromChangeCommitmentTargetState( }, nil } +// CommitmentStateWithUsage extends CommitmentState with usage tracking for billing calculations. +// Used by the report-usage API to track remaining capacity during VM-to-commitment assignment. +type CommitmentStateWithUsage struct { + CommitmentState + // RemainingMemoryBytes is the uncommitted capacity left for VM assignment + RemainingMemoryBytes int64 + // AssignedVMs tracks which VMs have been assigned to this commitment + AssignedVMs []string +} + +// NewCommitmentStateWithUsage creates a CommitmentStateWithUsage from a CommitmentState. +func NewCommitmentStateWithUsage(state *CommitmentState) *CommitmentStateWithUsage { + return &CommitmentStateWithUsage{ + CommitmentState: *state, + RemainingMemoryBytes: state.TotalMemoryBytes, + AssignedVMs: []string{}, + } +} + +// AssignVM attempts to assign a VM to this commitment if there's enough capacity. +// Returns true if the VM was assigned, false if not enough capacity. +func (c *CommitmentStateWithUsage) AssignVM(vmUUID string, vmMemoryBytes int64) bool { + if c.RemainingMemoryBytes >= vmMemoryBytes { + c.RemainingMemoryBytes -= vmMemoryBytes + c.AssignedVMs = append(c.AssignedVMs, vmUUID) + return true + } + return false +} + +// HasRemainingCapacity returns true if the commitment has any remaining capacity. +func (c *CommitmentStateWithUsage) HasRemainingCapacity() bool { + return c.RemainingMemoryBytes > 0 +} + // FromReservations reconstructs CommitmentState from existing Reservation CRDs. func FromReservations(reservations []v1alpha1.Reservation) (*CommitmentState, error) { if len(reservations) == 0 { diff --git a/internal/scheduling/reservations/commitments/usage.go b/internal/scheduling/reservations/commitments/usage.go new file mode 100644 index 000000000..d37b35a0a --- /dev/null +++ b/internal/scheduling/reservations/commitments/usage.go @@ -0,0 +1,456 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +package commitments + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" + "github.com/cobaltcore-dev/cortex/internal/scheduling/reservations" + "github.com/go-logr/logr" + . "github.com/majewsky/gg/option" + "github.com/sapcc/go-api-declarations/liquid" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// VMUsageInfo contains VM information needed for usage calculation. +// This is a local view of the VM enriched with flavor group information. +type VMUsageInfo struct { + UUID string + Name string + FlavorName string + FlavorGroup string + Status string + MemoryMB uint64 + VCPUs uint64 + DiskGB uint64 + AZ string + Hypervisor string + CreatedAt time.Time + UsageMultiple uint64 // Memory in multiples of smallest flavor in the group +} + +// UsageCalculator computes usage reports for Limes LIQUID API. +type UsageCalculator struct { + client client.Client + novaClient UsageNovaClient +} + +// NewUsageCalculator creates a new UsageCalculator instance. +func NewUsageCalculator(client client.Client, novaClient UsageNovaClient) *UsageCalculator { + return &UsageCalculator{ + client: client, + novaClient: novaClient, + } +} + +// CalculateUsage computes the usage report for a specific project. +func (c *UsageCalculator) CalculateUsage( + ctx context.Context, + log logr.Logger, + projectID string, + allAZs []liquid.AvailabilityZone, +) (liquid.ServiceUsageReport, error) { + // Step 1: Get flavor groups from knowledge + knowledge := &reservations.FlavorGroupKnowledgeClient{Client: c.client} + flavorGroups, err := knowledge.GetAllFlavorGroups(ctx, nil) + if err != nil { + return liquid.ServiceUsageReport{}, fmt.Errorf("failed to get flavor groups: %w", err) + } + + // Step 2: Build commitment capacity map from K8s Reservation CRDs + commitmentsByAZFlavorGroup, err := c.buildCommitmentCapacityMap(ctx, log, projectID) + if err != nil { + return liquid.ServiceUsageReport{}, fmt.Errorf("failed to build commitment capacity map: %w", err) + } + + // Step 3: Get and sort VMs for the project + vms, err := c.getProjectVMs(ctx, log, projectID, flavorGroups, allAZs) + if err != nil { + return liquid.ServiceUsageReport{}, fmt.Errorf("failed to get project VMs: %w", err) + } + sortVMsForUsageCalculation(vms) + + // Step 4: Assign VMs to commitments + vmAssignments, assignedToCommitments := c.assignVMsToCommitments(vms, commitmentsByAZFlavorGroup) + + // Step 5: Build the response + report := c.buildUsageResponse(vms, vmAssignments, flavorGroups, allAZs) + + log.Info("completed usage report", + "projectID", projectID, + "vmCount", len(vms), + "assignedToCommitments", assignedToCommitments, + "payg", len(vms)-assignedToCommitments, + "commitments", countCommitmentStates(commitmentsByAZFlavorGroup), + "resources", len(report.Resources)) + + return report, nil +} + +// azFlavorGroupKey creates a deterministic key for az:flavorGroup lookups. +func azFlavorGroupKey(az, flavorGroup string) string { + return az + ":" + flavorGroup +} + +// buildCommitmentCapacityMap retrieves all CR reservations for a project and builds +// a map of az:flavorGroup -> list of CommitmentStateWithUsage, sorted for deterministic assignment. +func (c *UsageCalculator) buildCommitmentCapacityMap( + ctx context.Context, + log logr.Logger, + projectID string, +) (map[string][]*CommitmentStateWithUsage, error) { + // List all committed resource reservations + var allReservations v1alpha1.ReservationList + if err := c.client.List(ctx, &allReservations, client.MatchingLabels{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + }); err != nil { + return nil, fmt.Errorf("failed to list reservations: %w", err) + } + + // Group reservations by commitment UUID, filtering by project + reservationsByCommitment := make(map[string][]v1alpha1.Reservation) + for _, res := range allReservations.Items { + if res.Spec.CommittedResourceReservation == nil { + continue + } + if res.Spec.CommittedResourceReservation.ProjectID != projectID { + continue + } + commitmentUUID := res.Spec.CommittedResourceReservation.CommitmentUUID + reservationsByCommitment[commitmentUUID] = append(reservationsByCommitment[commitmentUUID], res) + } + + // Build CommitmentState for each commitment and group by az:flavorGroup + // Only include commitments that are currently active (started and not expired) + now := time.Now() + result := make(map[string][]*CommitmentStateWithUsage) + for _, reservations := range reservationsByCommitment { + state, err := FromReservations(reservations) + if err != nil { + log.Error(err, "failed to build commitment state from reservations") + continue + } + + // Skip commitments that haven't started yet + if state.StartTime != nil && state.StartTime.After(now) { + log.V(1).Info("skipping commitment that hasn't started yet", + "commitmentUUID", state.CommitmentUUID, + "startTime", state.StartTime) + continue + } + + // Skip commitments that have already expired + if state.EndTime != nil && state.EndTime.Before(now) { + log.V(1).Info("skipping expired commitment", + "commitmentUUID", state.CommitmentUUID, + "endTime", state.EndTime) + continue + } + + stateWithUsage := NewCommitmentStateWithUsage(state) + key := azFlavorGroupKey(state.AvailabilityZone, state.FlavorGroupName) + result[key] = append(result[key], stateWithUsage) + } + + // Sort commitments within each az:flavorGroup for deterministic assignment + for key := range result { + sortCommitmentsForAssignment(result[key]) + } + + return result, nil +} + +// getProjectVMs retrieves all VMs for a project from Nova and enriches them with flavor group info. +func (c *UsageCalculator) getProjectVMs( + ctx context.Context, + log logr.Logger, + projectID string, + flavorGroups map[string]compute.FlavorGroupFeature, + allAZs []liquid.AvailabilityZone, +) ([]VMUsageInfo, error) { + + if c.novaClient == nil { + log.Info("Nova client not configured - returning empty VM list", "projectID", projectID) + return []VMUsageInfo{}, nil + } + + // Query VMs from Nova + servers, err := c.novaClient.ListProjectServers(ctx, projectID) + if err != nil { + return nil, fmt.Errorf("failed to list servers from Nova: %w", err) + } + + // Build flavor name -> flavor group lookup + flavorToGroup := make(map[string]string) + flavorToSmallestMemory := make(map[string]uint64) // for calculating usage multiples + for groupName, group := range flavorGroups { + for _, flavor := range group.Flavors { + flavorToGroup[flavor.Name] = groupName + } + // Smallest flavor in group determines the usage unit + if group.SmallestFlavor.Name != "" { + for _, flavor := range group.Flavors { + flavorToSmallestMemory[flavor.Name] = group.SmallestFlavor.MemoryMB + } + } + } + + // Convert to VMUsageInfo + var vms []VMUsageInfo + for _, server := range servers { + // Parse creation time (Nova returns ISO 8601/RFC3339 format) + createdAt, err := time.Parse(time.RFC3339, server.Created) + if err != nil { + log.V(1).Info("failed to parse server creation time, using zero time", + "server", server.ID, "created", server.Created, "error", err.Error()) + createdAt = time.Time{} + } + + // Determine flavor group + flavorGroup := flavorToGroup[server.FlavorName] + + // Calculate usage multiple (memory in units of smallest flavor) + var usageMultiple uint64 + if smallestMem := flavorToSmallestMemory[server.FlavorName]; smallestMem > 0 { + usageMultiple = (server.FlavorRAM + smallestMem - 1) / smallestMem // Round up + } + + // Normalize AZ - empty or unknown AZs become "unknown" (consistent with limes liquid-nova) + normalizedAZ := liquid.NormalizeAZ(server.AvailabilityZone, allAZs) + + vm := VMUsageInfo{ + UUID: server.ID, + Name: server.Name, + FlavorName: server.FlavorName, + FlavorGroup: flavorGroup, + Status: server.Status, + MemoryMB: server.FlavorRAM, + VCPUs: server.FlavorVCPUs, + DiskGB: server.FlavorDisk, + AZ: string(normalizedAZ), + Hypervisor: server.Hypervisor, + CreatedAt: createdAt, + UsageMultiple: usageMultiple, + } + + vms = append(vms, vm) + } + + return vms, nil +} + +// sortVMsForUsageCalculation sorts VMs deterministically for usage calculation: +// 1. Oldest first (by CreatedAt) +// 2. Largest first (by MemoryMB) +// 3. Tie-break by UUID +func sortVMsForUsageCalculation(vms []VMUsageInfo) { + sort.Slice(vms, func(i, j int) bool { + // 1. Oldest first + if !vms[i].CreatedAt.Equal(vms[j].CreatedAt) { + return vms[i].CreatedAt.Before(vms[j].CreatedAt) + } + // 2. Largest first + if vms[i].MemoryMB != vms[j].MemoryMB { + return vms[i].MemoryMB > vms[j].MemoryMB + } + // 3. Tie-break by UUID + return vms[i].UUID < vms[j].UUID + }) +} + +// sortCommitmentsForAssignment sorts commitments deterministically: +// 1. Oldest first (by StartTime) +// 2. Largest capacity first (by TotalMemoryBytes) +// 3. Tie-break by CommitmentUUID +func sortCommitmentsForAssignment(commitments []*CommitmentStateWithUsage) { + sort.Slice(commitments, func(i, j int) bool { + // 1. Oldest first (nil StartTime treated as very old) + iStart := commitments[i].StartTime + jStart := commitments[j].StartTime + iHasStart := iStart != nil + jHasStart := jStart != nil + switch { + case iHasStart && jHasStart: + if !iStart.Equal(*jStart) { + return iStart.Before(*jStart) + } + case iHasStart: + return false // j has nil, so j is "older" + case jHasStart: + return true // i has nil, so i is "older" + } + // 2. Largest capacity first + if commitments[i].TotalMemoryBytes != commitments[j].TotalMemoryBytes { + return commitments[i].TotalMemoryBytes > commitments[j].TotalMemoryBytes + } + // 3. Tie-break by UUID + return commitments[i].CommitmentUUID < commitments[j].CommitmentUUID + }) +} + +// assignVMsToCommitments assigns VMs to commitments based on az:flavorGroup matching. +// Returns a map of vmUUID -> commitmentUUID (empty string for PAYG VMs) and count of assigned VMs. +func (c *UsageCalculator) assignVMsToCommitments( + vms []VMUsageInfo, + commitmentsByAZFlavorGroup map[string][]*CommitmentStateWithUsage, +) (vmAssignments map[string]string, assignedCount int) { + + vmAssignments = make(map[string]string, len(vms)) + + for _, vm := range vms { + key := azFlavorGroupKey(vm.AZ, vm.FlavorGroup) + commitments := commitmentsByAZFlavorGroup[key] + + vmMemoryBytes := int64(vm.MemoryMB) * 1024 * 1024 //nolint:gosec // VM memory from Nova, realistically bounded + assigned := false + + // Try to assign to first commitment with remaining capacity + for _, commitment := range commitments { + if commitment.AssignVM(vm.UUID, vmMemoryBytes) { + vmAssignments[vm.UUID] = commitment.CommitmentUUID + assigned = true + assignedCount++ + break + } + } + + if !assigned { + // PAYG - no commitment assignment + vmAssignments[vm.UUID] = "" + } + } + + return vmAssignments, assignedCount +} + +// buildUsageResponse constructs the Liquid API ServiceUsageReport. +// Only flavor groups that accept commitments are included in the report. +func (c *UsageCalculator) buildUsageResponse( + vms []VMUsageInfo, + vmAssignments map[string]string, + flavorGroups map[string]compute.FlavorGroupFeature, + allAZs []liquid.AvailabilityZone, +) liquid.ServiceUsageReport { + // Initialize resources map for flavor groups that accept commitments + resources := make(map[liquid.ResourceName]*liquid.ResourceUsageReport) + + // Group VMs by flavor group and AZ for aggregation + type azUsageData struct { + usage uint64 + subresources []liquid.Subresource + } + usageByFlavorGroupAZ := make(map[string]map[liquid.AvailabilityZone]*azUsageData) + + for _, vm := range vms { + if vm.FlavorGroup == "" { + continue // Skip VMs without flavor group + } + + // Initialize maps if needed + if usageByFlavorGroupAZ[vm.FlavorGroup] == nil { + usageByFlavorGroupAZ[vm.FlavorGroup] = make(map[liquid.AvailabilityZone]*azUsageData) + } + az := liquid.AvailabilityZone(vm.AZ) + if usageByFlavorGroupAZ[vm.FlavorGroup][az] == nil { + usageByFlavorGroupAZ[vm.FlavorGroup][az] = &azUsageData{} + } + + // Accumulate usage + usageByFlavorGroupAZ[vm.FlavorGroup][az].usage += vm.UsageMultiple + + // Build subresource attributes + commitmentID := vmAssignments[vm.UUID] + attributes := buildVMAttributes(vm, commitmentID) + + subresource, err := liquid.SubresourceBuilder[map[string]any]{ + ID: vm.UUID, + Attributes: attributes, + }.Finalize() + if err != nil { + // This should never happen with valid attributes, skip this VM + continue + } + + usageByFlavorGroupAZ[vm.FlavorGroup][az].subresources = append( + usageByFlavorGroupAZ[vm.FlavorGroup][az].subresources, + subresource, + ) + } + + // Build ResourceUsageReport for each flavor group that accepts commitments + for flavorGroupName, groupData := range flavorGroups { + // Only report usage for flavor groups that accept commitments + if !FlavorGroupAcceptsCommitments(&groupData) { + continue + } + resourceName := liquid.ResourceName(commitmentResourceNamePrefix + flavorGroupName) + + perAZ := make(map[liquid.AvailabilityZone]*liquid.AZResourceUsageReport) + + // Initialize all AZs with zero usage + for _, az := range allAZs { + perAZ[az] = &liquid.AZResourceUsageReport{ + Usage: 0, + Subresources: []liquid.Subresource{}, + } + } + + // Fill in actual usage data + if azData, exists := usageByFlavorGroupAZ[flavorGroupName]; exists { + for az, data := range azData { + if _, known := perAZ[az]; !known { + // AZ not in allAZs, add it anyway + perAZ[az] = &liquid.AZResourceUsageReport{} + } + perAZ[az].Usage = data.usage + perAZ[az].PhysicalUsage = Some(data.usage) // No overcommit for RAM + perAZ[az].Subresources = data.subresources + } + } + + resources[resourceName] = &liquid.ResourceUsageReport{ + PerAZ: perAZ, + } + } + + return liquid.ServiceUsageReport{ + Resources: resources, + } +} + +// buildVMAttributes creates the attributes map for a VM subresource. +func buildVMAttributes(vm VMUsageInfo, commitmentID string) map[string]any { + attributes := map[string]any{ + "name": vm.Name, + "flavor": vm.FlavorName, + "status": vm.Status, + "hypervisor": vm.Hypervisor, + "ram": vm.MemoryMB, + "vcpu": vm.VCPUs, + "disk": vm.DiskGB, + } + + // Add commitment_id - nil for PAYG, string for committed + if commitmentID != "" { + attributes["commitment_id"] = commitmentID + } else { + attributes["commitment_id"] = nil + } + + return attributes +} + +// countCommitmentStates returns the total number of commitments across all az:flavorGroup keys. +func countCommitmentStates(m map[string][]*CommitmentStateWithUsage) int { + count := 0 + for _, list := range m { + count += len(list) + } + return count +} diff --git a/internal/scheduling/reservations/commitments/usage_test.go b/internal/scheduling/reservations/commitments/usage_test.go new file mode 100644 index 000000000..371667913 --- /dev/null +++ b/internal/scheduling/reservations/commitments/usage_test.go @@ -0,0 +1,779 @@ +// Copyright SAP SE +// SPDX-License-Identifier: Apache-2.0 + +//nolint:unparam,errcheck // test helper functions have fixed parameters for simplicity +package commitments + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" + + "github.com/cobaltcore-dev/cortex/api/v1alpha1" + "github.com/cobaltcore-dev/cortex/internal/knowledge/extractor/plugins/compute" + "github.com/cobaltcore-dev/cortex/internal/scheduling/nova" + hv1 "github.com/cobaltcore-dev/openstack-hypervisor-operator/api/v1" + "github.com/sapcc/go-api-declarations/liquid" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +// ============================================================================ +// Unit Tests for UsageCalculator +// ============================================================================ + +func TestUsageCalculator_CalculateUsage(t *testing.T) { + log.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(true))) + ctx := context.Background() + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + // Reuse TestFlavor from api_change_commitments_test.go + m1Small := &TestFlavor{Name: "m1.small", Group: "hana_1", MemoryMB: 1024, VCPUs: 4} + m1Large := &TestFlavor{Name: "m1.large", Group: "hana_1", MemoryMB: 4096, VCPUs: 16} + + tests := []struct { + name string + projectID string + vms []nova.ServerDetail + reservations []*v1alpha1.Reservation + allAZs []liquid.AvailabilityZone + expectedUsage map[string]uint64 // resourceName -> usage + }{ + { + name: "empty project", + projectID: "project-empty", + vms: []nova.ServerDetail{}, + reservations: []*v1alpha1.Reservation{}, + allAZs: []liquid.AvailabilityZone{"az-a"}, + expectedUsage: map[string]uint64{ + "ram_hana_1": 0, + }, + }, + { + name: "single VM with commitment", + projectID: "project-A", + vms: []nova.ServerDetail{ + { + ID: "vm-001", Name: "vm-001", Status: "ACTIVE", + TenantID: "project-A", AvailabilityZone: "az-a", + Created: baseTime.Format(time.RFC3339), + FlavorName: "m1.large", FlavorRAM: 4096, FlavorVCPUs: 16, + }, + }, + reservations: []*v1alpha1.Reservation{ + makeUsageTestReservation("commit-1", "project-A", "hana_1", "az-a", 1024*1024*1024, 0), + makeUsageTestReservation("commit-1", "project-A", "hana_1", "az-a", 1024*1024*1024, 1), + makeUsageTestReservation("commit-1", "project-A", "hana_1", "az-a", 1024*1024*1024, 2), + makeUsageTestReservation("commit-1", "project-A", "hana_1", "az-a", 1024*1024*1024, 3), + }, + allAZs: []liquid.AvailabilityZone{"az-a"}, + expectedUsage: map[string]uint64{ + "ram_hana_1": 4, // 4096 MB / 1024 MB = 4 units + }, + }, + { + name: "VM without matching commitment - PAYG", + projectID: "project-B", + vms: []nova.ServerDetail{ + { + ID: "vm-002", Name: "vm-002", Status: "ACTIVE", + TenantID: "project-B", AvailabilityZone: "az-a", + Created: baseTime.Format(time.RFC3339), + FlavorName: "m1.large", FlavorRAM: 4096, FlavorVCPUs: 16, + }, + }, + reservations: []*v1alpha1.Reservation{}, // No commitments + allAZs: []liquid.AvailabilityZone{"az-a"}, + expectedUsage: map[string]uint64{ + "ram_hana_1": 4, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup K8s client + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + _ = hv1.AddToScheme(scheme) + + objects := make([]client.Object, 0, len(tt.reservations)+1) + for _, r := range tt.reservations { + objects = append(objects, r) + } + + // Build flavor groups using existing test helpers + flavorGroups := TestFlavorGroup{ + infoVersion: 1234, + flavors: []compute.FlavorInGroup{m1Small.ToFlavorInGroup(), m1Large.ToFlavorInGroup()}, + }.ToFlavorGroupsKnowledge() + objects = append(objects, createKnowledgeCRD(flavorGroups)) + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + // Setup mock Nova client + novaClient := &mockUsageNovaClient{ + servers: map[string][]nova.ServerDetail{ + tt.projectID: tt.vms, + }, + } + + // Create calculator and run + calc := NewUsageCalculator(k8sClient, novaClient) + logger := log.FromContext(ctx) + report, err := calc.CalculateUsage(ctx, logger, tt.projectID, tt.allAZs) + if err != nil { + t.Fatalf("CalculateUsage failed: %v", err) + } + + // Verify resource count + if len(report.Resources) == 0 { + t.Error("Expected at least one resource in report") + } + + // Verify usage per resource + for resourceName, expectedUsage := range tt.expectedUsage { + res, ok := report.Resources[liquid.ResourceName(resourceName)] + if !ok { + t.Errorf("Resource %s not found", resourceName) + continue + } + + // Sum usage across all AZs + var totalUsage uint64 + for _, azReport := range res.PerAZ { + totalUsage += azReport.Usage + } + if totalUsage != expectedUsage { + t.Errorf("Resource %s: expected usage %d, got %d", resourceName, expectedUsage, totalUsage) + } + } + }) + } +} + +func TestSortVMsForUsageCalculation(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input []VMUsageInfo + expected []string // Expected order of UUIDs + }{ + { + name: "empty list", + input: []VMUsageInfo{}, + expected: []string{}, + }, + { + name: "sort by creation time - oldest first", + input: []VMUsageInfo{ + {UUID: "vm-newest", CreatedAt: baseTime.Add(2 * time.Hour), MemoryMB: 1024}, + {UUID: "vm-oldest", CreatedAt: baseTime, MemoryMB: 1024}, + {UUID: "vm-middle", CreatedAt: baseTime.Add(1 * time.Hour), MemoryMB: 1024}, + }, + expected: []string{"vm-oldest", "vm-middle", "vm-newest"}, + }, + { + name: "same creation time - largest first", + input: []VMUsageInfo{ + {UUID: "vm-small", CreatedAt: baseTime, MemoryMB: 1024}, + {UUID: "vm-large", CreatedAt: baseTime, MemoryMB: 8192}, + {UUID: "vm-medium", CreatedAt: baseTime, MemoryMB: 4096}, + }, + expected: []string{"vm-large", "vm-medium", "vm-small"}, + }, + { + name: "same time and size - sort by UUID", + input: []VMUsageInfo{ + {UUID: "vm-c", CreatedAt: baseTime, MemoryMB: 1024}, + {UUID: "vm-a", CreatedAt: baseTime, MemoryMB: 1024}, + {UUID: "vm-b", CreatedAt: baseTime, MemoryMB: 1024}, + }, + expected: []string{"vm-a", "vm-b", "vm-c"}, + }, + { + name: "mixed criteria", + input: []VMUsageInfo{ + {UUID: "vm-new-large", CreatedAt: baseTime.Add(1 * time.Hour), MemoryMB: 8192}, + {UUID: "vm-old-small", CreatedAt: baseTime, MemoryMB: 1024}, + {UUID: "vm-old-large", CreatedAt: baseTime, MemoryMB: 8192}, + }, + expected: []string{"vm-old-large", "vm-old-small", "vm-new-large"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sortVMsForUsageCalculation(tt.input) + + if len(tt.input) != len(tt.expected) { + t.Fatalf("Length mismatch: got %d, expected %d", len(tt.input), len(tt.expected)) + } + + for i, expectedUUID := range tt.expected { + if tt.input[i].UUID != expectedUUID { + t.Errorf("Position %d: expected %s, got %s", i, expectedUUID, tt.input[i].UUID) + } + } + }) + } +} + +func TestSortCommitmentsForAssignment(t *testing.T) { + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + time1 := baseTime + time2 := baseTime.Add(1 * time.Hour) + + tests := []struct { + name string + input []*CommitmentStateWithUsage + expected []string // Expected order of CommitmentUUIDs + }{ + { + name: "empty list", + input: []*CommitmentStateWithUsage{}, + expected: []string{}, + }, + { + name: "sort by start time - oldest first", + input: []*CommitmentStateWithUsage{ + {CommitmentState: CommitmentState{CommitmentUUID: "commit-new", StartTime: &time2, TotalMemoryBytes: 1024}}, + {CommitmentState: CommitmentState{CommitmentUUID: "commit-old", StartTime: &time1, TotalMemoryBytes: 1024}}, + }, + expected: []string{"commit-old", "commit-new"}, + }, + { + name: "nil start time treated as oldest", + input: []*CommitmentStateWithUsage{ + {CommitmentState: CommitmentState{CommitmentUUID: "commit-with-time", StartTime: &time1, TotalMemoryBytes: 1024}}, + {CommitmentState: CommitmentState{CommitmentUUID: "commit-no-time", StartTime: nil, TotalMemoryBytes: 1024}}, + }, + expected: []string{"commit-no-time", "commit-with-time"}, + }, + { + name: "same start time - largest first", + input: []*CommitmentStateWithUsage{ + {CommitmentState: CommitmentState{CommitmentUUID: "commit-small", StartTime: &time1, TotalMemoryBytes: 1024}}, + {CommitmentState: CommitmentState{CommitmentUUID: "commit-large", StartTime: &time1, TotalMemoryBytes: 8192}}, + }, + expected: []string{"commit-large", "commit-small"}, + }, + { + name: "same time and size - sort by UUID", + input: []*CommitmentStateWithUsage{ + {CommitmentState: CommitmentState{CommitmentUUID: "commit-c", StartTime: &time1, TotalMemoryBytes: 1024}}, + {CommitmentState: CommitmentState{CommitmentUUID: "commit-a", StartTime: &time1, TotalMemoryBytes: 1024}}, + {CommitmentState: CommitmentState{CommitmentUUID: "commit-b", StartTime: &time1, TotalMemoryBytes: 1024}}, + }, + expected: []string{"commit-a", "commit-b", "commit-c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sortCommitmentsForAssignment(tt.input) + + if len(tt.input) != len(tt.expected) { + t.Fatalf("Length mismatch: got %d, expected %d", len(tt.input), len(tt.expected)) + } + + for i, expectedUUID := range tt.expected { + if tt.input[i].CommitmentUUID != expectedUUID { + t.Errorf("Position %d: expected %s, got %s", i, expectedUUID, tt.input[i].CommitmentUUID) + } + } + }) + } +} + +func TestAzFlavorGroupKey(t *testing.T) { + tests := []struct { + az string + flavorGroup string + expected string + }{ + {"az-a", "hana_1", "az-a:hana_1"}, + {"", "hana_1", ":hana_1"}, + {"az-a", "", "az-a:"}, + {"", "", ":"}, + {"us-west-1a", "gpu_large_v2", "us-west-1a:gpu_large_v2"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := azFlavorGroupKey(tt.az, tt.flavorGroup) + if result != tt.expected { + t.Errorf("azFlavorGroupKey(%q, %q) = %q, expected %q", + tt.az, tt.flavorGroup, result, tt.expected) + } + }) + } +} + +func TestBuildVMAttributes(t *testing.T) { + vm := VMUsageInfo{ + UUID: "vm-123", + Name: "my-vm", + FlavorName: "m1.large", + Status: "ACTIVE", + Hypervisor: "host-1", + MemoryMB: 4096, + VCPUs: 16, + DiskGB: 100, + } + + t.Run("with commitment", func(t *testing.T) { + attrs := buildVMAttributes(vm, "commit-456") + + if attrs["name"] != "my-vm" { + t.Errorf("name = %v, expected my-vm", attrs["name"]) + } + if attrs["flavor"] != "m1.large" { + t.Errorf("flavor = %v, expected m1.large", attrs["flavor"]) + } + if attrs["status"] != "ACTIVE" { + t.Errorf("status = %v, expected ACTIVE", attrs["status"]) + } + if attrs["hypervisor"] != "host-1" { + t.Errorf("hypervisor = %v, expected host-1", attrs["hypervisor"]) + } + if attrs["ram"] != uint64(4096) { + t.Errorf("ram = %v, expected 4096", attrs["ram"]) + } + if attrs["vcpu"] != uint64(16) { + t.Errorf("vcpu = %v, expected 16", attrs["vcpu"]) + } + if attrs["disk"] != uint64(100) { + t.Errorf("disk = %v, expected 100", attrs["disk"]) + } + if attrs["commitment_id"] != "commit-456" { + t.Errorf("commitment_id = %v, expected commit-456", attrs["commitment_id"]) + } + }) + + t.Run("without commitment (PAYG)", func(t *testing.T) { + attrs := buildVMAttributes(vm, "") + + if attrs["commitment_id"] != nil { + t.Errorf("commitment_id = %v, expected nil", attrs["commitment_id"]) + } + }) +} + +func TestCountCommitmentStates(t *testing.T) { + tests := []struct { + name string + input map[string][]*CommitmentStateWithUsage + expected int + }{ + { + name: "empty map", + input: map[string][]*CommitmentStateWithUsage{}, + expected: 0, + }, + { + name: "single key with one commitment", + input: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": {{CommitmentState: CommitmentState{CommitmentUUID: "c1"}}}, + }, + expected: 1, + }, + { + name: "single key with multiple commitments", + input: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": { + {CommitmentState: CommitmentState{CommitmentUUID: "c1"}}, + {CommitmentState: CommitmentState{CommitmentUUID: "c2"}}, + {CommitmentState: CommitmentState{CommitmentUUID: "c3"}}, + }, + }, + expected: 3, + }, + { + name: "multiple keys", + input: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": { + {CommitmentState: CommitmentState{CommitmentUUID: "c1"}}, + {CommitmentState: CommitmentState{CommitmentUUID: "c2"}}, + }, + "az-b:hana_1": { + {CommitmentState: CommitmentState{CommitmentUUID: "c3"}}, + }, + "az-a:gp_1": { + {CommitmentState: CommitmentState{CommitmentUUID: "c4"}}, + {CommitmentState: CommitmentState{CommitmentUUID: "c5"}}, + }, + }, + expected: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := countCommitmentStates(tt.input) + if result != tt.expected { + t.Errorf("countCommitmentStates() = %d, expected %d", result, tt.expected) + } + }) + } +} + +func TestUsageCalculator_ExpiredAndFutureCommitments(t *testing.T) { + log.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(true))) + ctx := context.Background() + baseTime := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + now := time.Now() + + m1Small := &TestFlavor{Name: "m1.small", Group: "hana_1", MemoryMB: 1024, VCPUs: 4} + m1Large := &TestFlavor{Name: "m1.large", Group: "hana_1", MemoryMB: 4096, VCPUs: 16} + + tests := []struct { + name string + projectID string + vms []nova.ServerDetail + reservations []*v1alpha1.Reservation + allAZs []liquid.AvailabilityZone + expectedActiveCommitment string // non-empty if VM should be assigned to a commitment + }{ + { + name: "active commitment - within time range", + projectID: "project-A", + vms: []nova.ServerDetail{ + { + ID: "vm-001", Name: "vm-001", Status: "ACTIVE", + TenantID: "project-A", AvailabilityZone: "az-a", + Created: baseTime.Format(time.RFC3339), + FlavorName: "m1.large", FlavorRAM: 4096, FlavorVCPUs: 16, + }, + }, + reservations: func() []*v1alpha1.Reservation { + past := now.Add(-1 * time.Hour) + future := now.Add(1 * time.Hour) + return []*v1alpha1.Reservation{ + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 0, &past, &future), + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 1, &past, &future), + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 2, &past, &future), + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 3, &past, &future), + } + }(), + allAZs: []liquid.AvailabilityZone{"az-a"}, + expectedActiveCommitment: "commit-active", + }, + { + name: "expired commitment - should be ignored (VM goes to PAYG)", + projectID: "project-A", + vms: []nova.ServerDetail{ + { + ID: "vm-001", Name: "vm-001", Status: "ACTIVE", + TenantID: "project-A", AvailabilityZone: "az-a", + Created: baseTime.Format(time.RFC3339), + FlavorName: "m1.large", FlavorRAM: 4096, FlavorVCPUs: 16, + }, + }, + reservations: func() []*v1alpha1.Reservation { + past := now.Add(-2 * time.Hour) + expired := now.Add(-1 * time.Hour) // Already expired + return []*v1alpha1.Reservation{ + makeUsageTestReservationWithTimes("commit-expired", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 0, &past, &expired), + } + }(), + allAZs: []liquid.AvailabilityZone{"az-a"}, + expectedActiveCommitment: "", // PAYG - expired commitment ignored + }, + { + name: "future commitment - should be ignored (VM goes to PAYG)", + projectID: "project-A", + vms: []nova.ServerDetail{ + { + ID: "vm-001", Name: "vm-001", Status: "ACTIVE", + TenantID: "project-A", AvailabilityZone: "az-a", + Created: baseTime.Format(time.RFC3339), + FlavorName: "m1.large", FlavorRAM: 4096, FlavorVCPUs: 16, + }, + }, + reservations: func() []*v1alpha1.Reservation { + futureStart := now.Add(1 * time.Hour) // Hasn't started yet + futureEnd := now.Add(24 * time.Hour) + return []*v1alpha1.Reservation{ + makeUsageTestReservationWithTimes("commit-future", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 0, &futureStart, &futureEnd), + } + }(), + allAZs: []liquid.AvailabilityZone{"az-a"}, + expectedActiveCommitment: "", // PAYG - future commitment ignored + }, + { + name: "mixed - only active commitment is used", + projectID: "project-A", + vms: []nova.ServerDetail{ + { + ID: "vm-001", Name: "vm-001", Status: "ACTIVE", + TenantID: "project-A", AvailabilityZone: "az-a", + Created: baseTime.Format(time.RFC3339), + FlavorName: "m1.large", FlavorRAM: 4096, FlavorVCPUs: 16, + }, + }, + reservations: func() []*v1alpha1.Reservation { + // Expired commitment + expiredStart := now.Add(-48 * time.Hour) + expiredEnd := now.Add(-24 * time.Hour) + // Active commitment + activeStart := now.Add(-1 * time.Hour) + activeEnd := now.Add(24 * time.Hour) + // Future commitment + futureStart := now.Add(24 * time.Hour) + futureEnd := now.Add(48 * time.Hour) + + return []*v1alpha1.Reservation{ + makeUsageTestReservationWithTimes("commit-expired", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 0, &expiredStart, &expiredEnd), + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 0, &activeStart, &activeEnd), + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 1, &activeStart, &activeEnd), + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 2, &activeStart, &activeEnd), + makeUsageTestReservationWithTimes("commit-active", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 3, &activeStart, &activeEnd), + makeUsageTestReservationWithTimes("commit-future", "project-A", "hana_1", "az-a", 4*1024*1024*1024, 0, &futureStart, &futureEnd), + } + }(), + allAZs: []liquid.AvailabilityZone{"az-a"}, + expectedActiveCommitment: "commit-active", // Only active commitment is used + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + _ = v1alpha1.AddToScheme(scheme) + _ = hv1.AddToScheme(scheme) + + objects := make([]client.Object, 0, len(tt.reservations)+1) + for _, r := range tt.reservations { + objects = append(objects, r) + } + + flavorGroups := TestFlavorGroup{ + infoVersion: 1234, + flavors: []compute.FlavorInGroup{m1Small.ToFlavorInGroup(), m1Large.ToFlavorInGroup()}, + }.ToFlavorGroupsKnowledge() + objects = append(objects, createKnowledgeCRD(flavorGroups)) + + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + novaClient := &mockUsageNovaClient{ + servers: map[string][]nova.ServerDetail{ + tt.projectID: tt.vms, + }, + } + + calc := NewUsageCalculator(k8sClient, novaClient) + logger := log.FromContext(ctx) + report, err := calc.CalculateUsage(ctx, logger, tt.projectID, tt.allAZs) + if err != nil { + t.Fatalf("CalculateUsage failed: %v", err) + } + + // Find the VM in subresources and check its commitment assignment + res, ok := report.Resources["ram_hana_1"] + if !ok { + t.Fatal("Resource ram_hana_1 not found") + } + + var foundCommitment any + for _, azReport := range res.PerAZ { + for _, sub := range azReport.Subresources { + if sub.Attributes == nil { + continue + } + // Parse JSON attributes + var attrMap map[string]any + if err := json.Unmarshal(sub.Attributes, &attrMap); err != nil { + continue + } + foundCommitment = attrMap["commitment_id"] + } + } + + if tt.expectedActiveCommitment == "" { + // Expect PAYG (nil commitment_id) + if foundCommitment != nil { + t.Errorf("Expected PAYG (nil commitment_id), got %v", foundCommitment) + } + } else { + // Expect specific commitment + if foundCommitment != tt.expectedActiveCommitment { + t.Errorf("Expected commitment %s, got %v", tt.expectedActiveCommitment, foundCommitment) + } + } + }) + } +} + +func TestUsageCalculator_AssignVMsToCommitments(t *testing.T) { + tests := []struct { + name string + vms []VMUsageInfo + commitments map[string][]*CommitmentStateWithUsage + expectedAssignments map[string]string // vmUUID -> commitmentUUID (empty = PAYG) + expectedCount int + }{ + { + name: "no VMs", + vms: []VMUsageInfo{}, + commitments: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": {{CommitmentState: CommitmentState{CommitmentUUID: "c1"}, RemainingMemoryBytes: 4096 * 1024 * 1024}}, + }, + expectedAssignments: map[string]string{}, + expectedCount: 0, + }, + { + name: "no commitments - all PAYG", + vms: []VMUsageInfo{ + {UUID: "vm-1", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 1024}, + {UUID: "vm-2", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 1024}, + }, + commitments: map[string][]*CommitmentStateWithUsage{}, + expectedAssignments: map[string]string{ + "vm-1": "", + "vm-2": "", + }, + expectedCount: 0, + }, + { + name: "VM fits in commitment", + vms: []VMUsageInfo{ + {UUID: "vm-1", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 1024}, + }, + commitments: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": {{CommitmentState: CommitmentState{CommitmentUUID: "c1"}, RemainingMemoryBytes: 2 * 1024 * 1024 * 1024}}, + }, + expectedAssignments: map[string]string{ + "vm-1": "c1", + }, + expectedCount: 1, + }, + { + name: "VM does not fit - goes to PAYG", + vms: []VMUsageInfo{ + {UUID: "vm-1", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 4096}, + }, + commitments: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": {{CommitmentState: CommitmentState{CommitmentUUID: "c1"}, RemainingMemoryBytes: 1024 * 1024 * 1024}}, // Only 1GB capacity + }, + expectedAssignments: map[string]string{ + "vm-1": "", // PAYG - doesn't fit + }, + expectedCount: 0, + }, + { + name: "overflow to PAYG", + vms: []VMUsageInfo{ + {UUID: "vm-1", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 2048}, + {UUID: "vm-2", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 2048}, + {UUID: "vm-3", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 2048}, + }, + commitments: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": {{CommitmentState: CommitmentState{CommitmentUUID: "c1"}, RemainingMemoryBytes: 4 * 1024 * 1024 * 1024}}, // 4GB - fits 2 VMs + }, + expectedAssignments: map[string]string{ + "vm-1": "c1", + "vm-2": "c1", + "vm-3": "", // PAYG - no more capacity + }, + expectedCount: 2, + }, + { + name: "different AZs - separate assignment", + vms: []VMUsageInfo{ + {UUID: "vm-az-a", AZ: "az-a", FlavorGroup: "hana_1", MemoryMB: 1024}, + {UUID: "vm-az-b", AZ: "az-b", FlavorGroup: "hana_1", MemoryMB: 1024}, + }, + commitments: map[string][]*CommitmentStateWithUsage{ + "az-a:hana_1": {{CommitmentState: CommitmentState{CommitmentUUID: "c-az-a"}, RemainingMemoryBytes: 2 * 1024 * 1024 * 1024}}, + // No commitment in az-b + }, + expectedAssignments: map[string]string{ + "vm-az-a": "c-az-a", + "vm-az-b": "", // PAYG - no commitment in az-b + }, + expectedCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calc := &UsageCalculator{} + assignments, count := calc.assignVMsToCommitments(tt.vms, tt.commitments) + + if count != tt.expectedCount { + t.Errorf("assigned count = %d, expected %d", count, tt.expectedCount) + } + + for vmUUID, expectedCommitment := range tt.expectedAssignments { + actual, ok := assignments[vmUUID] + if !ok { + t.Errorf("VM %s not in assignments", vmUUID) + continue + } + if actual != expectedCommitment { + t.Errorf("VM %s: commitment = %q, expected %q", vmUUID, actual, expectedCommitment) + } + } + }) + } +} + +// ============================================================================ +// Helper Functions for Usage Tests +// ============================================================================ + +// makeUsageTestReservation creates a test reservation for UsageCalculator tests. +func makeUsageTestReservation(commitmentUUID, projectID, flavorGroup, az string, memoryBytes int64, slot int) *v1alpha1.Reservation { + return makeUsageTestReservationWithTimes(commitmentUUID, projectID, flavorGroup, az, memoryBytes, slot, nil, nil) +} + +// makeUsageTestReservationWithTimes creates a test reservation with start and end times. +func makeUsageTestReservationWithTimes(commitmentUUID, projectID, flavorGroup, az string, memoryBytes int64, slot int, startTime, endTime *time.Time) *v1alpha1.Reservation { + name := "commitment-" + commitmentUUID + "-" + string(rune('0'+slot)) + + res := &v1alpha1.Reservation{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + v1alpha1.LabelReservationType: v1alpha1.ReservationTypeLabelCommittedResource, + }, + }, + Spec: v1alpha1.ReservationSpec{ + Type: v1alpha1.ReservationTypeCommittedResource, + AvailabilityZone: az, + Resources: map[hv1.ResourceName]resource.Quantity{ + "memory": *resource.NewQuantity(memoryBytes, resource.BinarySI), + }, + CommittedResourceReservation: &v1alpha1.CommittedResourceReservationSpec{ + CommitmentUUID: commitmentUUID, + ProjectID: projectID, + ResourceGroup: flavorGroup, + }, + }, + } + + // StartTime and EndTime are on ReservationSpec, not CommittedResourceReservationSpec + if startTime != nil { + res.Spec.StartTime = &metav1.Time{Time: *startTime} + } + if endTime != nil { + res.Spec.EndTime = &metav1.Time{Time: *endTime} + } + + return res +}